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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3c81bfd943d2a666cb4708cbf7fb68dda7e9bfa73a4563ad3ec444475031bc62
|
|
4
|
+
data.tar.gz: ef8433cd9a72fe8429521b93e2724044ec5fac06a94af1053962db3e67468379
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f11f01f55bbc204af64100bffb53e88540c3c8374c80df2d42a68b551279f9f3b80c0fc3611458fcd5643645a6efbad48b8f4a073af44c76c2b96e4811959193
|
|
7
|
+
data.tar.gz: d4fe938a5b52edfe02b9cf9e86b300b6ebe98fcceb22807dc0c7d7eb937b39f8f010a4a9de9c0f804f61359338934e523f536f9bd15ecac2484729a768d846e6
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
- Remove lint from CI
|
|
6
|
+
|
|
7
|
+
## 0.1.0
|
|
8
|
+
|
|
9
|
+
First release. Server-side REST client, standard library only.
|
|
10
|
+
|
|
11
|
+
- `Blackevin::Rest` — `auth.create_token_request` (local HMAC signing), `auth.request_token`,
|
|
12
|
+
`channels.get(name).publish / history`, `presence.get / history`, `clients.disconnect`,
|
|
13
|
+
and `queues` (list, upsert, update, delete, rules, add_rule, delete_rule).
|
|
14
|
+
- `Blackevin.configure` and `Blackevin.rest` for a process-wide client; reads `BLACKEVIN_KEY`,
|
|
15
|
+
`BLACKEVIN_REST_ENDPOINT` and `BLACKEVIN_ENDPOINT`.
|
|
16
|
+
- `Blackevin::TokenEndpoint`, a Rack application for the browser's `authUrl`, mountable in Rails,
|
|
17
|
+
Sinatra and Hanami.
|
|
18
|
+
- An optional Railtie: `config.blackevin.*` and `credentials.blackevin.key`.
|
|
19
|
+
- `Blackevin::Error` (`status_code`, `reason`, `quota?`), `Blackevin::ConnectionError`,
|
|
20
|
+
`Blackevin::ConfigurationError`.
|
|
21
|
+
- Pattern matching (`deconstruct_keys`) on errors, token requests, token details and every returned value.
|
|
22
|
+
- Ruby 3.0 and up. The suite runs on 3.0 through 3.4.
|
|
23
|
+
- Tested against the language-neutral contract in `spec/contract`.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thadeu Esteves Jr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# blackevin
|
|
2
|
+
|
|
3
|
+
The server-side Ruby SDK for [Blackevin](https://blackevin.com) realtime.
|
|
4
|
+
|
|
5
|
+
Sign token requests for your browsers, publish from a job or a webhook, read
|
|
6
|
+
history and presence, manage queues.
|
|
7
|
+
|
|
8
|
+
**No runtime dependencies.** `net/http`, `openssl` and `json` from the standard
|
|
9
|
+
library — nothing that can break under you in an upgrade. Ruby 3.0 and up, in
|
|
10
|
+
Rails, Sinatra, Hanami or a plain script.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
gem "blackevin"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Configure
|
|
17
|
+
|
|
18
|
+
With `BLACKEVIN_KEY` in the environment there is nothing to configure:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
Blackevin.rest.channels.get("room:42").publish("greeting", {text: "hi"})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or say it explicitly, once, at boot:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
Blackevin.configure do |config|
|
|
28
|
+
config.key = ENV.fetch("BLACKEVIN_KEY") # secret.keyId, from the console
|
|
29
|
+
config.open_timeout = 5 # seconds
|
|
30
|
+
config.read_timeout = 10
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`Blackevin.rest` is one shared, thread-safe client. For more than one key, build
|
|
35
|
+
your own: `Blackevin::Rest.new(key: …)`.
|
|
36
|
+
|
|
37
|
+
Against a local or self-hosted node, which serves REST and the socket from one
|
|
38
|
+
origin, set `BLACKEVIN_ENDPOINT=ws://localhost:3000` (or `config.endpoint`) and the
|
|
39
|
+
REST base is derived from it.
|
|
40
|
+
|
|
41
|
+
## Token endpoint — the part every app needs
|
|
42
|
+
|
|
43
|
+
Your API key is your account, so it never goes to a browser. The browser asks
|
|
44
|
+
**your** server for a TokenRequest: signed with the key, scoped to what this
|
|
45
|
+
user may touch, dead after its `ttl`. Signing is local — no call to Blackevin, so
|
|
46
|
+
your sign-in never depends on us being reachable.
|
|
47
|
+
|
|
48
|
+
The browser side is the JavaScript SDK with `authUrl: "/blackevin/token"`.
|
|
49
|
+
|
|
50
|
+
### Rails
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# app/controllers/blackevin_tokens_controller.rb
|
|
54
|
+
class BlackevinTokensController < ApplicationController
|
|
55
|
+
before_action :authenticate_user!
|
|
56
|
+
|
|
57
|
+
def show
|
|
58
|
+
render json: Blackevin.rest.auth.create_token_request(
|
|
59
|
+
client_id: current_user.id,
|
|
60
|
+
ttl: 1.hour.in_milliseconds,
|
|
61
|
+
capability: {
|
|
62
|
+
"team:#{current_user.team_id}" => %w[subscribe publish],
|
|
63
|
+
"presence:team:#{current_user.team_id}" => %w[presence]
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# config/routes.rb
|
|
70
|
+
resource :blackevin_token, only: :show, path: "blackevin/token"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The key can live in credentials instead of the environment — the Railtie reads
|
|
74
|
+
`credentials.blackevin.key`, and `config.blackevin.*` in an environment file:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# config/environments/development.rb
|
|
78
|
+
config.blackevin.endpoint = "ws://localhost:3000"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Sinatra
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
require "sinatra"
|
|
85
|
+
require "blackevin"
|
|
86
|
+
|
|
87
|
+
get "/blackevin/token" do
|
|
88
|
+
halt 401 unless current_user
|
|
89
|
+
|
|
90
|
+
content_type :json
|
|
91
|
+
Blackevin.rest.auth.create_token_request(client_id: current_user.id).to_json
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Anything Rack (Hanami, Roda, `config.ru`)
|
|
96
|
+
|
|
97
|
+
`Blackevin::TokenEndpoint` is a Rack application. The block receives the Rack env
|
|
98
|
+
and answers who is asking; `nil` is a 401.
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
TOKENS = Blackevin::TokenEndpoint.new do |env|
|
|
102
|
+
user = env["warden"]&.user
|
|
103
|
+
|
|
104
|
+
next unless user
|
|
105
|
+
|
|
106
|
+
{client_id: user.id, capability: {"team:#{user.team_id}" => %w[subscribe publish]}}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Hanami: mount TOKENS, at: "/blackevin/token"
|
|
110
|
+
# Rails: mount TOKENS, at: "/blackevin/token"
|
|
111
|
+
# Rack: map("/blackevin/token") { run TOKENS }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Publish
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
channel = Blackevin.rest.channels.get("orders:#{order.id}")
|
|
118
|
+
|
|
119
|
+
channel.publish("status", {state: "shipped"})
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
It goes through the same fanout a socket publish does: subscribers, account
|
|
123
|
+
queues and integrations all see it. One HTTP request per publish — from a Rails
|
|
124
|
+
request, prefer a job:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
class BlackevinPublishJob < ApplicationJob
|
|
128
|
+
retry_on Blackevin::ConnectionError, wait: :polynomially_longer
|
|
129
|
+
|
|
130
|
+
def perform(channel, event, data)
|
|
131
|
+
Blackevin.rest.channels.get(channel).publish(event, data)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## History and presence
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
channel.history(limit: 50) # => [Blackevin::Message], newest first
|
|
140
|
+
channel.presence.get # => [Blackevin::PresenceMember]
|
|
141
|
+
channel.presence.history # => [Blackevin::PresenceEvent]
|
|
142
|
+
|
|
143
|
+
message = channel.history.first
|
|
144
|
+
message.name # "status"
|
|
145
|
+
message.data # {"state" => "shipped"}
|
|
146
|
+
message.time # a Time; message.timestamp is the wire value, in ms
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Sign a user out everywhere
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
Blackevin.rest.clients.disconnect(user.id) # => how many connections were closed
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Queues
|
|
156
|
+
|
|
157
|
+
The key needs the `amqp-subscribe` capability. A queue is addressed by its `id`.
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
queues = Blackevin.rest.queues
|
|
161
|
+
|
|
162
|
+
queue = queues.upsert(name: "inbox", max_length: 10_000)
|
|
163
|
+
queues.add_rule(queue.id, source_pattern: "orders:*")
|
|
164
|
+
|
|
165
|
+
queues.update(queue.id, enabled: false) # pause
|
|
166
|
+
queues.list(all: true) # paused ones included
|
|
167
|
+
queues.delete(queue.id)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Errors
|
|
171
|
+
|
|
172
|
+
One rescue covers everything the SDK raises:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
begin
|
|
176
|
+
channel.publish("status", payload)
|
|
177
|
+
rescue Blackevin::Error => error
|
|
178
|
+
error.status_code # 403, 429, … or nil when no response arrived
|
|
179
|
+
error.reason # "queue_limit", "connection_limit", or nil
|
|
180
|
+
error.quota? # a plan ceiling — show an upgrade prompt, retry later
|
|
181
|
+
error.message # the server's own sentence; do not branch on it
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Errors, token requests and every returned value support pattern matching, so a
|
|
186
|
+
rescue can branch by shape:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
rescue Blackevin::Error => error
|
|
190
|
+
case error
|
|
191
|
+
in {reason: "queue_limit"} then redirect_to upgrade_path
|
|
192
|
+
in {status_code: 401 | 403} then raise
|
|
193
|
+
in {status_code: nil} then retry_job wait: 30.seconds
|
|
194
|
+
end
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- `Blackevin::ConnectionError` — DNS, refused, TLS, timeout. Worth retrying.
|
|
198
|
+
- `Blackevin::ConfigurationError` — a missing or malformed key, raised before any request.
|
|
199
|
+
|
|
200
|
+
## Acting as one client
|
|
201
|
+
|
|
202
|
+
A token instead of a key restricts the client to that token's capability:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
details = Blackevin.rest.auth.request_token(
|
|
206
|
+
Blackevin.rest.auth.create_token_request(client_id: "worker-1", capability: {"jobs:*" => %w[publish]})
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
Blackevin::Rest.new(token: details.token).channels.get("jobs:1").publish("done")
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Testing your app
|
|
213
|
+
|
|
214
|
+
Swap the transport and nothing leaves the process:
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
Blackevin.configure do |config|
|
|
218
|
+
config.key = "secret.test"
|
|
219
|
+
config.transport = ->(request) { Blackevin::Response.new(status: 200, body: "{}") }
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Development
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
bundle install
|
|
227
|
+
bundle exec rake # rspec + standard
|
|
228
|
+
bundle exec rake contract:sync # refresh spec/contract from ../blackevin/spec
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Releasing
|
|
232
|
+
|
|
233
|
+
Bump `lib/blackevin/version.rb`, add the entry to `CHANGELOG.md`, commit, then:
|
|
234
|
+
|
|
235
|
+
```sh
|
|
236
|
+
bundle exec rake tag # tags vX.Y.Z from version.rb and pushes it
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The tag runs `.github/workflows/release.yml`: lint, tests, publish to RubyGems by
|
|
240
|
+
Trusted Publishing (no API key), and a GitHub release with the `.gem` attached.
|
|
241
|
+
|
|
242
|
+
`spec/contract` is a copy of the language-neutral contract — an OpenAPI file and
|
|
243
|
+
JSON fixtures — that every Blackevin SDK is tested against. The suite fails when
|
|
244
|
+
the contract gains an operation this gem does not implement.
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
MIT.
|
data/blackevin.gemspec
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/blackevin/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "blackevin"
|
|
7
|
+
spec.version = Blackevin::VERSION
|
|
8
|
+
spec.authors = ["Blackevin"]
|
|
9
|
+
|
|
10
|
+
spec.summary = "Blackevin Ruby SDK"
|
|
11
|
+
spec.description = "Sign token requests, publish, read history and presence, and manage queues on Blackevin. " \
|
|
12
|
+
"Standard library only: no runtime dependencies. Ruby 3.0 and up. Works in Rails, Sinatra, Hanami or plain Ruby."
|
|
13
|
+
spec.homepage = "https://blackevin.com"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
spec.required_ruby_version = ">= 3.0"
|
|
16
|
+
|
|
17
|
+
spec.metadata = {
|
|
18
|
+
"rubygems_mfa_required" => "true",
|
|
19
|
+
"documentation_uri" => "https://docs.blackevin.com",
|
|
20
|
+
"source_code_uri" => "https://github.com/thadeu/blackevin-ruby",
|
|
21
|
+
"changelog_uri" => "https://github.com/thadeu/blackevin-ruby/blob/main/CHANGELOG.md"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
spec.files = Dir.chdir(__dir__) do
|
|
25
|
+
Dir["README.md", "CHANGELOG.md", "LICENSE", "blackevin.gemspec", "lib/**/*.rb"]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
spec.require_paths = ["lib"]
|
|
29
|
+
|
|
30
|
+
spec.add_development_dependency "rake", ">= 13.0"
|
|
31
|
+
spec.add_development_dependency "rspec", ">= 3.13", "< 4.0"
|
|
32
|
+
spec.add_development_dependency "standard", ">= 1.0"
|
|
33
|
+
spec.add_development_dependency "railties", ">= 7.0"
|
|
34
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blackevin
|
|
4
|
+
# An API key, written +secret.keyId+.
|
|
5
|
+
#
|
|
6
|
+
# Split on the LAST dot: the keyId carries none, so everything before it is
|
|
7
|
+
# the secret verbatim. Same rules as the node, pinned by the contract fixtures.
|
|
8
|
+
class ApiKey
|
|
9
|
+
attr_reader :key_name, :secret
|
|
10
|
+
|
|
11
|
+
# @param raw [String] the key as copied from the console
|
|
12
|
+
# @raise [Blackevin::ConfigurationError] when it is not +secret.keyId+
|
|
13
|
+
def self.parse(raw)
|
|
14
|
+
case raw.to_s.strip.rpartition('.')
|
|
15
|
+
in [secret, '.', key_name] unless secret.empty? || key_name.empty?
|
|
16
|
+
new(key_name, secret)
|
|
17
|
+
else
|
|
18
|
+
raise ConfigurationError, 'malformed API key: expected secret.keyId'
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(key_name, secret)
|
|
23
|
+
@key_name = key_name
|
|
24
|
+
@secret = secret
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Never print the secret: this object ends up in logs and error reports.
|
|
28
|
+
def inspect = "#<Blackevin::ApiKey key_name=#{key_name.inspect} secret=[FILTERED]>"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blackevin
|
|
4
|
+
# Process-wide defaults for {Blackevin.rest}.
|
|
5
|
+
#
|
|
6
|
+
# Blackevin.configure do |config|
|
|
7
|
+
# config.key = ENV.fetch("BLACKEVIN_KEY")
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# Nothing here is required: with +BLACKEVIN_KEY+ in the environment,
|
|
11
|
+
# {Blackevin.rest} works unconfigured.
|
|
12
|
+
class Configuration
|
|
13
|
+
ENV_KEY = 'BLACKEVIN_KEY'
|
|
14
|
+
|
|
15
|
+
# @return [String, nil] the full API key, +secret.keyId+
|
|
16
|
+
attr_writer :key
|
|
17
|
+
|
|
18
|
+
# @return [String, nil] REST base URL; see {Blackevin::Endpoints}
|
|
19
|
+
attr_accessor :rest_endpoint
|
|
20
|
+
|
|
21
|
+
# @return [String, nil] socket endpoint, read only to derive the REST base for a single-origin node
|
|
22
|
+
attr_accessor :endpoint
|
|
23
|
+
|
|
24
|
+
# @return [Numeric] seconds
|
|
25
|
+
attr_accessor :open_timeout, :read_timeout
|
|
26
|
+
|
|
27
|
+
# @return [#call, nil] replaces the Net::HTTP transport
|
|
28
|
+
attr_accessor :transport
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@open_timeout = 5
|
|
32
|
+
@read_timeout = 10
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def key = @key || ENV[ENV_KEY]
|
|
36
|
+
|
|
37
|
+
# @return [Hash] the options {Blackevin::Rest.new} takes
|
|
38
|
+
def to_rest_options
|
|
39
|
+
{
|
|
40
|
+
key: key,
|
|
41
|
+
rest_endpoint: rest_endpoint,
|
|
42
|
+
endpoint: endpoint,
|
|
43
|
+
open_timeout: open_timeout,
|
|
44
|
+
read_timeout: read_timeout,
|
|
45
|
+
transport: transport
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blackevin
|
|
4
|
+
# Which hosts the SDK talks to: explicit option, then environment, then the
|
|
5
|
+
# production default.
|
|
6
|
+
#
|
|
7
|
+
# The REST base is derived from the socket endpoint only when that endpoint
|
|
8
|
+
# was given, because a dev or self-hosted node serves both from one origin.
|
|
9
|
+
# Production serves them from two names, so the default never derives.
|
|
10
|
+
module Endpoints
|
|
11
|
+
DEFAULT_WS = 'wss://ws.blackevin.com'
|
|
12
|
+
DEFAULT_REST = 'https://api.blackevin.com'
|
|
13
|
+
|
|
14
|
+
ENV_WS = 'BLACKEVIN_ENDPOINT'
|
|
15
|
+
ENV_REST = 'BLACKEVIN_REST_ENDPOINT'
|
|
16
|
+
|
|
17
|
+
Resolved = Struct.new(:endpoint, :rest_endpoint, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# @param endpoint [String, nil] socket endpoint, e.g. "ws://localhost:3000"
|
|
22
|
+
# @param rest_endpoint [String, nil] REST base URL
|
|
23
|
+
# @param env [#[]] injected for tests; defaults to ENV
|
|
24
|
+
# @return [Resolved]
|
|
25
|
+
def resolve(endpoint: nil, rest_endpoint: nil, env: ENV)
|
|
26
|
+
explicit_ws = present(endpoint) || present(env[ENV_WS])
|
|
27
|
+
|
|
28
|
+
rest = present(rest_endpoint) ||
|
|
29
|
+
present(env[ENV_REST]) ||
|
|
30
|
+
(explicit_ws ? derive_rest(explicit_ws) : DEFAULT_REST)
|
|
31
|
+
|
|
32
|
+
Resolved.new(endpoint: explicit_ws || DEFAULT_WS, rest_endpoint: rest)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def derive_rest(ws_endpoint) = ws_endpoint.sub(/\Aws/i, 'http').delete_suffix('/')
|
|
36
|
+
|
|
37
|
+
def present(value) = value.to_s.strip.then { _1.empty? ? nil : _1 }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Blackevin
|
|
6
|
+
# What the server said when it refused.
|
|
7
|
+
#
|
|
8
|
+
# Branch on {#status_code} and {#reason}, never on the message: the message
|
|
9
|
+
# is the server's sentence, written for a person.
|
|
10
|
+
class Error < StandardError
|
|
11
|
+
QUOTA_STATUS = 429
|
|
12
|
+
|
|
13
|
+
# @return [Integer, nil] the HTTP status, or nil when no response arrived
|
|
14
|
+
attr_reader :status_code
|
|
15
|
+
|
|
16
|
+
# @return [String, nil] a stable slug such as "queue_limit", when the server sent one
|
|
17
|
+
attr_reader :reason
|
|
18
|
+
|
|
19
|
+
def initialize(message, status_code = nil, reason = nil)
|
|
20
|
+
super(message)
|
|
21
|
+
|
|
22
|
+
@status_code = status_code
|
|
23
|
+
@reason = reason
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# True when this is a plan ceiling rather than a bug or a bad credential.
|
|
27
|
+
def quota? = status_code == QUOTA_STATUS
|
|
28
|
+
|
|
29
|
+
# Lets a rescue branch by shape instead of by a chain of conditionals:
|
|
30
|
+
#
|
|
31
|
+
# case error
|
|
32
|
+
# in {reason: "queue_limit"} then upgrade_prompt
|
|
33
|
+
# in {status_code: 401 | 403} then rotate_key
|
|
34
|
+
# in {status_code: nil} then retry_later
|
|
35
|
+
# end
|
|
36
|
+
def deconstruct_keys(_keys) = { status_code: status_code, reason: reason, message: message }
|
|
37
|
+
|
|
38
|
+
# Builds the error for a non-2xx response, keeping the server's sentence.
|
|
39
|
+
#
|
|
40
|
+
# A body that is not JSON degrades to the status alone: a proxy answering
|
|
41
|
+
# HTML on a 502 must not replace a useful error with a parse failure.
|
|
42
|
+
#
|
|
43
|
+
# @param what [String] the operation label, e.g. "publish"
|
|
44
|
+
# @param status [Integer]
|
|
45
|
+
# @param body [String, nil] the raw response body
|
|
46
|
+
# @return [Blackevin::Error]
|
|
47
|
+
def self.from_response(what, status, body)
|
|
48
|
+
parsed = parse(body)
|
|
49
|
+
|
|
50
|
+
detail = case parsed
|
|
51
|
+
in { error: String => sentence } unless sentence.empty? then sentence
|
|
52
|
+
else status
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
reason = case parsed
|
|
56
|
+
in { reason: String => slug } then slug
|
|
57
|
+
else nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
new("#{what} failed: #{detail}", status, reason)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.parse(body)
|
|
64
|
+
case JSON.parse(body.to_s, symbolize_names: true)
|
|
65
|
+
in Hash => parsed then parsed
|
|
66
|
+
else {}
|
|
67
|
+
end
|
|
68
|
+
rescue JSON::ParserError
|
|
69
|
+
{}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private_class_method :parse
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The request never produced a response: DNS, refused connection, TLS, timeout.
|
|
76
|
+
class ConnectionError < Error
|
|
77
|
+
def initialize(message)
|
|
78
|
+
super(message, nil, nil)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The SDK was given something it cannot work with, before any network call.
|
|
83
|
+
class ConfigurationError < Error
|
|
84
|
+
def initialize(message)
|
|
85
|
+
super(message, nil, nil)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Blackevin
|
|
4
|
+
# Loaded only when Rails is. Lets the key live where a Rails app keeps secrets:
|
|
5
|
+
#
|
|
6
|
+
# # config/credentials.yml.enc
|
|
7
|
+
# blackevin:
|
|
8
|
+
# key: ck_live_….kAbC
|
|
9
|
+
#
|
|
10
|
+
# and lets an environment file set the rest:
|
|
11
|
+
#
|
|
12
|
+
# config.blackevin.rest_endpoint = "http://localhost:3000"
|
|
13
|
+
#
|
|
14
|
+
# An explicit +Blackevin.configure+ or +BLACKEVIN_KEY+ still wins over credentials.
|
|
15
|
+
class Railtie < Rails::Railtie
|
|
16
|
+
SETTINGS = %i[key rest_endpoint endpoint open_timeout read_timeout transport].freeze
|
|
17
|
+
|
|
18
|
+
config.blackevin = ActiveSupport::OrderedOptions.new
|
|
19
|
+
|
|
20
|
+
initializer 'blackevin.configure' do |app|
|
|
21
|
+
options = app.config.blackevin
|
|
22
|
+
credentials_key = app.credentials.dig(:blackevin, :key) if app.respond_to?(:credentials)
|
|
23
|
+
|
|
24
|
+
Blackevin.configure do |config|
|
|
25
|
+
SETTINGS.each do |setting|
|
|
26
|
+
value = options[setting]
|
|
27
|
+
|
|
28
|
+
config.public_send(:"#{setting}=", value) unless value.nil?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
config.key = credentials_key if config.key.nil? && credentials_key
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Blackevin
|
|
7
|
+
class Rest
|
|
8
|
+
# Token requests: signing one locally, and exchanging one for a token.
|
|
9
|
+
class Auth
|
|
10
|
+
DEFAULT_TTL_MS = 3_600_000
|
|
11
|
+
DEFAULT_CAPABILITY = {"*" => %w[subscribe publish presence history]}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(rest, clock: nil, nonce: nil)
|
|
14
|
+
@rest = rest
|
|
15
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) }
|
|
16
|
+
@nonce = nonce || -> { SecureRandom.hex(16) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Signs a TokenRequest for a browser to exchange.
|
|
20
|
+
#
|
|
21
|
+
# Local: no network call. Your server holds the secret and hands out
|
|
22
|
+
# something scoped and short-lived, so Blackevin is never on the critical
|
|
23
|
+
# path of your own sign-in.
|
|
24
|
+
#
|
|
25
|
+
# @param client_id [String, Integer, nil] who the token will act as
|
|
26
|
+
# @param ttl [Integer, nil] milliseconds; default one hour, maximum 48 hours
|
|
27
|
+
# @param capability [Hash, String, nil] channel pattern to allowed operations.
|
|
28
|
+
# A Hash is serialised compactly in insertion order; a String is signed byte for byte.
|
|
29
|
+
# @param timestamp [Integer, nil] milliseconds since the epoch; default now
|
|
30
|
+
# @param nonce [String, nil] default random
|
|
31
|
+
# @return [Blackevin::TokenRequest] signed
|
|
32
|
+
# @raise [Blackevin::ConfigurationError] without a usable API key
|
|
33
|
+
def create_token_request(client_id: nil, ttl: nil, capability: nil, timestamp: nil, nonce: nil)
|
|
34
|
+
api_key = @rest.api_key
|
|
35
|
+
|
|
36
|
+
request = TokenRequest.new(
|
|
37
|
+
key_name: api_key.key_name,
|
|
38
|
+
ttl: ttl || DEFAULT_TTL_MS,
|
|
39
|
+
capability: serialise(capability),
|
|
40
|
+
client_id: client_id&.to_s,
|
|
41
|
+
timestamp: timestamp || @clock.call,
|
|
42
|
+
nonce: nonce || @nonce.call
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
request.sign(api_key.secret)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Exchanges a TokenRequest for a token.
|
|
49
|
+
#
|
|
50
|
+
# Usually the browser's job. A server wanting a token for its own use —
|
|
51
|
+
# to act as one client under a narrow capability — does it here.
|
|
52
|
+
#
|
|
53
|
+
# @param token_request [Blackevin::TokenRequest, Hash]
|
|
54
|
+
# @return [Blackevin::TokenDetails]
|
|
55
|
+
# @raise [Blackevin::Error]
|
|
56
|
+
def request_token(token_request)
|
|
57
|
+
wire =
|
|
58
|
+
case token_request
|
|
59
|
+
in TokenRequest => signed then signed.to_h
|
|
60
|
+
in Hash => hash then TokenRequest.from_h(hash).to_h
|
|
61
|
+
else raise ConfigurationError, "request_token takes a TokenRequest or a Hash, got #{token_request.class}"
|
|
62
|
+
end
|
|
63
|
+
path = "/keys/#{Rest.escape(wire.fetch("keyName"))}/requestToken"
|
|
64
|
+
|
|
65
|
+
TokenDetails.from_h(@rest.request("requestToken", "POST", path, body: wire, authorize: false))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def serialise(capability)
|
|
71
|
+
case capability
|
|
72
|
+
in String => signed_as_is then signed_as_is
|
|
73
|
+
in Hash => map then JSON.generate(map)
|
|
74
|
+
in nil then JSON.generate(DEFAULT_CAPABILITY)
|
|
75
|
+
else raise ConfigurationError, "capability must be a Hash or a JSON String, got #{capability.class}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|