mint_http 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 +7 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +34 -0
- data/LICENSE.txt +21 -0
- data/README.md +218 -0
- data/Rakefile +12 -0
- data/lib/mint_http/errors/authentication_error.rb +4 -0
- data/lib/mint_http/errors/authorization_error.rb +4 -0
- data/lib/mint_http/errors/client_error.rb +4 -0
- data/lib/mint_http/errors/not_found_error.rb +4 -0
- data/lib/mint_http/errors/response_error.rb +15 -0
- data/lib/mint_http/errors/server_error.rb +4 -0
- data/lib/mint_http/headers.rb +11 -0
- data/lib/mint_http/net_http_factory.rb +77 -0
- data/lib/mint_http/pool.rb +109 -0
- data/lib/mint_http/pool_entry.rb +72 -0
- data/lib/mint_http/request.rb +322 -0
- data/lib/mint_http/response.rb +76 -0
- data/lib/mint_http/version.rb +5 -0
- data/lib/mint_http.rb +25 -0
- data/sig/mint_http.rbs +4 -0
- metadata +137 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: '091443e652f456b8bd3d867b48379610f5a0405272158921c8e92f7885483aa4'
|
4
|
+
data.tar.gz: 42b042095a6a3bad22c22d5cc4da0b004463b9e5a30918701a837ee480e46a80
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: fe2c2aa2bb1984f361d8cc695a5e2e26cfa3d4b1479d746ec4ccd2ae2f8c1df765a11280796cf81b8812d1b6fa68cccd789b432c0c0d26226c88b8877d4cff97
|
7
|
+
data.tar.gz: 9e58beac30df8cb862ce02440c2132fc88f24da196e703219443959d668e5715a24a29e5dfd9b311ebcf7c4ec3dd3f02664473f18042942c8edd814c800a845e
|
data/CHANGELOG.md
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
mint_http (0.1.0)
|
5
|
+
base64
|
6
|
+
json
|
7
|
+
net-http
|
8
|
+
openssl
|
9
|
+
uri
|
10
|
+
|
11
|
+
GEM
|
12
|
+
remote: https://rubygems.org/
|
13
|
+
specs:
|
14
|
+
base64 (0.1.0)
|
15
|
+
ipaddr (1.2.4)
|
16
|
+
json (2.6.2)
|
17
|
+
minitest (5.18.0)
|
18
|
+
net-http (0.3.2)
|
19
|
+
uri
|
20
|
+
openssl (2.2.2)
|
21
|
+
ipaddr
|
22
|
+
rake (13.0.6)
|
23
|
+
uri (0.12.1)
|
24
|
+
|
25
|
+
PLATFORMS
|
26
|
+
arm64-darwin-22
|
27
|
+
|
28
|
+
DEPENDENCIES
|
29
|
+
minitest (~> 5.0)
|
30
|
+
mint_http!
|
31
|
+
rake (~> 13.0)
|
32
|
+
|
33
|
+
BUNDLED WITH
|
34
|
+
2.2.33
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2023 Ali Alhoshaiyan
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,218 @@
|
|
1
|
+
# MintHttp
|
2
|
+
|
3
|
+
A simple and fluent HTTP client with connection pooling capability.
|
4
|
+
|
5
|
+
MintHttp is built on top of Ruby's Net::HTTP library to provide you with the following features:
|
6
|
+
|
7
|
+
- Fluent API to build requests
|
8
|
+
- HTTP proxies support
|
9
|
+
- Client certificate support
|
10
|
+
- File uploads
|
11
|
+
- Connection pooling
|
12
|
+
- No DSLs or any other shenanigans
|
13
|
+
|
14
|
+
|
15
|
+
## Installation
|
16
|
+
|
17
|
+
Install the gem and add to the application's Gemfile by executing:
|
18
|
+
|
19
|
+
$ bundle add mint_http
|
20
|
+
|
21
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
22
|
+
|
23
|
+
$ gem install mint_http
|
24
|
+
|
25
|
+
|
26
|
+
## Basic Usage
|
27
|
+
|
28
|
+
Once you have installed the gem, require it into your Ruby code with:
|
29
|
+
|
30
|
+
```ruby
|
31
|
+
require 'mint_http'
|
32
|
+
```
|
33
|
+
|
34
|
+
Now perform a basic HTTP request:
|
35
|
+
|
36
|
+
```ruby
|
37
|
+
puts MintHttp.query(foo: 'bar').get('https://icanhazip.com').body
|
38
|
+
```
|
39
|
+
|
40
|
+
Note: when performing a request with MintHttp, you will get a [MintHttp::Response](/lib/mint_http/response.rb).
|
41
|
+
|
42
|
+
|
43
|
+
## Post Requests
|
44
|
+
|
45
|
+
MintHttp makes it easy to consume and interact with JSON APIs. You can simply make a post request as follows:
|
46
|
+
|
47
|
+
```ruby
|
48
|
+
MintHttp
|
49
|
+
.as_json
|
50
|
+
.accept_json
|
51
|
+
.post('https://dummyjson.com/products/add', {
|
52
|
+
title: 'Porsche Wallet'
|
53
|
+
})
|
54
|
+
```
|
55
|
+
|
56
|
+
The `as_json` and `accept_json` helper methods sets `Content-Type` and `Accept` headers to `application/json`. The above
|
57
|
+
call can be re-written as:
|
58
|
+
|
59
|
+
```ruby
|
60
|
+
MintHttp
|
61
|
+
.header('Content-Type' => 'application/json')
|
62
|
+
.header('Accept' => 'application/json')
|
63
|
+
.post('https://dummyjson.com/products/add', {
|
64
|
+
title: 'Porsche Wallet'
|
65
|
+
})
|
66
|
+
```
|
67
|
+
|
68
|
+
Note: When no content type is set, `application/json` is set by default.
|
69
|
+
|
70
|
+
|
71
|
+
## Put Requests
|
72
|
+
|
73
|
+
Like a post request, you can easily make a `PUT` request as follows:
|
74
|
+
|
75
|
+
```ruby
|
76
|
+
MintHttp.put('https://dummyjson.com/products/1', {
|
77
|
+
title: 'Porsche Wallet'
|
78
|
+
})
|
79
|
+
```
|
80
|
+
|
81
|
+
or make a `PATCH` request
|
82
|
+
|
83
|
+
```ruby
|
84
|
+
MintHttp.patch('https://dummyjson.com/products/1', {
|
85
|
+
title: 'Porsche Wallet'
|
86
|
+
})
|
87
|
+
```
|
88
|
+
|
89
|
+
|
90
|
+
## Delete Requests
|
91
|
+
|
92
|
+
To delete some resource, you can easily call:
|
93
|
+
|
94
|
+
```ruby
|
95
|
+
MintHttp.delete('https://dummyjson.com/products/1')
|
96
|
+
```
|
97
|
+
|
98
|
+
|
99
|
+
## Uploading Files
|
100
|
+
|
101
|
+
MintHttp makes good use of the powerful Net::HTTP library to allow you to upload files:
|
102
|
+
|
103
|
+
```ruby
|
104
|
+
MintHttp
|
105
|
+
.as_multipart
|
106
|
+
.with_file('upload_field', File.open('/tmp/file.txt'), 'grocery-list.txt', 'text/plain')
|
107
|
+
.post('https://example.com/upload')
|
108
|
+
```
|
109
|
+
|
110
|
+
Note: `as_multipart` is required in order to upload files, this will set the content type to `multipart/form-data`.
|
111
|
+
|
112
|
+
|
113
|
+
## Authentication
|
114
|
+
|
115
|
+
MintHttp provides you with little helpers for common authentication schemes
|
116
|
+
|
117
|
+
### Basic Auth
|
118
|
+
|
119
|
+
```ruby
|
120
|
+
MintHttp
|
121
|
+
.basic_auth('username', 'password')
|
122
|
+
.get('https://example.com/secret-door')
|
123
|
+
```
|
124
|
+
|
125
|
+
|
126
|
+
### Bearer Auth
|
127
|
+
|
128
|
+
```ruby
|
129
|
+
MintHttp
|
130
|
+
.bearer('super-string-token')
|
131
|
+
.get('https://example.com/secret-door')
|
132
|
+
```
|
133
|
+
|
134
|
+
|
135
|
+
## Using a Proxy
|
136
|
+
|
137
|
+
Connecting through an HTTP proxy is as simple as chaining the following call:
|
138
|
+
|
139
|
+
```ruby
|
140
|
+
MintHttp
|
141
|
+
.via_proxy('proxy.example.com', 3128, 'optional-username', 'optional-password')
|
142
|
+
.get('https://icanhazip.com')
|
143
|
+
```
|
144
|
+
|
145
|
+
|
146
|
+
## Pooling Connections
|
147
|
+
|
148
|
+
When your application is communicating with external services so often, it is a good idea to keep a couple of connections
|
149
|
+
open if the target server supports that, this can save you time that is usually gone by TCP and TLS handshakes. This
|
150
|
+
is especially true when the target server is in a faraway geographical region.
|
151
|
+
|
152
|
+
MintHttp uses a pool to manage connections and make sure each connection is used by a single thread at a time. To create
|
153
|
+
a pool, simply do the following:
|
154
|
+
|
155
|
+
```ruby
|
156
|
+
pool = MintHttp::Pool.new({
|
157
|
+
ttl: 30_000,
|
158
|
+
idle_ttl: 10_000,
|
159
|
+
size: 10,
|
160
|
+
usage_limit: 500,
|
161
|
+
timeout: 500,
|
162
|
+
})
|
163
|
+
```
|
164
|
+
|
165
|
+
This will create a new pool with the following properties:
|
166
|
+
|
167
|
+
- Allow a connection to be open for a maximum of 30 seconds defined by `ttl`.
|
168
|
+
- If a connection is not used within 10 seconds of the last use then it is considered expired, defined by `idle_ttl`.
|
169
|
+
- Only hold a maximum of 10 connections, if an 11th thread tries to acquire a connection, it will block until there is one available or timeout is reached.
|
170
|
+
- A connection may be only used for 500 requests, then it should be closed. This is defined by `usage_limit`.
|
171
|
+
|
172
|
+
Once you have created a pool, you can use it in your requests as following:
|
173
|
+
|
174
|
+
```ruby
|
175
|
+
MintHttp
|
176
|
+
.use_pool(pool)
|
177
|
+
.get('https://example.com')
|
178
|
+
```
|
179
|
+
|
180
|
+
A single pool can be used for multiple endpoints, as MintHttp will logically separate connections based on hostname, port,
|
181
|
+
scheme, client certificate, proxy, and other variables.
|
182
|
+
|
183
|
+
> Note: it is possible to use only a single pool for the entire application.
|
184
|
+
|
185
|
+
> Note: The Pool object is thread-safe
|
186
|
+
|
187
|
+
|
188
|
+
## The Response Object
|
189
|
+
|
190
|
+
Each HTTP request will return a [response](/lib/mint_http/response.rb) object when a response is received from the server regardless of the status code.
|
191
|
+
|
192
|
+
You can chain the `raise!` call after the request to make MintHttp throw an exception when a `4xx` ot `5xx` error is returned.
|
193
|
+
Otherwise the same response object is returned.
|
194
|
+
|
195
|
+
```ruby
|
196
|
+
MintHttp.get('https://example.com').raise!
|
197
|
+
```
|
198
|
+
|
199
|
+
|
200
|
+
## Missing Features
|
201
|
+
|
202
|
+
There are a couple of features that are coming soon, these include:
|
203
|
+
|
204
|
+
- Retrying Requests
|
205
|
+
- Middlewares
|
206
|
+
|
207
|
+
|
208
|
+
## Credit
|
209
|
+
|
210
|
+
This library was inspired by Laravel's `Http` wrapper over `GuzzleHttp`.
|
211
|
+
|
212
|
+
## Contributing
|
213
|
+
|
214
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/ahoshaiyan/mint_http.
|
215
|
+
|
216
|
+
## License
|
217
|
+
|
218
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class MintHttp::ResponseError < StandardError
|
4
|
+
# @return [HTTP::Response]
|
5
|
+
attr_reader :response
|
6
|
+
|
7
|
+
def initialize(msg, response)
|
8
|
+
super(msg)
|
9
|
+
@response = response
|
10
|
+
end
|
11
|
+
|
12
|
+
def to_s
|
13
|
+
response.inspect
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class MintHttp::NetHttpFactory
|
4
|
+
def defaults(options = {})
|
5
|
+
options[:open_timeout] = options[:open_timeout] || 5
|
6
|
+
options[:write_timeout] = options[:write_timeout] || 5
|
7
|
+
options[:read_timeout] = options[:read_timeout] || 20
|
8
|
+
options[:ssl_timeout] = options[:ssl_timeout] || 5
|
9
|
+
options[:verify_mode] = options[:verify_mode] || OpenSSL::SSL::VERIFY_PEER
|
10
|
+
options[:verify_hostname] = options[:verify_hostname] || true
|
11
|
+
options
|
12
|
+
end
|
13
|
+
|
14
|
+
def client_namespace(hostname, port, options = {})
|
15
|
+
options = defaults(options)
|
16
|
+
|
17
|
+
host_group = "#{hostname.downcase}_#{port.to_s.downcase}"
|
18
|
+
proxy_group = "#{options[:proxy_address]}_#{options[:proxy_port]}_#{options[:proxy_user]}"
|
19
|
+
timeout_group = "#{options[:open_timeout]}_#{options[:write_timeout]}_#{options[:read_timeout]}"
|
20
|
+
|
21
|
+
cert_signature = options[:cert]&.serial&.to_s
|
22
|
+
key_signature = OpenSSL::Digest::SHA1.new(options[:key]&.to_der || '').to_s
|
23
|
+
ssl_group = "#{options[:use_ssl]}_#{options[:ssl_timeout]}_#{options[:ca_file]}_#{cert_signature}_#{key_signature}_#{options[:verify_mode]}_#{options[:verify_hostname]}"
|
24
|
+
|
25
|
+
"#{host_group}_#{proxy_group}_#{timeout_group}_#{ssl_group}"
|
26
|
+
end
|
27
|
+
|
28
|
+
# Available options:
|
29
|
+
# proxy_address
|
30
|
+
# proxy_port
|
31
|
+
# proxy_user
|
32
|
+
# proxy_pass
|
33
|
+
# open_timeout
|
34
|
+
# write_timeout
|
35
|
+
# read_timeout
|
36
|
+
# ssl_timeout
|
37
|
+
# ca_file
|
38
|
+
# cert
|
39
|
+
# key
|
40
|
+
# verify_mode
|
41
|
+
# verify_hostname
|
42
|
+
def make_client(hostname, port, options = {})
|
43
|
+
options = defaults(options)
|
44
|
+
|
45
|
+
net_http = Net::HTTP.new(hostname, port, nil)
|
46
|
+
|
47
|
+
# Disable retries
|
48
|
+
net_http.max_retries = 0
|
49
|
+
|
50
|
+
# Set proxy options
|
51
|
+
net_http.proxy_address = options[:proxy_address]
|
52
|
+
net_http.proxy_port = options[:proxy_port]
|
53
|
+
net_http.proxy_user = options[:proxy_user]
|
54
|
+
net_http.proxy_pass = options[:proxy_pass]
|
55
|
+
|
56
|
+
# Timeout
|
57
|
+
net_http.open_timeout = options[:open_timeout]
|
58
|
+
net_http.write_timeout = options[:write_timeout]
|
59
|
+
net_http.read_timeout = options[:read_timeout]
|
60
|
+
|
61
|
+
# SSL options
|
62
|
+
net_http.use_ssl = options[:use_ssl]
|
63
|
+
net_http.ssl_timeout = options[:ssl_timeout]
|
64
|
+
net_http.cert = options[:cert]
|
65
|
+
net_http.key = options[:key]
|
66
|
+
net_http.verify_mode = options[:verify_mode]
|
67
|
+
net_http.verify_hostname = options[:verify_hostname]
|
68
|
+
|
69
|
+
if OpenSSL::X509::Store === options[:ca]
|
70
|
+
net_http.cert_store = options[:ca]
|
71
|
+
else
|
72
|
+
net_http.ca_file = options[:ca]
|
73
|
+
end
|
74
|
+
|
75
|
+
net_http
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class MintHttp::Pool
|
4
|
+
attr_reader :ttl
|
5
|
+
attr_reader :idle_ttl
|
6
|
+
attr_reader :timeout
|
7
|
+
attr_reader :size
|
8
|
+
attr_reader :usage_limit
|
9
|
+
|
10
|
+
def initialize(options = {})
|
11
|
+
@mutex = Mutex.new
|
12
|
+
|
13
|
+
@ttl = options[:ttl] || 10000
|
14
|
+
@idle_ttl = options[:idle_ttl] || 5000
|
15
|
+
@timeout = options[:timeout] || 5000
|
16
|
+
|
17
|
+
@size = options[:size] || 10
|
18
|
+
@usage_limit = options[:usage_limit] || 100
|
19
|
+
|
20
|
+
@pool = []
|
21
|
+
end
|
22
|
+
|
23
|
+
def net_factory
|
24
|
+
@net_factory ||= MintHttp::NetHttpFactory.new
|
25
|
+
end
|
26
|
+
|
27
|
+
def acquire(hostname, port, options = {})
|
28
|
+
namespace = net_factory.client_namespace(hostname, port, options)
|
29
|
+
deadline = time_ms + @timeout
|
30
|
+
|
31
|
+
while time_ms < deadline
|
32
|
+
@mutex.synchronize do
|
33
|
+
if (entry = @pool.find { |e| e.namespace == namespace && e.available? })
|
34
|
+
entry.acquire!
|
35
|
+
return entry.client
|
36
|
+
end
|
37
|
+
|
38
|
+
if @pool.length > 0 && @pool.all? { |e| e.to_clean? }
|
39
|
+
clean_pool_unsafe!
|
40
|
+
end
|
41
|
+
|
42
|
+
if @pool.length < @size
|
43
|
+
client = net_factory.make_client(hostname, port, options)
|
44
|
+
entry = append(client, namespace)
|
45
|
+
entry.acquire!
|
46
|
+
return entry.client
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
sleep_time = (deadline - time_ms) / 2
|
51
|
+
sleep_time = [sleep_time, 100].max
|
52
|
+
sleep_time = sleep_time / 1000.0
|
53
|
+
|
54
|
+
sleep(sleep_time)
|
55
|
+
end
|
56
|
+
|
57
|
+
raise RuntimeError, "Cannot acquire lock after #{@timeout}ms."
|
58
|
+
end
|
59
|
+
|
60
|
+
def release(client)
|
61
|
+
raise ArgumentError, 'An client is required to be released.' unless client
|
62
|
+
|
63
|
+
@mutex.synchronize do
|
64
|
+
if (entry = @pool.find { |e| e.matches?(client) })
|
65
|
+
entry.release!
|
66
|
+
end
|
67
|
+
|
68
|
+
clean_pool_unsafe!
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
def append(client, namespace)
|
75
|
+
if @pool.any? { |e| e.matches?(client) }
|
76
|
+
raise RuntimeError, "client with id ##{client.object_id} already exists in the pool."
|
77
|
+
end
|
78
|
+
|
79
|
+
@pool << (entry = MintHttp::PoolEntry.new(self, client, namespace))
|
80
|
+
entry
|
81
|
+
end
|
82
|
+
|
83
|
+
def time_ms
|
84
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
85
|
+
end
|
86
|
+
|
87
|
+
def elapsed(beginning)
|
88
|
+
time_ms - beginning
|
89
|
+
end
|
90
|
+
|
91
|
+
def clean_pool_unsafe!
|
92
|
+
to_clean = []
|
93
|
+
|
94
|
+
@pool.delete_if do |e|
|
95
|
+
to_clean << e if (should_clean = e.to_clean?)
|
96
|
+
should_clean
|
97
|
+
end
|
98
|
+
|
99
|
+
to_clean.each do |e|
|
100
|
+
e.client.finish rescue nil
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def clean_pool!
|
105
|
+
@mutex.synchronize do
|
106
|
+
clean_pool_unsafe!
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class MintHttp::PoolEntry
|
4
|
+
attr_reader :client
|
5
|
+
attr_reader :namespace
|
6
|
+
attr_reader :acquired
|
7
|
+
attr_reader :last_used
|
8
|
+
attr_reader :usage
|
9
|
+
attr_reader :unhealthy
|
10
|
+
|
11
|
+
def initialize(pool, client, namespace)
|
12
|
+
@pool = pool
|
13
|
+
@client = client
|
14
|
+
@namespace = namespace
|
15
|
+
@acquired = false
|
16
|
+
@birth_time = time_ms
|
17
|
+
@last_used = time_ms
|
18
|
+
@usage = 0
|
19
|
+
@unhealthy = false
|
20
|
+
end
|
21
|
+
|
22
|
+
def matches?(other)
|
23
|
+
@client.object_id == other.object_id
|
24
|
+
end
|
25
|
+
|
26
|
+
def ttl_reached?
|
27
|
+
(time_ms - @birth_time) > @pool.ttl
|
28
|
+
end
|
29
|
+
|
30
|
+
def idle_ttl_reached?
|
31
|
+
(time_ms - @last_used) > @pool.idle_ttl
|
32
|
+
end
|
33
|
+
|
34
|
+
def usage_reached?
|
35
|
+
@usage >= @pool.usage_limit
|
36
|
+
end
|
37
|
+
|
38
|
+
def expired?
|
39
|
+
idle_ttl_reached? || ttl_reached? || usage_reached?
|
40
|
+
end
|
41
|
+
|
42
|
+
def acquire!
|
43
|
+
@acquired = true
|
44
|
+
@last_used = time_ms
|
45
|
+
@usage += 1
|
46
|
+
end
|
47
|
+
|
48
|
+
def release!
|
49
|
+
@acquired = false
|
50
|
+
end
|
51
|
+
|
52
|
+
def available?
|
53
|
+
!@acquired && !expired? && !@unhealthy
|
54
|
+
end
|
55
|
+
|
56
|
+
def healthy?
|
57
|
+
# TODO: add health check
|
58
|
+
healthy = true
|
59
|
+
@unhealthy = !healthy
|
60
|
+
healthy
|
61
|
+
end
|
62
|
+
|
63
|
+
def to_clean?
|
64
|
+
(expired? || @unhealthy) && !@acquired
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def time_ms
|
70
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,322 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'base64'
|
4
|
+
require 'net/http'
|
5
|
+
require 'uri'
|
6
|
+
require 'openssl'
|
7
|
+
require 'json'
|
8
|
+
|
9
|
+
module MintHttp
|
10
|
+
class Request
|
11
|
+
attr_reader :base_url
|
12
|
+
attr_reader :headers
|
13
|
+
attr_reader :body_type
|
14
|
+
attr_reader :body
|
15
|
+
attr_reader :query
|
16
|
+
attr_reader :open_timeout
|
17
|
+
attr_reader :write_timeout
|
18
|
+
attr_reader :read_timeout
|
19
|
+
attr_reader :ca
|
20
|
+
attr_reader :cert
|
21
|
+
attr_reader :proxy_address
|
22
|
+
attr_reader :proxy_port
|
23
|
+
|
24
|
+
def initialize
|
25
|
+
@pool = nil
|
26
|
+
@base_url = nil
|
27
|
+
@headers = {}
|
28
|
+
@body_type = nil
|
29
|
+
@body = nil
|
30
|
+
@query = {}
|
31
|
+
@files = []
|
32
|
+
@open_timeout = 5
|
33
|
+
@write_timeout = 5
|
34
|
+
@read_timeout = 20
|
35
|
+
@ca = nil
|
36
|
+
@cert = nil
|
37
|
+
@key = nil
|
38
|
+
@proxy_address = nil
|
39
|
+
@proxy_port = nil
|
40
|
+
@proxy_user = nil
|
41
|
+
@proxy_pass = nil
|
42
|
+
|
43
|
+
header('User-Agent' => 'Mint Http')
|
44
|
+
as_json
|
45
|
+
end
|
46
|
+
|
47
|
+
def use_pool(pool)
|
48
|
+
raise ArgumentError, 'Expected a MintHttp::Pool' unless Pool === pool
|
49
|
+
|
50
|
+
@pool = pool
|
51
|
+
self
|
52
|
+
end
|
53
|
+
|
54
|
+
def timeout(open, write, read)
|
55
|
+
@open_timeout = open
|
56
|
+
@write_timeout = write
|
57
|
+
@read_timeout = read
|
58
|
+
end
|
59
|
+
|
60
|
+
def base_url(url)
|
61
|
+
@base_url = URI.parse(url)
|
62
|
+
self
|
63
|
+
end
|
64
|
+
|
65
|
+
def use_ca(ca)
|
66
|
+
@ca = ca
|
67
|
+
self
|
68
|
+
end
|
69
|
+
|
70
|
+
def use_cert(cert, key)
|
71
|
+
unless OpenSSL::X509::Certificate === cert
|
72
|
+
raise ArgumentError, 'Expected an OpenSSL::X509::Certificate'
|
73
|
+
end
|
74
|
+
|
75
|
+
unless OpenSSL::PKey::PKey === key
|
76
|
+
raise ArgumentError, 'Expected an OpenSSL::PKey::PKey'
|
77
|
+
end
|
78
|
+
|
79
|
+
@cert = cert
|
80
|
+
@key = key
|
81
|
+
|
82
|
+
self
|
83
|
+
end
|
84
|
+
|
85
|
+
def via_proxy(proxy_address, proxy_port = 3128, proxy_user = nil, proxy_pass = nil)
|
86
|
+
@proxy_address = proxy_address
|
87
|
+
@proxy_port = proxy_port
|
88
|
+
@proxy_user = proxy_user
|
89
|
+
@proxy_pass = proxy_pass
|
90
|
+
|
91
|
+
self
|
92
|
+
end
|
93
|
+
|
94
|
+
def query(queries = {})
|
95
|
+
queries.each do |k, v|
|
96
|
+
k = k.to_s
|
97
|
+
|
98
|
+
if v.nil?
|
99
|
+
@query.delete(k)
|
100
|
+
next
|
101
|
+
end
|
102
|
+
|
103
|
+
@query[k] = v.to_s
|
104
|
+
end
|
105
|
+
|
106
|
+
self
|
107
|
+
end
|
108
|
+
|
109
|
+
def header(headers = {})
|
110
|
+
headers.each do |k, v|
|
111
|
+
k = k.downcase.to_s
|
112
|
+
|
113
|
+
if v.nil?
|
114
|
+
@headers.delete(k)
|
115
|
+
next
|
116
|
+
end
|
117
|
+
|
118
|
+
v = v.join(' ;') if Array === v
|
119
|
+
@headers[k] = v
|
120
|
+
end
|
121
|
+
|
122
|
+
self
|
123
|
+
end
|
124
|
+
|
125
|
+
def basic_auth(username, password = '')
|
126
|
+
header('Authorization' => 'Basic ' + Base64.strict_encode64("#{username}:#{password}"))
|
127
|
+
end
|
128
|
+
|
129
|
+
def token_auth(type, token)
|
130
|
+
header('Authorization' => "#{type} #{token}")
|
131
|
+
end
|
132
|
+
|
133
|
+
def bearer(token)
|
134
|
+
token_auth('Bearer', token)
|
135
|
+
end
|
136
|
+
|
137
|
+
def accept(type)
|
138
|
+
header('Accept' => type)
|
139
|
+
end
|
140
|
+
|
141
|
+
def accept_json
|
142
|
+
accept('application/json')
|
143
|
+
end
|
144
|
+
|
145
|
+
def content_type(type)
|
146
|
+
header('Content-Type' => type)
|
147
|
+
end
|
148
|
+
|
149
|
+
def with_body(raw)
|
150
|
+
@body_type = :raw
|
151
|
+
@body = raw
|
152
|
+
content_type(nil)
|
153
|
+
self
|
154
|
+
end
|
155
|
+
|
156
|
+
def as_json
|
157
|
+
@body_type = :json
|
158
|
+
content_type('application/json')
|
159
|
+
end
|
160
|
+
|
161
|
+
def as_form
|
162
|
+
@body_type = :form
|
163
|
+
content_type('application/x-www-form-urlencoded')
|
164
|
+
end
|
165
|
+
|
166
|
+
def as_multipart
|
167
|
+
@body_type = :multipart
|
168
|
+
content_type('multipart/form-data')
|
169
|
+
end
|
170
|
+
|
171
|
+
def with_file(name, file, filename = nil, content_type = nil)
|
172
|
+
unless file.respond_to?(:read)
|
173
|
+
raise ArgumentError, "File must be an IO or IO like"
|
174
|
+
end
|
175
|
+
|
176
|
+
@files << [
|
177
|
+
name,
|
178
|
+
file,
|
179
|
+
{ filename: filename, content_type: content_type }.compact
|
180
|
+
]
|
181
|
+
|
182
|
+
self
|
183
|
+
end
|
184
|
+
|
185
|
+
def get(url, params = {})
|
186
|
+
query(params).send_request('get', url)
|
187
|
+
end
|
188
|
+
|
189
|
+
def head(url, params = {})
|
190
|
+
query(params).send_request('head', url)
|
191
|
+
end
|
192
|
+
|
193
|
+
def post(url, data = nil)
|
194
|
+
@body = data if data
|
195
|
+
send_request('post', url)
|
196
|
+
end
|
197
|
+
|
198
|
+
def put(url, data = nil)
|
199
|
+
@body = data if data
|
200
|
+
send_request('put', url)
|
201
|
+
end
|
202
|
+
|
203
|
+
def patch(url, data = nil)
|
204
|
+
@body = data if data
|
205
|
+
send_request('patch', url)
|
206
|
+
end
|
207
|
+
|
208
|
+
def delete(url, data = nil)
|
209
|
+
@body = data if data
|
210
|
+
send_request('delete', url)
|
211
|
+
end
|
212
|
+
|
213
|
+
def send_request(method, url)
|
214
|
+
url, net_request, options = build_request(method, url)
|
215
|
+
|
216
|
+
res = with_client(url.hostname, url.port, options) do |http|
|
217
|
+
http.request(net_request)
|
218
|
+
end
|
219
|
+
|
220
|
+
Response.new(res)
|
221
|
+
end
|
222
|
+
|
223
|
+
private
|
224
|
+
|
225
|
+
def build_request(method, url)
|
226
|
+
url = URI.parse(url)
|
227
|
+
url = @base_url + url if @base_url
|
228
|
+
|
229
|
+
unless %w[http https].include?(url.scheme)
|
230
|
+
raise ArgumentError, "Only HTTP and HTTPS URLs are allowed"
|
231
|
+
end
|
232
|
+
|
233
|
+
url.query = URI.encode_www_form(@query)
|
234
|
+
|
235
|
+
net_request = case method.to_s
|
236
|
+
when 'get'
|
237
|
+
@body_type = nil
|
238
|
+
Net::HTTP::Get.new(url)
|
239
|
+
when 'head'
|
240
|
+
@body_type = nil
|
241
|
+
Net::HTTP::Head.new(url)
|
242
|
+
when 'post'
|
243
|
+
Net::HTTP::Post.new(url)
|
244
|
+
when 'put'
|
245
|
+
Net::HTTP::Put.new(url)
|
246
|
+
when 'patch'
|
247
|
+
Net::HTTP::Patch.new(url)
|
248
|
+
when 'delete'
|
249
|
+
Net::HTTP::Delete.new(url)
|
250
|
+
else
|
251
|
+
raise ArgumentError, "Unsupported HTTP method #{method}"
|
252
|
+
end
|
253
|
+
|
254
|
+
# add body
|
255
|
+
case @body_type
|
256
|
+
when nil
|
257
|
+
# Ignore body
|
258
|
+
when :raw
|
259
|
+
net_request.body = @body
|
260
|
+
when :json
|
261
|
+
net_request.body = @body.to_json if @body
|
262
|
+
when :form
|
263
|
+
net_request.body = URI.encode_www_form(@body) if @body
|
264
|
+
when :multipart
|
265
|
+
params = []
|
266
|
+
params.concat(@body.map { |k, v| [k.to_s, v.to_s] }) if Hash === @body
|
267
|
+
params.concat(@files)
|
268
|
+
net_request.set_form(params, 'multipart/form-data')
|
269
|
+
else
|
270
|
+
raise ArgumentError, "Invalid body type #{@body_type}"
|
271
|
+
end
|
272
|
+
|
273
|
+
# add headers
|
274
|
+
@headers.each do |k, v|
|
275
|
+
net_request.send(:set_field, k, v)
|
276
|
+
end
|
277
|
+
|
278
|
+
options = {
|
279
|
+
use_ssl: url.scheme == 'https',
|
280
|
+
open_timeout: @open_timeout,
|
281
|
+
write_timeout: @write_timeout,
|
282
|
+
read_timeout: @read_timeout,
|
283
|
+
ca: @ca,
|
284
|
+
cert: @cert,
|
285
|
+
key: @key,
|
286
|
+
proxy_address: @proxy_address,
|
287
|
+
proxy_port: @proxy_port,
|
288
|
+
proxy_user: @proxy_user,
|
289
|
+
proxy_pass: @proxy_pass,
|
290
|
+
}
|
291
|
+
|
292
|
+
[url, net_request, options]
|
293
|
+
end
|
294
|
+
|
295
|
+
def with_client(hostname, port, options = {})
|
296
|
+
raise ArgumentError, 'Block is required' unless block_given?
|
297
|
+
|
298
|
+
# Get client from pool
|
299
|
+
net_http = @pool&.acquire(hostname, port, options)
|
300
|
+
|
301
|
+
# make a new client if there is no pool
|
302
|
+
unless net_http
|
303
|
+
net_http = net_factory.make_client(hostname, port, options)
|
304
|
+
end
|
305
|
+
|
306
|
+
begin
|
307
|
+
net_http.start unless net_http.started?
|
308
|
+
yield(net_http)
|
309
|
+
ensure
|
310
|
+
if @pool
|
311
|
+
@pool.release(net_http)
|
312
|
+
else
|
313
|
+
net_http.finish
|
314
|
+
end
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
def net_factory
|
319
|
+
@net_factory ||= NetHttpFactory.new
|
320
|
+
end
|
321
|
+
end
|
322
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MintHttp
|
4
|
+
class Response
|
5
|
+
attr_reader :net_response
|
6
|
+
attr_reader :version
|
7
|
+
attr_reader :status_code
|
8
|
+
attr_reader :status_text
|
9
|
+
attr_reader :headers
|
10
|
+
|
11
|
+
def initialize(net_response)
|
12
|
+
@net_response = net_response
|
13
|
+
@version = net_response.http_version
|
14
|
+
@status_code = net_response.code.to_i
|
15
|
+
@status_text = net_response.message
|
16
|
+
@headers = Headers.new.merge(net_response.each_header.to_h)
|
17
|
+
end
|
18
|
+
|
19
|
+
def success?
|
20
|
+
(200..299).include?(@status_code)
|
21
|
+
end
|
22
|
+
|
23
|
+
def redirect?
|
24
|
+
(300..399).include?(@status_code)
|
25
|
+
end
|
26
|
+
|
27
|
+
def client_error?
|
28
|
+
(400..499).include?(@status_code)
|
29
|
+
end
|
30
|
+
|
31
|
+
def unauthenticated?
|
32
|
+
@status_code == 401
|
33
|
+
end
|
34
|
+
|
35
|
+
def unauthorized?
|
36
|
+
@status_code == 403
|
37
|
+
end
|
38
|
+
|
39
|
+
def not_found?
|
40
|
+
@status_code == 404
|
41
|
+
end
|
42
|
+
|
43
|
+
def server_error?
|
44
|
+
(500..599).include?(@status_code)
|
45
|
+
end
|
46
|
+
|
47
|
+
def raise!
|
48
|
+
case @status_code
|
49
|
+
when 401
|
50
|
+
raise Errors::AuthenticationError.new('Unauthenticated', self)
|
51
|
+
when 403
|
52
|
+
raise Errors::AuthorizationError.new('Forbidden', self)
|
53
|
+
when 404
|
54
|
+
raise Errors::NotFoundError.new('Not Found', self)
|
55
|
+
when 400..499
|
56
|
+
raise Errors::ClientError.new('Client Error', self)
|
57
|
+
when 500..599
|
58
|
+
raise Errors::ClientError.new('Server Error', self)
|
59
|
+
else
|
60
|
+
self
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def body
|
65
|
+
net_response.body
|
66
|
+
end
|
67
|
+
|
68
|
+
def json
|
69
|
+
@json ||= JSON.parse(body)
|
70
|
+
end
|
71
|
+
|
72
|
+
def inspect
|
73
|
+
"#<#{self.class}/#{@version} #{@status_code} #{@status_text} #{@headers.inspect}>"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
data/lib/mint_http.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'mint_http/version'
|
4
|
+
require_relative 'mint_http/pool_entry'
|
5
|
+
require_relative 'mint_http/pool'
|
6
|
+
require_relative 'mint_http/headers'
|
7
|
+
require_relative 'mint_http/errors/response_error'
|
8
|
+
require_relative 'mint_http/errors/server_error'
|
9
|
+
require_relative 'mint_http/errors/client_error'
|
10
|
+
require_relative 'mint_http/errors/authorization_error'
|
11
|
+
require_relative 'mint_http/errors/authentication_error'
|
12
|
+
require_relative 'mint_http/errors/not_found_error'
|
13
|
+
require_relative 'mint_http/net_http_factory'
|
14
|
+
require_relative 'mint_http/response'
|
15
|
+
require_relative 'mint_http/request'
|
16
|
+
|
17
|
+
module MintHttp
|
18
|
+
class << self
|
19
|
+
# @return [::MintHttp::Request]
|
20
|
+
def method_missing(method, *args)
|
21
|
+
request = Request.new
|
22
|
+
request.send(method, *args)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
data/sig/mint_http.rbs
ADDED
metadata
ADDED
@@ -0,0 +1,137 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: mint_http
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Ali Alhoshaiyan
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2023-07-03 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: net-http
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: openssl
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: json
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: uri
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: base64
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
description: Like a mint breeze, MintHttp allows you to write simple HTTP requests
|
84
|
+
while giving you the full power of Net::HTTP.
|
85
|
+
email:
|
86
|
+
- ahoshaiyan@fastmail.com
|
87
|
+
executables: []
|
88
|
+
extensions: []
|
89
|
+
extra_rdoc_files: []
|
90
|
+
files:
|
91
|
+
- CHANGELOG.md
|
92
|
+
- Gemfile
|
93
|
+
- Gemfile.lock
|
94
|
+
- LICENSE.txt
|
95
|
+
- README.md
|
96
|
+
- Rakefile
|
97
|
+
- lib/mint_http.rb
|
98
|
+
- lib/mint_http/errors/authentication_error.rb
|
99
|
+
- lib/mint_http/errors/authorization_error.rb
|
100
|
+
- lib/mint_http/errors/client_error.rb
|
101
|
+
- lib/mint_http/errors/not_found_error.rb
|
102
|
+
- lib/mint_http/errors/response_error.rb
|
103
|
+
- lib/mint_http/errors/server_error.rb
|
104
|
+
- lib/mint_http/headers.rb
|
105
|
+
- lib/mint_http/net_http_factory.rb
|
106
|
+
- lib/mint_http/pool.rb
|
107
|
+
- lib/mint_http/pool_entry.rb
|
108
|
+
- lib/mint_http/request.rb
|
109
|
+
- lib/mint_http/response.rb
|
110
|
+
- lib/mint_http/version.rb
|
111
|
+
- sig/mint_http.rbs
|
112
|
+
homepage: https://github.com/ahoshaiyan/mint_http
|
113
|
+
licenses:
|
114
|
+
- MIT
|
115
|
+
metadata:
|
116
|
+
homepage_uri: https://github.com/ahoshaiyan/mint_http
|
117
|
+
source_code_uri: https://github.com/ahoshaiyan/mint_http
|
118
|
+
post_install_message:
|
119
|
+
rdoc_options: []
|
120
|
+
require_paths:
|
121
|
+
- lib
|
122
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
123
|
+
requirements:
|
124
|
+
- - ">="
|
125
|
+
- !ruby/object:Gem::Version
|
126
|
+
version: 3.0.6
|
127
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
requirements: []
|
133
|
+
rubygems_version: 3.2.33
|
134
|
+
signing_key:
|
135
|
+
specification_version: 4
|
136
|
+
summary: A small fluent HTTP client.
|
137
|
+
test_files: []
|