blackevin 0.1.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.
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blackevin
4
+ class Rest
5
+ # The channels of one client. +get+ returns the same object for the same name.
6
+ class Channels
7
+ def initialize(rest)
8
+ @rest = rest
9
+ @channels = {}
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ # @param name [String]
14
+ # @return [Blackevin::Rest::Channel]
15
+ def get(name)
16
+ name = name.to_s
17
+
18
+ raise ConfigurationError, "a channel needs a name" if name.empty?
19
+
20
+ @mutex.synchronize { @channels[name] ||= Channel.new(@rest, name) }
21
+ end
22
+
23
+ alias_method :[], :get
24
+ end
25
+
26
+ # One channel: publish, history, presence.
27
+ class Channel
28
+ DEFAULT_HISTORY_LIMIT = 100
29
+
30
+ # @return [String]
31
+ attr_reader :name
32
+
33
+ # @return [Blackevin::Rest::Presence]
34
+ attr_reader :presence
35
+
36
+ def initialize(rest, name)
37
+ @rest = rest
38
+ @name = name
39
+ @path = "/api/channels/#{Rest.escape(name)}"
40
+ @presence = Presence.new(rest, @path)
41
+ end
42
+
43
+ # Publishes without a socket — from a job, a cron, or a webhook arriving
44
+ # at your backend. Subscribers, account queues and integrations see it
45
+ # exactly as they see a socket publish.
46
+ #
47
+ # @param event [String, Symbol] the event name
48
+ # @param data [Object, nil] anything JSON can carry
49
+ # @return [true]
50
+ # @raise [Blackevin::Error]
51
+ def publish(event, data = nil)
52
+ body = {"name" => event.to_s}
53
+ body["data"] = data unless data.nil?
54
+
55
+ @rest.request("publish", "POST", "#{@path}/publish", body: body)
56
+
57
+ true
58
+ end
59
+
60
+ # Stored messages, newest first.
61
+ #
62
+ # @param limit [Integer] clamped by the node to 1..1000
63
+ # @return [Array<Blackevin::Message>]
64
+ # @raise [Blackevin::Error]
65
+ def history(limit: DEFAULT_HISTORY_LIMIT)
66
+ body = @rest.request("history", "GET", "#{@path}/history", query: {"limit" => limit})
67
+
68
+ Array(body["messages"]).map { Message.from_h(_1) }
69
+ end
70
+
71
+ def inspect = "#<Blackevin::Rest::Channel name=#{name.inspect}>"
72
+ end
73
+
74
+ # Presence on one channel.
75
+ class Presence
76
+ def initialize(rest, channel_path)
77
+ @rest = rest
78
+ @path = "#{channel_path}/presence"
79
+ end
80
+
81
+ # Who is present now.
82
+ #
83
+ # @return [Array<Blackevin::PresenceMember>]
84
+ # @raise [Blackevin::Error]
85
+ def get
86
+ body = @rest.request("presence get", "GET", @path)
87
+
88
+ Array(body["members"]).map { PresenceMember.from_h(_1) }
89
+ end
90
+
91
+ # Past enter, leave and update events, newest first.
92
+ #
93
+ # @param limit [Integer] clamped by the node to 1..1000
94
+ # @return [Array<Blackevin::PresenceEvent>]
95
+ # @raise [Blackevin::Error]
96
+ def history(limit: Channel::DEFAULT_HISTORY_LIMIT)
97
+ body = @rest.request("presence history", "GET", "#{@path}/history", query: {"limit" => limit})
98
+
99
+ Array(body["events"]).map { PresenceEvent.from_h(_1) }
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blackevin
4
+ class Rest
5
+ # Acting on connected clients.
6
+ class Clients
7
+ def initialize(rest)
8
+ @rest = rest
9
+ end
10
+
11
+ # Closes every connection a client holds — for signing a user out
12
+ # everywhere, or cutting off one you have just banned.
13
+ #
14
+ # @param client_id [String, Integer]
15
+ # @return [Integer] how many connections were closed
16
+ # @raise [Blackevin::Error]
17
+ def disconnect(client_id)
18
+ client_id = client_id.to_s
19
+
20
+ raise ConfigurationError, "disconnect needs a client_id" if client_id.empty?
21
+
22
+ path = "/api/clients/#{Rest.escape(client_id)}/connections/close"
23
+
24
+ @rest.request("disconnect", "POST", path)["closed"].to_i
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blackevin
4
+ class Rest
5
+ # Account queues and the rules that feed them. The key needs +amqp-subscribe+.
6
+ #
7
+ # A queue is addressed by its id, never its name: the name is the physical
8
+ # queue on the broker, so a 404 must mean "no such queue".
9
+ class Queues
10
+ def initialize(rest)
11
+ @rest = rest
12
+ end
13
+
14
+ # @param all [Boolean] include paused queues
15
+ # @return [Array<Blackevin::Queue>]
16
+ # @raise [Blackevin::Error]
17
+ def list(all: false)
18
+ body = @rest.request("list queues", "GET", "/api/queues", query: all ? {"all" => "1"} : nil)
19
+
20
+ Array(body["queues"]).map { Queue.from_h(_1) }
21
+ end
22
+
23
+ # Creates a queue, or edits the one already holding this name. Only a new
24
+ # queue counts against the plan's ceiling.
25
+ #
26
+ # @param name [String]
27
+ # @param max_length [Integer, nil]
28
+ # @param enabled [Boolean]
29
+ # @return [Blackevin::Queue]
30
+ # @raise [Blackevin::Error] with reason "queue_limit" at the plan's ceiling
31
+ def upsert(name:, max_length: nil, enabled: true)
32
+ body = {"name" => name.to_s, "enabled" => enabled}
33
+ body["maxLength"] = max_length unless max_length.nil?
34
+
35
+ Queue.from_h(@rest.request("upsert queue", "POST", "/api/queues", body: body).fetch("queue"))
36
+ end
37
+
38
+ alias_method :create, :upsert
39
+
40
+ # Pauses, resumes or resizes. Omitted arguments are left as they are;
41
+ # +max_length: nil+ passed explicitly removes the bound.
42
+ #
43
+ # @param id [String]
44
+ # @param enabled [Boolean]
45
+ # @param max_length [Integer, nil]
46
+ # @return [Blackevin::Queue]
47
+ # @raise [Blackevin::Error]
48
+ def update(id, **changes)
49
+ unknown = changes.except(:enabled, :max_length).keys
50
+
51
+ raise ArgumentError, "unknown keywords: #{unknown.join(", ")}" unless unknown.empty?
52
+
53
+ body = {}
54
+ body["enabled"] = changes[:enabled] if changes.key?(:enabled)
55
+ body["maxLength"] = changes[:max_length] if changes.key?(:max_length)
56
+
57
+ Queue.from_h(@rest.request("update queue", "PATCH", queue_path(id), body: body).fetch("queue"))
58
+ end
59
+
60
+ # Deletes the queue and whatever is waiting in it.
61
+ #
62
+ # @param id [String]
63
+ # @return [String] the name of the deleted queue
64
+ # @raise [Blackevin::Error]
65
+ def delete(id)
66
+ @rest.request("delete queue", "DELETE", queue_path(id))["deleted"]
67
+ end
68
+
69
+ # @param id [String] the queue's id
70
+ # @return [Array<Blackevin::QueueRule>]
71
+ # @raise [Blackevin::Error]
72
+ def rules(id)
73
+ body = @rest.request("list queue rules", "GET", "#{queue_path(id)}/rules")
74
+
75
+ Array(body["rules"]).map { QueueRule.from_h(_1) }
76
+ end
77
+
78
+ # Copies messages from channels matching the pattern into the queue.
79
+ #
80
+ # @param id [String] the queue's id
81
+ # @param source_pattern [String] a channel name or wildcard pattern
82
+ # @param filter [String, nil]
83
+ # @return [Blackevin::QueueRule]
84
+ # @raise [Blackevin::Error]
85
+ def add_rule(id, source_pattern:, filter: nil)
86
+ body = {"sourcePattern" => source_pattern.to_s}
87
+ body["filter"] = filter unless filter.nil?
88
+
89
+ QueueRule.from_h(@rest.request("add queue rule", "POST", "#{queue_path(id)}/rules", body: body).fetch("rule"))
90
+ end
91
+
92
+ # Stops the copies at the source. Messages already enqueued stay.
93
+ #
94
+ # @param id [String] the queue's id
95
+ # @param rule_id [String]
96
+ # @return [true]
97
+ # @raise [Blackevin::Error]
98
+ def delete_rule(id, rule_id)
99
+ @rest.request("delete queue rule", "DELETE", "#{queue_path(id)}/rules/#{Rest.escape(rule_id)}")
100
+
101
+ true
102
+ end
103
+
104
+ private
105
+
106
+ def queue_path(id)
107
+ raise ConfigurationError, "a queue is addressed by its id" if id.to_s.empty?
108
+
109
+ "/api/queues/#{Rest.escape(id)}"
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Blackevin
6
+ # The REST client: no socket, no reconnection, no state beyond its options.
7
+ #
8
+ # rest = Blackevin::Rest.new(key: ENV.fetch("BLACKEVIN_KEY"))
9
+ #
10
+ # rest.auth.create_token_request(client_id: "bob")
11
+ # rest.channels.get("room:42").publish("greeting", {text: "hi"})
12
+ #
13
+ # Safe to share across threads: every call opens its own connection and the
14
+ # instance holds nothing mutable but a channel cache behind a mutex.
15
+ class Rest
16
+ USER_AGENT = "blackevin-ruby/#{VERSION} ruby/#{RUBY_VERSION}"
17
+
18
+ # @return [Blackevin::Rest::Auth]
19
+ attr_reader :auth
20
+
21
+ # @return [Blackevin::Rest::Channels]
22
+ attr_reader :channels
23
+
24
+ # @return [Blackevin::Rest::Clients]
25
+ attr_reader :clients
26
+
27
+ # @return [Blackevin::Rest::Queues]
28
+ attr_reader :queues
29
+
30
+ # @return [String] the resolved REST base URL
31
+ attr_reader :rest_endpoint
32
+
33
+ # @param key [String, nil] full API key +secret.keyId+; required to sign token requests
34
+ # @param token [String, nil] a token, when acting as one client rather than as the account
35
+ # @param rest_endpoint [String, nil] overrides the resolved REST host
36
+ # @param endpoint [String, nil] socket endpoint; read only to derive the REST base
37
+ # @param open_timeout [Numeric] seconds
38
+ # @param read_timeout [Numeric] seconds
39
+ # @param transport [#call, nil] replaces Net::HTTP; receives a {Request}, returns a {Response}
40
+ # @param clock [#call] returns milliseconds since the epoch; injected for tests
41
+ # @param nonce [#call] returns a fresh nonce; injected for tests
42
+ # @param env [#[]] where the endpoint variables are read from
43
+ def initialize(key: nil, token: nil, rest_endpoint: nil, endpoint: nil, open_timeout: 5, read_timeout: 10,
44
+ transport: nil, clock: nil, nonce: nil, env: ENV)
45
+ @key = key
46
+ @token = token
47
+ @rest_endpoint = Endpoints.resolve(endpoint: endpoint, rest_endpoint: rest_endpoint, env: env).rest_endpoint
48
+ @transport = transport || Transport.new(open_timeout: open_timeout, read_timeout: read_timeout)
49
+
50
+ @auth = Auth.new(self, clock: clock, nonce: nonce)
51
+ @channels = Channels.new(self)
52
+ @clients = Clients.new(self)
53
+ @queues = Queues.new(self)
54
+ end
55
+
56
+ # @return [Blackevin::ApiKey]
57
+ # @raise [Blackevin::ConfigurationError] when no key was given, or it is malformed
58
+ def api_key
59
+ raise ConfigurationError, 'this call needs the API key: pass key: or set BLACKEVIN_KEY' if @key.to_s.strip.empty?
60
+
61
+ ApiKey.parse(@key)
62
+ end
63
+
64
+ # A token wins over a key: a client acting as one user must not silently
65
+ # act as the whole account.
66
+ #
67
+ # @return [String, nil]
68
+ def authorization_header
69
+ return "Bearer #{@token}" if @token
70
+ return nil if @key.nil?
71
+
72
+ "Basic #{[@key].pack('m0')}"
73
+ end
74
+
75
+ # @api private
76
+ def request(what, method, path, query: nil, body: nil, authorize: true)
77
+ headers = { 'accept' => 'application/json', 'user-agent' => USER_AGENT }
78
+ authorization = authorize ? authorization_header : nil
79
+
80
+ headers['authorization'] = authorization if authorization
81
+ headers['content-type'] = 'application/json' if body
82
+
83
+ response = @transport.call(
84
+ Request.new(method: method, url: url_for(path, query), headers: headers, body: body && JSON.generate(body))
85
+ )
86
+
87
+ raise Error.from_response(what, response.status, response.body) unless (200..299).cover?(response.status)
88
+
89
+ parse(what, response)
90
+ end
91
+
92
+ # Percent-encodes one path segment so it decodes back to the exact string.
93
+ #
94
+ # Not form encoding: +URI.encode_www_form_component+ turns a space into "+",
95
+ # which the node reads as a literal plus sign.
96
+ #
97
+ # @api private
98
+ def self.escape(segment) = segment.to_s.b.gsub(/[^A-Za-z0-9\-._~]/) { format('%%%02X', _1.ord) }
99
+
100
+ def inspect = "#<Blackevin::Rest rest_endpoint=#{rest_endpoint.inspect}>"
101
+
102
+ private
103
+
104
+ def url_for(path, query)
105
+ return "#{rest_endpoint}#{path}" if query.nil? || query.empty?
106
+
107
+ "#{rest_endpoint}#{path}?#{URI.encode_www_form(query)}"
108
+ end
109
+
110
+ def parse(what, response)
111
+ case JSON.parse(response.body.to_s)
112
+ in Hash => parsed then parsed
113
+ else {}
114
+ end
115
+ rescue JSON::ParserError
116
+ raise Error.new("#{what} failed: the response was not JSON", response.status)
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blackevin
4
+ # The token a TokenRequest was exchanged for, and what it is good for.
5
+ class TokenDetails
6
+ attr_reader :token, :key_name, :issued, :expires, :capability, :client_id
7
+
8
+ # @param hash [Hash] the wire body of +requestToken+
9
+ # @return [Blackevin::TokenDetails]
10
+ def self.from_h(hash)
11
+ new(
12
+ token: hash['token'],
13
+ key_name: hash['keyName'],
14
+ issued: hash['issued'],
15
+ expires: hash['expires'],
16
+ capability: hash['capability'],
17
+ client_id: hash['clientId']
18
+ )
19
+ end
20
+
21
+ def initialize(token:, key_name: nil, issued: nil, expires: nil, capability: nil, client_id: nil)
22
+ @token = token
23
+ @key_name = key_name
24
+ @issued = issued
25
+ @expires = expires
26
+ @capability = capability
27
+ @client_id = client_id
28
+ end
29
+
30
+ # @return [Time, nil]
31
+ def expires_at = expires&.then { Time.at(_1 / 1000.0) }
32
+
33
+ # @return [Time, nil]
34
+ def issued_at = issued&.then { Time.at(_1 / 1000.0) }
35
+
36
+ def expired?(now = Time.now) = !expires.nil? && expires_at <= now
37
+
38
+ # The wire shape, as the node sent it.
39
+ def to_h
40
+ {
41
+ 'token' => token,
42
+ 'keyName' => key_name,
43
+ 'issued' => issued,
44
+ 'expires' => expires,
45
+ 'capability' => capability,
46
+ 'clientId' => client_id
47
+ }.compact
48
+ end
49
+
50
+ def as_json(*) = to_h
51
+
52
+ def to_json(...) = to_h.to_json(...)
53
+
54
+ # Pattern matching, with the Ruby names. The token itself is left out on
55
+ # purpose: a pattern's bindings end up in logs more easily than a reader.
56
+ def deconstruct_keys(_keys)
57
+ { key_name: key_name, issued: issued, expires: expires, capability: capability, client_id: client_id }
58
+ end
59
+
60
+ # The token is a credential; keep it out of logs.
61
+ def inspect
62
+ "#<Blackevin::TokenDetails key_name=#{key_name.inspect} client_id=#{client_id.inspect} expires=#{expires.inspect} token=[FILTERED]>"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Blackevin
6
+ # The token endpoint a browser's +authUrl+ points at, as a Rack application —
7
+ # so the same object mounts in Rails, Sinatra, Hanami or a bare +config.ru+.
8
+ #
9
+ # The block receives the Rack env and answers who is asking. Return the
10
+ # arguments for {Rest::Auth#create_token_request}, or nil to refuse:
11
+ #
12
+ # TOKENS = Blackevin::TokenEndpoint.new do |env|
13
+ # user = env["warden"]&.user
14
+ #
15
+ # next unless user
16
+ #
17
+ # {client_id: user.id, capability: {"team:#{user.team_id}" => %w[subscribe publish]}}
18
+ # end
19
+ #
20
+ # # Rails: mount TOKENS, at: "/blackevin/token"
21
+ # # Sinatra: map("/blackevin/token") { run TOKENS }
22
+ #
23
+ # This gem does not depend on Rack; a Rack app is only an object with +call+.
24
+ class TokenEndpoint
25
+ ALLOWED_METHODS = %w[GET POST].freeze
26
+
27
+ HEADERS = {
28
+ 'content-type' => 'application/json',
29
+ 'cache-control' => 'no-store'
30
+ }.freeze
31
+
32
+ # @param rest [Blackevin::Rest, nil] defaults to {Blackevin.rest}, resolved per request
33
+ # @yieldparam env [Hash] the Rack env
34
+ # @yieldreturn [Hash, nil] keyword arguments for +create_token_request+, or nil to answer 401
35
+ def initialize(rest: nil, &identify)
36
+ raise ArgumentError, 'TokenEndpoint needs a block that identifies the caller' unless identify
37
+
38
+ @rest = rest
39
+ @identify = identify
40
+ end
41
+
42
+ # @param env [Hash] the Rack env
43
+ # @return [Array(Integer, Hash, Array<String>)]
44
+ def call(env)
45
+ unless ALLOWED_METHODS.include?(env['REQUEST_METHOD'])
46
+ return respond(405, { 'error' => 'method not allowed' }, 'allow' => ALLOWED_METHODS.join(', '))
47
+ end
48
+
49
+ case @identify.call(env)
50
+ in nil | false
51
+ respond(401, { 'error' => 'unauthorized' })
52
+ in Hash => params
53
+ respond(200, (@rest || Blackevin.rest).auth.create_token_request(**params.transform_keys(&:to_sym)).to_h)
54
+ in other
55
+ raise ConfigurationError, "the TokenEndpoint block must return a Hash or nil, got #{other.class}"
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def respond(status, body, extra = {})
62
+ [status, HEADERS.merge(extra), [JSON.generate(body)]]
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'openssl'
5
+
6
+ module Blackevin
7
+ # What your server hands a browser instead of the API key: signed, scoped to a
8
+ # capability, and dead after its ttl. The browser exchanges it for a token.
9
+ #
10
+ # Serialises to the wire shape, so a controller can render it as is:
11
+ #
12
+ # render json: Blackevin.rest.auth.create_token_request(client_id: current_user.id)
13
+ class TokenRequest
14
+ # The field ORDER and the newline after each are the wire contract. The node
15
+ # recomputes exactly this text to verify, and a reordering breaks every
16
+ # token with an error ("invalid token request mac") that names nothing.
17
+ SIGNED_FIELDS = %i[key_name ttl capability client_id timestamp nonce].freeze
18
+
19
+ WIRE_NAMES = {
20
+ key_name: 'keyName',
21
+ ttl: 'ttl',
22
+ capability: 'capability',
23
+ client_id: 'clientId',
24
+ timestamp: 'timestamp',
25
+ nonce: 'nonce',
26
+ mac: 'mac'
27
+ }.freeze
28
+
29
+ attr_reader :key_name, :ttl, :capability, :client_id, :timestamp, :nonce, :mac
30
+
31
+ # @param key_name [String]
32
+ # @param timestamp [Integer] milliseconds since the epoch
33
+ # @param nonce [String] single use, at least 16 characters
34
+ # @param ttl [Integer, nil] milliseconds
35
+ # @param capability [String, nil] a capability map serialised as JSON
36
+ # @param client_id [String, nil]
37
+ # @param mac [String, nil]
38
+ def initialize(key_name:, timestamp:, nonce:, ttl: nil, capability: nil, client_id: nil, mac: nil)
39
+ @key_name = key_name
40
+ @ttl = ttl
41
+ @capability = capability
42
+ @client_id = client_id
43
+ @timestamp = timestamp
44
+ @nonce = nonce
45
+ @mac = mac
46
+ end
47
+
48
+ # Builds one from a wire hash (camelCase keys) or a Ruby hash (snake_case).
49
+ #
50
+ # @param hash [Hash]
51
+ # @return [Blackevin::TokenRequest]
52
+ def self.from_h(hash)
53
+ values = WIRE_NAMES.to_h do |attribute, wire|
54
+ [attribute, hash[wire] || hash[wire.to_sym] || hash[attribute] || hash[attribute.to_s]]
55
+ end
56
+
57
+ new(**values)
58
+ end
59
+
60
+ # The exact text the MAC is computed over.
61
+ def signing_text = SIGNED_FIELDS.map { "#{public_send(_1)}\n" }.join
62
+
63
+ # @param secret [String] the secret half of the API key
64
+ # @return [Blackevin::TokenRequest] a copy carrying the MAC
65
+ def sign(secret)
66
+ digest = OpenSSL::HMAC.digest('SHA256', secret, signing_text)
67
+
68
+ self.class.new(**attributes, mac: [digest].pack('m0'))
69
+ end
70
+
71
+ # The wire shape: camelCase keys, absent fields omitted.
72
+ #
73
+ # @return [Hash{String => Object}]
74
+ def to_h
75
+ WIRE_NAMES.each_with_object({}) do |(attribute, wire), hash|
76
+ value = public_send(attribute)
77
+
78
+ hash[wire] = value unless value.nil?
79
+ end
80
+ end
81
+
82
+ # Rails' +render json:+ calls this, so the wire shape survives it too.
83
+ def as_json(*) = to_h
84
+
85
+ def to_json(...) = to_h.to_json(...)
86
+
87
+ def ==(other) = other.is_a?(self.class) && other.to_h == to_h
88
+
89
+ # Pattern matching, with the Ruby names: +in {client_id:, ttl:}+.
90
+ def deconstruct_keys(_keys) = attributes.merge(mac: mac)
91
+
92
+ private
93
+
94
+ def attributes = SIGNED_FIELDS.to_h { [_1, public_send(_1)] }
95
+ end
96
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'uri'
6
+
7
+ module Blackevin
8
+ # One HTTP exchange, as the transport sees it.
9
+ Request = Struct.new(:method, :url, :headers, :body, keyword_init: true)
10
+ Response = Struct.new(:status, :body, keyword_init: true)
11
+
12
+ # The default transport: Net::HTTP from the standard library, and nothing else.
13
+ #
14
+ # Anything responding to +call(request)+ and returning a {Response} can stand
15
+ # in for it, which is how the specs observe a request without sending one and
16
+ # how an application can route through its own HTTP stack.
17
+ class Transport
18
+ NETWORK_ERRORS = [
19
+ SocketError,
20
+ SystemCallError,
21
+ IOError,
22
+ Timeout::Error,
23
+ OpenSSL::SSL::SSLError,
24
+ Net::HTTPBadResponse,
25
+ Net::ProtocolError
26
+ ].freeze
27
+
28
+ VERBS = {
29
+ 'GET' => Net::HTTP::Get,
30
+ 'POST' => Net::HTTP::Post,
31
+ 'PUT' => Net::HTTP::Put,
32
+ 'PATCH' => Net::HTTP::Patch,
33
+ 'DELETE' => Net::HTTP::Delete
34
+ }.freeze
35
+
36
+ # @param open_timeout [Numeric] seconds to wait for the connection
37
+ # @param read_timeout [Numeric] seconds to wait for each read
38
+ def initialize(open_timeout: 5, read_timeout: 10)
39
+ @open_timeout = open_timeout
40
+ @read_timeout = read_timeout
41
+ end
42
+
43
+ # @param request [Blackevin::Request]
44
+ # @return [Blackevin::Response]
45
+ # @raise [Blackevin::ConnectionError] when no response arrived
46
+ def call(request)
47
+ uri = URI.parse(request.url)
48
+ http_request = build(request, uri)
49
+
50
+ response = connection(uri).start { |http| http.request(http_request) }
51
+
52
+ Response.new(status: response.code.to_i, body: response.body)
53
+ rescue *NETWORK_ERRORS => e
54
+ raise ConnectionError, "could not reach #{uri&.host || request.url}: #{e.class}: #{e.message}"
55
+ end
56
+
57
+ private
58
+
59
+ def build(request, uri)
60
+ http_request = VERBS.fetch(request.method).new(uri.request_uri)
61
+
62
+ request.headers.each { |name, value| http_request[name] = value }
63
+ http_request.body = request.body if request.body
64
+
65
+ http_request
66
+ end
67
+
68
+ def connection(uri)
69
+ http = Net::HTTP.new(uri.host, uri.port)
70
+
71
+ http.use_ssl = uri.scheme == 'https'
72
+ http.open_timeout = @open_timeout
73
+ http.read_timeout = @read_timeout
74
+ http.write_timeout = @read_timeout
75
+
76
+ http
77
+ end
78
+ end
79
+ end