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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +21 -0
- data/README.md +248 -0
- data/blackevin.gemspec +34 -0
- data/lib/blackevin/api_key.rb +30 -0
- data/lib/blackevin/configuration.rb +49 -0
- data/lib/blackevin/endpoints.rb +39 -0
- data/lib/blackevin/errors.rb +88 -0
- data/lib/blackevin/railtie.rb +35 -0
- data/lib/blackevin/rest/auth.rb +80 -0
- data/lib/blackevin/rest/channels.rb +103 -0
- data/lib/blackevin/rest/clients.rb +28 -0
- data/lib/blackevin/rest/queues.rb +113 -0
- data/lib/blackevin/rest.rb +119 -0
- data/lib/blackevin/token_details.rb +65 -0
- data/lib/blackevin/token_endpoint.rb +65 -0
- data/lib/blackevin/token_request.rb +96 -0
- data/lib/blackevin/transport.rb +79 -0
- data/lib/blackevin/types.rb +78 -0
- data/lib/blackevin/version.rb +5 -0
- data/lib/blackevin.rb +63 -0
- metadata +127 -0
|
@@ -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
|