spectre-http 2.0.1

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,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3de456efce6ddaaac5c17c26b2bd4e8c137e4075a14ec0d86dfd6703b89d96c6
4
+ data.tar.gz: c1620ce695e4fd5c8133010619a9b33250f1e687268dd8a044501ad9e5865fab
5
+ SHA512:
6
+ metadata.gz: 80fefe48489d2dcf898c8d5bb4e98eabd322b7b53bbb802f2f1b297f98291ec47a9d5a91cdd3295545b89bedcc26b86a6d03a58b2678a9301f3a852c78a3f083
7
+ data.tar.gz: 4ddd9f6138710f28abde160b234731ad0fa1a4a17b4b693d8f9c608426bbeabb0a92985f0f122e4c3bc611cfe34528000477feb197b3593bd33801cc9f9571bc
@@ -0,0 +1,101 @@
1
+ require_relative '../http'
2
+
3
+ module Spectre
4
+ module Http
5
+ class SpectreHttpRequest
6
+ def keystone url, username, password, project, domain, cert = nil
7
+ @__req['keystone'] = {} unless @__req.key? 'keystone'
8
+
9
+ @__req['keystone']['url'] = url
10
+ @__req['keystone']['username'] = username
11
+ @__req['keystone']['password'] = password
12
+ @__req['keystone']['project'] = project
13
+ @__req['keystone']['domain'] = domain
14
+ @__req['keystone']['cert'] = cert
15
+
16
+ @__req['auth'] = 'keystone'
17
+ end
18
+ end
19
+
20
+ module Keystone
21
+ @@cache = {}
22
+
23
+ def self.on_req _http, net_req, req
24
+ return unless req.key? 'keystone' and req['auth'] == 'keystone'
25
+
26
+ keystone_cfg = req['keystone']
27
+
28
+ if @@cache.key? keystone_cfg
29
+ token = @@cache[keystone_cfg]
30
+ else
31
+ token, = authenticate(
32
+ keystone_cfg['url'],
33
+ keystone_cfg['username'],
34
+ keystone_cfg['password'],
35
+ keystone_cfg['project'],
36
+ keystone_cfg['domain'],
37
+ keystone_cfg['cert']
38
+ )
39
+
40
+ @@cache[keystone_cfg] = token
41
+ end
42
+
43
+ net_req['X-Auth-Token'] = token
44
+ end
45
+
46
+ def self.authenticate keystone_url, username, password, project, domain, cert
47
+ auth_data = {
48
+ auth: {
49
+ identity: {
50
+ methods: ['password'],
51
+ password: {
52
+ user: {
53
+ name: username,
54
+ password: password,
55
+ domain: {
56
+ name: domain,
57
+ },
58
+ },
59
+ },
60
+ },
61
+ scope: {
62
+ project: {
63
+ name: project,
64
+ domain: {
65
+ name: domain,
66
+ },
67
+ },
68
+ },
69
+ },
70
+ }
71
+
72
+ keystone_url += '/' unless keystone_url.end_with? '/'
73
+
74
+ base_uri = URI(keystone_url)
75
+ uri = URI.join(base_uri, 'auth/tokens?nocatalog=true')
76
+
77
+ http = Net::HTTP.new(base_uri.host, base_uri.port)
78
+
79
+ if cert
80
+ http.use_ssl = true
81
+ http.ca_file = cert
82
+ end
83
+
84
+ req = Net::HTTP::Post.new(uri)
85
+ req.body = JSON.pretty_generate(auth_data)
86
+ req.content_type = 'application/json'
87
+
88
+ res = http.request(req)
89
+
90
+ raise "error while authenticating: #{res.code} #{res.message}\n#{res.body}" if res.code != '201'
91
+
92
+ [
93
+ res['X-Subject-Token'],
94
+ JSON.parse(res.body),
95
+ ]
96
+ end
97
+
98
+ Spectre::Http::MODULES << self
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,474 @@
1
+ require 'net/http'
2
+ require 'openssl'
3
+ require 'json'
4
+ require 'jsonpath'
5
+ require 'yaml'
6
+ require 'securerandom'
7
+ require 'logger'
8
+ require 'ostruct'
9
+ require 'ectoplasm'
10
+
11
+ class ::String
12
+ def pick path
13
+ raise ArgumentError, "`path' must not be nil or empty" if path.nil? or path.empty?
14
+
15
+ begin
16
+ JsonPath.on(self, path)
17
+ rescue MultiJson::ParseError
18
+ # do nothing and return nil
19
+ end
20
+ end
21
+ end
22
+
23
+ class ::OpenStruct
24
+ def pick path
25
+ raise ArgumentError, "`path' must not be nil or empty" if path.nil? or path.empty?
26
+
27
+ JsonPath.on(self, path)
28
+ end
29
+ end
30
+
31
+ module Spectre
32
+ module Http
33
+ DEFAULT_HTTP_CONFIG = {
34
+ 'method' => 'GET',
35
+ 'path' => '',
36
+ 'host' => nil,
37
+ 'port' => 80,
38
+ 'scheme' => 'http',
39
+ 'use_ssl' => false,
40
+ 'cert' => nil,
41
+ 'headers' => [],
42
+ 'query' => [],
43
+ 'params' => {},
44
+ 'content_type' => nil,
45
+ 'timeout' => 180,
46
+ 'retries' => 0,
47
+ }
48
+
49
+ class SpectreHttpError < StandardError
50
+ end
51
+
52
+ class SpectreHttpRequest
53
+ include Spectre::Delegate if defined? Spectre::Delegate
54
+
55
+ class Headers
56
+ CONTENT_TYPE = 'Content-Type'
57
+ UNIQUE_HEADERS = [CONTENT_TYPE].freeze
58
+ end
59
+
60
+ def initialize request
61
+ @__req = request
62
+ end
63
+
64
+ def endpoint name
65
+ @__req['endpoint'] = name
66
+ end
67
+
68
+ def method method_name
69
+ @__req['method'] = method_name.upcase
70
+ end
71
+
72
+ [:get, :post, :put, :patch, :delete].each do |method|
73
+ define_method(method) do |url_path|
74
+ @__req['method'] = method.to_s.upcase
75
+ @__req['path'] = url_path
76
+ end
77
+ end
78
+
79
+ def url base_url
80
+ @__req['base_url'] = base_url
81
+ end
82
+
83
+ def path url_path
84
+ @__req['path'] = url_path
85
+ end
86
+
87
+ def basic_auth username, password
88
+ @__req['basic_auth'] = {
89
+ 'username' => username,
90
+ 'password' => password,
91
+ }
92
+
93
+ @__req['auth'] = 'basic_auth'
94
+ end
95
+
96
+ def timeout seconds
97
+ @__req['timeout'] = seconds
98
+ end
99
+
100
+ def retries count
101
+ @__req['retries'] = count
102
+ end
103
+
104
+ def header name, value
105
+ @__req['headers'].append [name.to_sym, value.to_s.strip]
106
+ end
107
+
108
+ def query name = nil, value = nil, **kwargs
109
+ @__req['query'].append [name, value.to_s.strip] unless name.nil?
110
+ @__req['query'] += kwargs.map { |key, val| [key.to_s, val] } if kwargs.any?
111
+ end
112
+
113
+ # This alias is deprecated and should not be used anymore
114
+ # in favor on +query+ as it conflicts with the route +params+ property
115
+ alias param query
116
+
117
+ def with **params
118
+ @__req['params'].merge! params
119
+ end
120
+
121
+ def content_type media_type
122
+ @__req['content_type'] = media_type
123
+ end
124
+
125
+ def json data
126
+ body JSON.pretty_generate(data)
127
+
128
+ content_type('application/json') unless @__req['content_type']
129
+ end
130
+
131
+ def body body_content
132
+ @__req['body'] = body_content.to_s
133
+ end
134
+
135
+ def ensure_success!
136
+ @__req['ensure_success'] = true
137
+ end
138
+
139
+ def ensure_success?
140
+ @__req['ensure_success']
141
+ end
142
+
143
+ def authenticate method
144
+ @__req['auth'] = method
145
+ end
146
+
147
+ def no_auth!
148
+ @__req['auth'] = 'none'
149
+ end
150
+
151
+ def certificate path
152
+ @__req['cert'] = path
153
+ end
154
+
155
+ def use_ssl!
156
+ @__req['use_ssl'] = true
157
+ end
158
+
159
+ def no_log!
160
+ @__req['no_log'] = true
161
+ end
162
+
163
+ def to_s
164
+ @__req.to_s
165
+ end
166
+
167
+ alias auth authenticate
168
+ alias cert certificate
169
+ alias media_type content_type
170
+ end
171
+
172
+ class SpectreHttpHeader
173
+ def initialize headers
174
+ @headers = headers || {}
175
+ end
176
+
177
+ def [] key
178
+ return nil unless @headers.key?(key.downcase)
179
+
180
+ @headers[key.downcase].first
181
+ end
182
+
183
+ def to_s
184
+ @headers.to_s
185
+ end
186
+ end
187
+
188
+ class SpectreHttpResponse
189
+ attr_reader :code, :message, :headers, :body, :json
190
+
191
+ def initialize net_res
192
+ @code = net_res.code.to_i
193
+ @message = net_res.message
194
+ @body = net_res.body
195
+ @headers = SpectreHttpHeader.new(net_res.to_hash)
196
+ @json = nil
197
+
198
+ return if @body.nil?
199
+
200
+ begin
201
+ @json = JSON.parse(@body, object_class: OpenStruct)
202
+ rescue JSON::ParserError
203
+ # Shhhhh... it's ok. Do nothing here
204
+ end
205
+ end
206
+
207
+ def success?
208
+ @code < 400
209
+ end
210
+ end
211
+
212
+ PROGNAME = 'spectre/http'
213
+ MODULES = []
214
+ DEFAULT_SECURE_KEYS = ['password', 'pass', 'token', 'secret', 'key', 'auth',
215
+ 'authorization', 'cookie', 'session', 'csrf', 'jwt', 'bearer']
216
+
217
+ class Client
218
+ def initialize config, logger
219
+ @config = config['http']
220
+ @logger = logger
221
+ @debug = config['debug'] || false
222
+ @openapi_cache = {}
223
+ end
224
+
225
+ def https(name, &)
226
+ http(name, secure: true, &)
227
+ end
228
+
229
+ def http(name, secure: false, &)
230
+ req = Marshal.load(Marshal.dump(DEFAULT_HTTP_CONFIG))
231
+
232
+ if @config.key? name
233
+ deep_merge(req, Marshal.load(Marshal.dump(@config[name])))
234
+
235
+ unless req['base_url']
236
+ raise SpectreHttpError, "No `base_url' set for HTTP client '#{name}'. " \
237
+ 'Check your HTTP config in your environment.'
238
+ end
239
+ else
240
+ req['base_url'] = name
241
+ end
242
+
243
+ req['use_ssl'] = secure unless secure.nil?
244
+
245
+ SpectreHttpRequest.new(req).instance_eval(&) if block_given?
246
+
247
+ invoke(req)
248
+ end
249
+
250
+ def request
251
+ req = Thread.current.thread_variable_get(:request)
252
+
253
+ raise 'No request has been invoked yet' unless req
254
+
255
+ req
256
+ end
257
+
258
+ def response
259
+ res = Thread.current.thread_variable_get(:response)
260
+
261
+ raise 'No response has been received yet' unless res
262
+
263
+ res
264
+ end
265
+
266
+ private
267
+
268
+ def deep_merge(first, second)
269
+ return unless second.is_a?(Hash)
270
+
271
+ merger = proc { |_key, v1, v2| v1.is_a?(Hash) && v2.is_a?(Hash) ? v1.merge!(v2, &merger) : v2 }
272
+ first.merge!(second, &merger)
273
+ end
274
+
275
+ def try_format_json json, pretty: false
276
+ return json unless json or json.empty?
277
+
278
+ if json.is_a? String
279
+ begin
280
+ json = JSON.parse(json)
281
+ rescue StandardError
282
+ # do nothing
283
+ end
284
+ end
285
+
286
+ json.obfuscate!(DEFAULT_SECURE_KEYS) unless @debug
287
+ pretty ? JSON.pretty_generate(json) : JSON.dump(json)
288
+ end
289
+
290
+ def secure? key
291
+ DEFAULT_SECURE_KEYS.any? { |x| key.to_s.downcase.include? x.downcase }
292
+ end
293
+
294
+ def header_to_s headers
295
+ s = ''
296
+ headers.each_header.each do |header, value|
297
+ value = '*****' if secure?(header) and !@debug
298
+ s += "#{header.to_s.ljust(30, '.')}: #{value}\n"
299
+ end
300
+ s
301
+ end
302
+
303
+ def load_openapi config
304
+ path = config['openapi']
305
+
306
+ return @openapi_cache[path] if @openapi_cache.key? path
307
+
308
+ content = if path.match 'http[s]?://'
309
+ Net::HTTP.get URI(path)
310
+ else
311
+ File.read(path)
312
+ end
313
+
314
+ openapi = YAML.safe_load(content)
315
+
316
+ config['endpoints'] = {}
317
+
318
+ openapi['paths'].each do |uri_path, path_config|
319
+ path_config.each do |method, endpoint|
320
+ config['endpoints'][endpoint['operationId']] = {
321
+ 'method' => method.upcase,
322
+ 'path' => uri_path,
323
+ }
324
+ end
325
+ end
326
+
327
+ @openapi_cache[path] = config['endpoints']
328
+ end
329
+
330
+ def invoke req
331
+ Thread.current.thread_variable_set(:request, nil)
332
+
333
+ # Build URI
334
+
335
+ scheme = req['use_ssl'] ? 'https' : 'http'
336
+ base_url = req['base_url']
337
+
338
+ base_url = "#{scheme}://#{base_url}" unless base_url.match %r{http(?:s)?://}
339
+
340
+ if req.key? 'endpoint'
341
+ load_openapi(req) if req.key? 'openapi'
342
+
343
+ raise 'no endpoints configured' unless req.key? 'endpoints'
344
+
345
+ endpoint = req['endpoints'][req['endpoint']] or raise 'endpoint not found'
346
+ endpoint = Marshal.load(Marshal.dump(endpoint))
347
+
348
+ req.merge! endpoint
349
+ end
350
+
351
+ method = req['method'] || 'GET'
352
+ path = req['path']
353
+
354
+ if path
355
+ base_url += '/' unless base_url.end_with? '/'
356
+ path = path[1..] if path.start_with? '/'
357
+ base_url += path
358
+
359
+ req['params'].each do |key, val|
360
+ base_url.gsub! "{#{key}}", val.to_s
361
+ end
362
+ end
363
+
364
+ uri = URI(base_url)
365
+
366
+ raise SpectreHttpError, "'#{uri}' is not a valid uri" unless uri.host
367
+
368
+ # Build query parameters
369
+
370
+ uri.query = URI.encode_www_form(req['query']) unless !req['query'] or req['query'].empty?
371
+
372
+ # Create HTTP client
373
+
374
+ net_http = Net::HTTP.new(uri.host, uri.port)
375
+ net_http.read_timeout = req['timeout']
376
+ net_http.max_retries = req['retries']
377
+
378
+ if uri.scheme == 'https'
379
+ net_http.use_ssl = true
380
+
381
+ if req['cert']
382
+ raise SpectreHttpError, "Certificate '#{req['cert']}' does not exist" unless File.exist? req['cert']
383
+
384
+ net_http.verify_mode = OpenSSL::SSL::VERIFY_PEER
385
+ net_http.ca_file = req['cert']
386
+ else
387
+ net_http.verify_mode = OpenSSL::SSL::VERIFY_NONE
388
+ end
389
+ end
390
+
391
+ # Create HTTP Request
392
+
393
+ net_req = Net::HTTPGenericRequest.new(method, true, true, uri)
394
+ body = req['body']
395
+ body = JSON.dump(body) if body.is_a? Hash
396
+ net_req.body = body
397
+ net_req.content_type = req['content_type'] if req['content_type'] and !req['content_type'].empty?
398
+
399
+ if req.key? 'basic_auth' and req['auth'] == 'basic_auth'
400
+ net_req.basic_auth(req['basic_auth']['username'], req['basic_auth']['password'])
401
+ end
402
+
403
+ req['headers']&.each do |header|
404
+ net_req[header[0]] = header[1]
405
+ end
406
+
407
+ req_id = SecureRandom.uuid[0..5]
408
+
409
+ # Run HTTP modules
410
+
411
+ MODULES.each do |mod|
412
+ mod.on_req(net_http, net_req, req) if mod.respond_to? :on_req
413
+ end
414
+
415
+ # Log request
416
+
417
+ req_log = "[>] #{req_id} #{method} #{uri}\n"
418
+ req_log += header_to_s(net_req)
419
+
420
+ unless req['body'].nil? or req['body'].empty?
421
+ req_log += req['no_log'] ? '[...]' : try_format_json(req['body'], pretty: true)
422
+ end
423
+
424
+ @logger.log(Logger::Severity::INFO, req_log, PROGNAME)
425
+
426
+ # Request
427
+
428
+ start_time = Time.now
429
+
430
+ begin
431
+ net_res = net_http.request(net_req)
432
+ rescue SocketError => e
433
+ raise SpectreHttpError, "The request '#{method} #{uri}' failed: #{e.message}\n" \
434
+ "Please check if the given URL '#{uri}' is valid " \
435
+ 'and available or a corresponding HTTP config in ' \
436
+ 'the environment file exists. See log for more details. '
437
+ rescue Net::ReadTimeout
438
+ raise SpectreHttpError, "HTTP timeout of #{net_http.read_timeout}s exceeded"
439
+ end
440
+
441
+ end_time = Time.now
442
+
443
+ req['started_at'] = start_time
444
+ req['finished_at'] = end_time
445
+
446
+ # Run HTTP modules
447
+
448
+ MODULES.each do |mod|
449
+ mod.on_res(net_http, net_res, req) if mod.respond_to? :on_res
450
+ end
451
+
452
+ # Log response
453
+
454
+ res_log = "[<] #{req_id} #{net_res.code} #{net_res.message} (#{end_time - start_time}s)\n"
455
+ res_log += header_to_s(net_res)
456
+
457
+ unless net_res.body.nil? or net_res.body.empty?
458
+ res_log += req['no_log'] ? '[...]' : try_format_json(net_res.body, pretty: true)
459
+ end
460
+
461
+ @logger.log(Logger::Severity::INFO, res_log, PROGNAME)
462
+
463
+ if req['ensure_success'] and net_res.code.to_i >= 400
464
+ raise "Response code of #{req_id} did not indicate success: #{net_res.code} #{net_res.message}"
465
+ end
466
+
467
+ Thread.current.thread_variable_set(:request, OpenStruct.new(req).freeze)
468
+ Thread.current.thread_variable_set(:response, SpectreHttpResponse.new(net_res).freeze)
469
+ end
470
+ end
471
+ end
472
+
473
+ Engine.register(Http::Client, :http, :https, :request, :response) if defined? Engine
474
+ end
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spectre-http
3
+ version: !ruby/object:Gem::Version
4
+ version: 2.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Christian Neubauer
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-03-21 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ectoplasm
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: jsonpath
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: logger
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: ostruct
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ description: A HTTP wrapper for nice readability. Is compatible with spectre-core.
69
+ email:
70
+ - christian.neubauer@ionos.com
71
+ executables: []
72
+ extensions: []
73
+ extra_rdoc_files: []
74
+ files:
75
+ - lib/spectre/http.rb
76
+ - lib/spectre/http/keystone.rb
77
+ homepage: https://github.com/ionos-spectre/spectre-http
78
+ licenses:
79
+ - GPL-3.0-or-later
80
+ metadata:
81
+ homepage_uri: https://github.com/ionos-spectre/spectre-http
82
+ source_code_uri: https://github.com/ionos-spectre/spectre-http
83
+ changelog_uri: https://github.com/ionos-spectre/spectre-http/blob/master/CHANGELOG.md
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '3.4'
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubygems_version: 3.6.2
99
+ specification_version: 4
100
+ summary: Standalone HTTP wrapper compatible with spectre
101
+ test_files: []