http_mimic 0.3.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 +60 -0
- data/LICENSE.txt +21 -0
- data/README.md +263 -0
- data/http_mimic.gemspec +31 -0
- data/lib/http_mimic/client.rb +72 -0
- data/lib/http_mimic/command_builder.rb +333 -0
- data/lib/http_mimic/configuration.rb +41 -0
- data/lib/http_mimic/cookies.rb +111 -0
- data/lib/http_mimic/downloader.rb +272 -0
- data/lib/http_mimic/exceptions.rb +34 -0
- data/lib/http_mimic/headers.rb +101 -0
- data/lib/http_mimic/module_methods.rb +101 -0
- data/lib/http_mimic/request.rb +88 -0
- data/lib/http_mimic/response.rb +99 -0
- data/lib/http_mimic/response_parser.rb +148 -0
- data/lib/http_mimic/version.rb +5 -0
- data/lib/http_mimic.rb +85 -0
- metadata +102 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
require 'zlib'
|
|
8
|
+
require 'rubygems/package'
|
|
9
|
+
require 'open3'
|
|
10
|
+
|
|
11
|
+
module HttpMimic
|
|
12
|
+
class Downloader
|
|
13
|
+
DEFAULT_GITHUB_REPO = 'lexiforest/curl-impersonate'
|
|
14
|
+
DEFAULT_VERSION = 'v2.1.1'
|
|
15
|
+
|
|
16
|
+
class DownloadError < HttpMimic::Error; end
|
|
17
|
+
class UnsupportedPlatformError < HttpMimic::Error; end
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def download!(version: nil, install_dir: nil, repo: nil, force: false)
|
|
21
|
+
target_version = normalize_version(version || HttpMimic.configuration.driver_version || DEFAULT_VERSION)
|
|
22
|
+
target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
|
|
23
|
+
target_repo = repo || HttpMimic.configuration.github_repo || DEFAULT_GITHUB_REPO
|
|
24
|
+
|
|
25
|
+
FileUtils.mkdir_p(target_dir)
|
|
26
|
+
|
|
27
|
+
# Check if the target version is already installed and intact
|
|
28
|
+
if !force && installed?(version: target_version, install_dir: target_dir)
|
|
29
|
+
log_info("curl-impersonate #{target_version} is already installed in #{target_dir}")
|
|
30
|
+
return target_dir
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
slug = platform_slug
|
|
34
|
+
asset_name = "curl-impersonate-#{target_version}.#{slug}.tar.gz"
|
|
35
|
+
download_url = "https://github.com/#{target_repo}/releases/download/#{target_version}/#{asset_name}"
|
|
36
|
+
|
|
37
|
+
log_info("Downloading curl-impersonate (#{target_version}) [#{asset_name}]...")
|
|
38
|
+
|
|
39
|
+
archive_data = fetch_binary(download_url)
|
|
40
|
+
|
|
41
|
+
log_info("Extracting archive to #{target_dir}...")
|
|
42
|
+
extract_tar_gz(archive_data, target_dir)
|
|
43
|
+
|
|
44
|
+
# Write version marker file
|
|
45
|
+
File.write(version_file_path(target_dir), target_version)
|
|
46
|
+
|
|
47
|
+
log_info("curl-impersonate #{target_version} installation complete!")
|
|
48
|
+
target_dir
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def installed?(version: nil, install_dir: nil)
|
|
52
|
+
target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
|
|
53
|
+
core_binary = File.join(target_dir, binary_name_for_platform('curl-impersonate'))
|
|
54
|
+
return false unless File.file?(core_binary) && File.executable?(core_binary)
|
|
55
|
+
|
|
56
|
+
if version
|
|
57
|
+
target_version = normalize_version(version)
|
|
58
|
+
v_file = version_file_path(target_dir)
|
|
59
|
+
return false unless File.file?(v_file)
|
|
60
|
+
return File.read(v_file).strip == target_version
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def binary_path(name, install_dir: nil)
|
|
67
|
+
target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
|
|
68
|
+
candidate = File.join(target_dir, binary_name_for_platform(name))
|
|
69
|
+
return candidate if File.file?(candidate) && File.executable?(candidate)
|
|
70
|
+
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def available_binaries(install_dir: nil)
|
|
75
|
+
target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
|
|
76
|
+
return [] unless Dir.exist?(target_dir)
|
|
77
|
+
|
|
78
|
+
Dir.glob(File.join(target_dir, '*')).select do |file|
|
|
79
|
+
File.file?(file) && File.executable?(file)
|
|
80
|
+
end.map { |f| File.basename(f) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def platform_slug
|
|
84
|
+
os = host_os
|
|
85
|
+
cpu = host_cpu
|
|
86
|
+
|
|
87
|
+
case os
|
|
88
|
+
when :macos
|
|
89
|
+
case cpu
|
|
90
|
+
when :arm64 then 'arm64-macos'
|
|
91
|
+
when :x86_64 then 'x86_64-macos'
|
|
92
|
+
else
|
|
93
|
+
raise UnsupportedPlatformError, "Unsupported macOS CPU architecture: #{cpu}"
|
|
94
|
+
end
|
|
95
|
+
when :linux
|
|
96
|
+
libc = linux_libc
|
|
97
|
+
case cpu
|
|
98
|
+
when :x86_64
|
|
99
|
+
libc == :musl ? 'x86_64-linux-musl' : 'x86_64-linux-gnu'
|
|
100
|
+
when :aarch64, :arm64
|
|
101
|
+
libc == :musl ? 'aarch64-linux-musl' : 'aarch64-linux-gnu'
|
|
102
|
+
when :i386, :i686
|
|
103
|
+
'i386-linux-gnu'
|
|
104
|
+
when :arm
|
|
105
|
+
'arm-linux-gnueabihf'
|
|
106
|
+
when :riscv64
|
|
107
|
+
'riscv64-linux-gnu'
|
|
108
|
+
when :loongarch64
|
|
109
|
+
'loongarch64-linux-gnu'
|
|
110
|
+
else
|
|
111
|
+
raise UnsupportedPlatformError, "Unsupported Linux CPU architecture: #{cpu}"
|
|
112
|
+
end
|
|
113
|
+
when :windows
|
|
114
|
+
case cpu
|
|
115
|
+
when :x86_64 then 'x86_64-win32'
|
|
116
|
+
when :arm64 then 'arm64-win32'
|
|
117
|
+
when :i386, :i686 then 'i686-win32'
|
|
118
|
+
else
|
|
119
|
+
raise UnsupportedPlatformError, "Unsupported Windows CPU architecture: #{cpu}"
|
|
120
|
+
end
|
|
121
|
+
when :freebsd
|
|
122
|
+
case cpu
|
|
123
|
+
when :x86_64 then 'x86_64-freebsd'
|
|
124
|
+
when :arm64, :aarch64 then 'aarch64-freebsd'
|
|
125
|
+
else
|
|
126
|
+
raise UnsupportedPlatformError, "Unsupported FreeBSD CPU architecture: #{cpu}"
|
|
127
|
+
end
|
|
128
|
+
else
|
|
129
|
+
raise UnsupportedPlatformError, "Unsupported operating system: #{RbConfig::CONFIG['host_os']}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def host_os
|
|
136
|
+
os_str = RbConfig::CONFIG['host_os'].downcase
|
|
137
|
+
case os_str
|
|
138
|
+
when /darwin|mac os/
|
|
139
|
+
:macos
|
|
140
|
+
when /linux/
|
|
141
|
+
:linux
|
|
142
|
+
when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
|
|
143
|
+
:windows
|
|
144
|
+
when /freebsd/
|
|
145
|
+
:freebsd
|
|
146
|
+
else
|
|
147
|
+
:unknown
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def host_cpu
|
|
152
|
+
cpu_str = RbConfig::CONFIG['host_cpu'].downcase
|
|
153
|
+
case cpu_str
|
|
154
|
+
when /arm64|aarch64/
|
|
155
|
+
host_os == :macos || host_os == :windows ? :arm64 : :aarch64
|
|
156
|
+
when /x86_64|amd64|x64/
|
|
157
|
+
:x86_64
|
|
158
|
+
when /i[3-6]86|x86/
|
|
159
|
+
:i386
|
|
160
|
+
when /arm/
|
|
161
|
+
:arm
|
|
162
|
+
when /riscv64/
|
|
163
|
+
:riscv64
|
|
164
|
+
when /loongarch64/
|
|
165
|
+
:loongarch64
|
|
166
|
+
else
|
|
167
|
+
cpu_str.to_sym
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def linux_libc
|
|
172
|
+
if File.exist?('/etc/alpine-release') || `ldd --version 2>&1` =~ /musl/i
|
|
173
|
+
:musl
|
|
174
|
+
else
|
|
175
|
+
:gnu
|
|
176
|
+
end
|
|
177
|
+
rescue StandardError
|
|
178
|
+
:gnu
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def binary_name_for_platform(name)
|
|
182
|
+
host_os == :windows ? "#{name}.exe" : name
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def normalize_version(ver)
|
|
186
|
+
v = ver.to_s.strip
|
|
187
|
+
v.start_with?('v') ? v : "v#{v}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def version_file_path(dir)
|
|
191
|
+
File.join(dir, '.version')
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def fetch_binary(url, limit = 5)
|
|
195
|
+
raise DownloadError, "Download failed: too many redirects (#{url})" if limit <= 0
|
|
196
|
+
|
|
197
|
+
uri = URI.parse(url)
|
|
198
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
199
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
200
|
+
http.read_timeout = 60
|
|
201
|
+
http.open_timeout = 30
|
|
202
|
+
|
|
203
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
|
204
|
+
req['User-Agent'] = "Ruby-HttpMimic/#{HttpMimic::VERSION} (#{RUBY_PLATFORM})"
|
|
205
|
+
|
|
206
|
+
res = http.request(req)
|
|
207
|
+
|
|
208
|
+
case res
|
|
209
|
+
when Net::HTTPSuccess
|
|
210
|
+
res.body
|
|
211
|
+
when Net::HTTPRedirection
|
|
212
|
+
location = res['location']
|
|
213
|
+
fetch_binary(location, limit - 1)
|
|
214
|
+
else
|
|
215
|
+
raise DownloadError, "Download failed with HTTP status #{res.code}: #{url}"
|
|
216
|
+
end
|
|
217
|
+
rescue StandardError => e
|
|
218
|
+
raise DownloadError, "Failed to download curl-impersonate release: #{e.message}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def extract_tar_gz(data, dest_dir)
|
|
222
|
+
# 1. Try system tar command first
|
|
223
|
+
begin
|
|
224
|
+
tmp_tar = File.join(dest_dir, ".temp_download_#{Process.pid}.tar.gz")
|
|
225
|
+
File.binwrite(tmp_tar, data)
|
|
226
|
+
|
|
227
|
+
_stdout, _stderr, status = Open3.capture3('tar', '-xzf', tmp_tar, '-C', dest_dir)
|
|
228
|
+
FileUtils.rm_f(tmp_tar)
|
|
229
|
+
|
|
230
|
+
if status && status.success?
|
|
231
|
+
chmod_all_binaries(dest_dir)
|
|
232
|
+
return
|
|
233
|
+
end
|
|
234
|
+
rescue StandardError => e
|
|
235
|
+
log_info("System tar command failed (#{e.message}), falling back to Ruby extraction...")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# 2. Pure Ruby tar.gz extraction fallback
|
|
239
|
+
gz = Zlib::GzipReader.new(StringIO.new(data))
|
|
240
|
+
tar = Gem::Package::TarReader.new(gz)
|
|
241
|
+
|
|
242
|
+
tar.each do |entry|
|
|
243
|
+
target_path = File.join(dest_dir, entry.full_name)
|
|
244
|
+
if entry.directory?
|
|
245
|
+
FileUtils.mkdir_p(target_path)
|
|
246
|
+
elsif entry.file?
|
|
247
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
|
248
|
+
File.binwrite(target_path, entry.read)
|
|
249
|
+
File.chmod(0755, target_path)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
tar.close
|
|
253
|
+
gz.close
|
|
254
|
+
chmod_all_binaries(dest_dir)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def chmod_all_binaries(dir)
|
|
258
|
+
Dir.glob(File.join(dir, '*')).each do |f|
|
|
259
|
+
File.chmod(0755, f) if File.file?(f)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def log_info(msg)
|
|
264
|
+
if HttpMimic.configuration.logger
|
|
265
|
+
HttpMimic.configuration.logger.info("[HttpMimic::Downloader] #{msg}")
|
|
266
|
+
elsif HttpMimic.configuration.debug
|
|
267
|
+
puts "[HttpMimic::Downloader] #{msg}"
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpMimic
|
|
4
|
+
# Base exception class
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the curl-impersonate binary cannot be found
|
|
8
|
+
class BinaryNotFoundError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when the curl command fails or returns a non-zero exit status
|
|
11
|
+
class CommandError < Error
|
|
12
|
+
attr_reader :exit_code, :stderr, :command, :response
|
|
13
|
+
|
|
14
|
+
def initialize(message, exit_code: nil, stderr: nil, command: nil, response: nil)
|
|
15
|
+
super(message)
|
|
16
|
+
@exit_code = exit_code
|
|
17
|
+
@stderr = stderr
|
|
18
|
+
@command = command
|
|
19
|
+
@response = response
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Connection timeout (Curl exit code 28)
|
|
24
|
+
class TimeoutError < CommandError; end
|
|
25
|
+
|
|
26
|
+
# Connection failure or DNS resolution error (Curl exit code 6, 7, 52, etc.)
|
|
27
|
+
class ConnectionError < CommandError; end
|
|
28
|
+
|
|
29
|
+
# SSL/TLS handshake or certificate error (Curl exit code 35, 51, 60, etc.)
|
|
30
|
+
class SSLError < CommandError; end
|
|
31
|
+
|
|
32
|
+
# Raised when response parsing fails
|
|
33
|
+
class ResponseParseError < Error; end
|
|
34
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpMimic
|
|
4
|
+
# Case-insensitive HTTP Headers wrapper that preserves original casing
|
|
5
|
+
class Headers
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
def initialize(headers_hash = {})
|
|
9
|
+
@headers = {}
|
|
10
|
+
@names = {}
|
|
11
|
+
@multi_values = {}
|
|
12
|
+
|
|
13
|
+
headers_hash.each do |key, val|
|
|
14
|
+
self[key] = val
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def [](key)
|
|
19
|
+
return nil if key.nil?
|
|
20
|
+
canonical = @names[normalize_key(key)]
|
|
21
|
+
return nil unless canonical
|
|
22
|
+
|
|
23
|
+
norm_key = normalize_key(key)
|
|
24
|
+
if norm_key == 'set-cookie' && @multi_values.key?(norm_key)
|
|
25
|
+
# For Set-Cookie, return an Array if multiple values exist
|
|
26
|
+
@multi_values[norm_key].size > 1 ? @multi_values[norm_key] : @multi_values[norm_key].first
|
|
27
|
+
else
|
|
28
|
+
@headers[canonical]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def []=(key, value)
|
|
33
|
+
return if key.nil?
|
|
34
|
+
norm = normalize_key(key)
|
|
35
|
+
if existing_key = @names[norm]
|
|
36
|
+
@headers.delete(existing_key)
|
|
37
|
+
end
|
|
38
|
+
orig_key = key.to_s
|
|
39
|
+
@names[norm] = orig_key
|
|
40
|
+
@headers[orig_key] = value
|
|
41
|
+
|
|
42
|
+
@multi_values[norm] = value.is_a?(Array) ? value.dup : [value]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Add header (supports duplicate headers with the same name, e.g., multiple Set-Cookie entries)
|
|
46
|
+
def add(key, value)
|
|
47
|
+
return if key.nil?
|
|
48
|
+
norm = normalize_key(key)
|
|
49
|
+
orig_key = @names[norm] || key.to_s
|
|
50
|
+
@names[norm] = orig_key
|
|
51
|
+
|
|
52
|
+
@multi_values[norm] ||= []
|
|
53
|
+
@multi_values[norm] << value
|
|
54
|
+
|
|
55
|
+
@headers[orig_key] = value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def get_all(key)
|
|
59
|
+
return [] if key.nil?
|
|
60
|
+
@multi_values[normalize_key(key)] || []
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def key?(key)
|
|
64
|
+
return false if key.nil?
|
|
65
|
+
@names.key?(normalize_key(key))
|
|
66
|
+
end
|
|
67
|
+
alias has_key? key?
|
|
68
|
+
alias include? key?
|
|
69
|
+
|
|
70
|
+
def each(&block)
|
|
71
|
+
@headers.each(&block)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def keys
|
|
75
|
+
@headers.keys
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def values
|
|
79
|
+
@headers.values
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def to_h
|
|
83
|
+
@headers.dup
|
|
84
|
+
end
|
|
85
|
+
alias to_hash to_h
|
|
86
|
+
|
|
87
|
+
def inspect
|
|
88
|
+
"#<#{self.class.name} #{@headers.inspect}>"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def to_s
|
|
92
|
+
@headers.to_s
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def normalize_key(key)
|
|
98
|
+
key.to_s.downcase.tr('_', '-')
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpMimic
|
|
4
|
+
module ModuleMethods
|
|
5
|
+
def base_uri(uri = nil)
|
|
6
|
+
return default_options[:base_uri] if uri.nil?
|
|
7
|
+
default_options[:base_uri] = uri
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def headers(h = nil)
|
|
11
|
+
return default_options[:headers] if h.nil?
|
|
12
|
+
default_options[:headers] ||= {}
|
|
13
|
+
default_options[:headers].merge!(h)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def default_params(params = nil)
|
|
17
|
+
return default_options[:query] if params.nil?
|
|
18
|
+
default_options[:query] ||= {}
|
|
19
|
+
default_options[:query].merge!(params)
|
|
20
|
+
end
|
|
21
|
+
alias default_query default_params
|
|
22
|
+
|
|
23
|
+
def default_timeout(seconds = nil)
|
|
24
|
+
return default_options[:timeout] if seconds.nil?
|
|
25
|
+
default_options[:timeout] = seconds
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def impersonate(target = nil)
|
|
29
|
+
return default_options[:impersonate] if target.nil?
|
|
30
|
+
default_options[:impersonate] = target
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def proxy(proxy_str = nil)
|
|
34
|
+
return default_options[:proxy] if proxy_str.nil?
|
|
35
|
+
default_options[:proxy] = proxy_str
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def cookies(c = nil)
|
|
39
|
+
return default_options[:cookies] if c.nil?
|
|
40
|
+
default_options[:cookies] ||= {}
|
|
41
|
+
default_options[:cookies].merge!(c)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def default_options
|
|
45
|
+
@default_options ||= {}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def get(url, options = {})
|
|
49
|
+
request(:get, url, options)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def post(url, options = {})
|
|
53
|
+
request(:post, url, options)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def put(url, options = {})
|
|
57
|
+
request(:put, url, options)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def patch(url, options = {})
|
|
61
|
+
request(:patch, url, options)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def delete(url, options = {})
|
|
65
|
+
request(:delete, url, options)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def head(url, options = {})
|
|
69
|
+
request(:head, url, options)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def options(url, options = {})
|
|
73
|
+
request(:options, url, options)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def request(method, url, options = {})
|
|
77
|
+
merged = default_options.merge(options)
|
|
78
|
+
|
|
79
|
+
# Deep merge headers
|
|
80
|
+
if default_options[:headers] || options[:headers]
|
|
81
|
+
base_h = default_options[:headers] || {}
|
|
82
|
+
opt_h = options[:headers] || {}
|
|
83
|
+
merged[:headers] = base_h.merge(opt_h)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Deep merge query
|
|
87
|
+
base_q = default_options[:query] || {}
|
|
88
|
+
opt_q = options[:query] || options[:params] || {}
|
|
89
|
+
if !base_q.empty? || !opt_q.empty?
|
|
90
|
+
merged[:query] = base_q.merge(opt_q)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Deep merge cookies
|
|
94
|
+
if default_options[:cookies].is_a?(Hash) && options[:cookies].is_a?(Hash)
|
|
95
|
+
merged[:cookies] = default_options[:cookies].merge(options[:cookies])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
Request.new(method, url, merged).perform
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module HttpMimic
|
|
6
|
+
class Request
|
|
7
|
+
attr_reader :method, :url, :options, :config
|
|
8
|
+
|
|
9
|
+
def initialize(method, url, options = {}, config = nil)
|
|
10
|
+
@method = method
|
|
11
|
+
@url = url
|
|
12
|
+
@options = options.dup
|
|
13
|
+
@config = config || HttpMimic.configuration
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def perform
|
|
17
|
+
builder = CommandBuilder.new(method, url, options, config)
|
|
18
|
+
binary, args, stdin_data, final_url = builder.build
|
|
19
|
+
|
|
20
|
+
full_command = [binary] + args
|
|
21
|
+
|
|
22
|
+
log_debug("Executing HttpMimic command: #{full_command.join(' ')}")
|
|
23
|
+
log_debug("Stdin data: #{stdin_data}") if stdin_data
|
|
24
|
+
|
|
25
|
+
stdout, stderr, status = execute_open3(full_command, stdin_data)
|
|
26
|
+
|
|
27
|
+
log_debug("Curl exit status: #{status.exitstatus}")
|
|
28
|
+
log_debug("Curl stderr: #{stderr}") unless stderr.empty?
|
|
29
|
+
|
|
30
|
+
response = ResponseParser.new(
|
|
31
|
+
stdout,
|
|
32
|
+
exit_status: status,
|
|
33
|
+
stderr: stderr,
|
|
34
|
+
command: full_command,
|
|
35
|
+
request_url: final_url
|
|
36
|
+
).parse
|
|
37
|
+
|
|
38
|
+
handle_errors(status, stderr, full_command, response)
|
|
39
|
+
|
|
40
|
+
response
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def execute_open3(command_array, stdin_data)
|
|
46
|
+
if stdin_data
|
|
47
|
+
Open3.capture3(*command_array, stdin_data: stdin_data)
|
|
48
|
+
else
|
|
49
|
+
Open3.capture3(*command_array)
|
|
50
|
+
end
|
|
51
|
+
rescue Errno::ENOENT => e
|
|
52
|
+
raise BinaryNotFoundError, "Executable not found #{command_array.first}: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def handle_errors(status, stderr, command, response)
|
|
56
|
+
raise_error = options.fetch(:raise_on_error, config.raise_on_error)
|
|
57
|
+
exit_code = status.exitstatus
|
|
58
|
+
|
|
59
|
+
if exit_code != 0
|
|
60
|
+
message = "curl execution error (exit code #{exit_code}): #{stderr.strip}"
|
|
61
|
+
error_class = case exit_code
|
|
62
|
+
when 28
|
|
63
|
+
TimeoutError
|
|
64
|
+
when 6, 7, 52
|
|
65
|
+
ConnectionError
|
|
66
|
+
when 35, 51, 60
|
|
67
|
+
SSLError
|
|
68
|
+
else
|
|
69
|
+
CommandError
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if raise_error
|
|
73
|
+
raise error_class.new(message, exit_code: exit_code, stderr: stderr, command: command, response: response)
|
|
74
|
+
end
|
|
75
|
+
elsif raise_error && response.error?
|
|
76
|
+
raise CommandError.new("HTTP request failed (status #{response.code})", exit_code: exit_code, stderr: stderr, command: command, response: response)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def log_debug(message)
|
|
81
|
+
if config.logger
|
|
82
|
+
config.logger.debug(message)
|
|
83
|
+
elsif config.debug || options[:debug]
|
|
84
|
+
puts "[HttpMimic DEBUG] #{message}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpMimic
|
|
4
|
+
class Response
|
|
5
|
+
attr_reader :code,
|
|
6
|
+
:http_version,
|
|
7
|
+
:status_message,
|
|
8
|
+
:headers,
|
|
9
|
+
:cookies,
|
|
10
|
+
:body,
|
|
11
|
+
:parsed_response,
|
|
12
|
+
:raw_headers,
|
|
13
|
+
:history,
|
|
14
|
+
:request_url,
|
|
15
|
+
:stderr,
|
|
16
|
+
:exit_code,
|
|
17
|
+
:command
|
|
18
|
+
|
|
19
|
+
alias status code
|
|
20
|
+
|
|
21
|
+
def initialize(attributes = {})
|
|
22
|
+
@code = attributes[:code] || 0
|
|
23
|
+
@http_version = attributes[:http_version]
|
|
24
|
+
@status_message = attributes[:status_message]
|
|
25
|
+
@headers = attributes[:headers] || Headers.new
|
|
26
|
+
@cookies = attributes[:cookies] || Cookies.new
|
|
27
|
+
@body = attributes[:body] || ''
|
|
28
|
+
@parsed_response = attributes[:parsed_response]
|
|
29
|
+
@raw_headers = attributes[:raw_headers] || ''
|
|
30
|
+
@history = attributes[:history] || []
|
|
31
|
+
@request_url = attributes[:request_url]
|
|
32
|
+
@stderr = attributes[:stderr] || ''
|
|
33
|
+
@exit_code = attributes[:exit_code] || 0
|
|
34
|
+
@command = attributes[:command]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def success?
|
|
38
|
+
code >= 200 && code < 300
|
|
39
|
+
end
|
|
40
|
+
alias ok? success?
|
|
41
|
+
|
|
42
|
+
def redirect?
|
|
43
|
+
code >= 300 && code < 400
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def client_error?
|
|
47
|
+
code >= 400 && code < 500
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def server_error?
|
|
51
|
+
code >= 500 && code < 600
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def error?
|
|
55
|
+
client_error? || server_error?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def [](key)
|
|
59
|
+
if parsed_response.is_a?(Hash) || parsed_response.is_a?(Array)
|
|
60
|
+
parsed_response[key]
|
|
61
|
+
else
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def to_s
|
|
67
|
+
body.to_s
|
|
68
|
+
end
|
|
69
|
+
alias to_str to_s
|
|
70
|
+
|
|
71
|
+
def blank?
|
|
72
|
+
body.nil? || body.strip.empty?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def present?
|
|
76
|
+
!blank?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def inspect
|
|
80
|
+
"#<#{self.class.name}:0x#{object_id.to_s(16)} @code=#{code} @status_message=#{status_message.inspect} @headers=#{headers.to_h.inspect} @parsed_response=#{parsed_response.inspect}>"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def method_missing(method_name, *args, &block)
|
|
84
|
+
if parsed_response.respond_to?(method_name)
|
|
85
|
+
parsed_response.public_send(method_name, *args, &block)
|
|
86
|
+
elsif body.respond_to?(method_name)
|
|
87
|
+
body.public_send(method_name, *args, &block)
|
|
88
|
+
else
|
|
89
|
+
super
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
94
|
+
parsed_response.respond_to?(method_name, include_private) ||
|
|
95
|
+
body.respond_to?(method_name, include_private) ||
|
|
96
|
+
super
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|