relaygrid 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 +87 -0
- data/QUICKSTART.md +112 -0
- data/README.md +249 -0
- data/examples/rails_initializer.rb +37 -0
- data/examples/usage_examples.rb +115 -0
- data/lib/relaygrid/client.rb +171 -0
- data/lib/relaygrid/configuration.rb +96 -0
- data/lib/relaygrid/delivery.rb +129 -0
- data/lib/relaygrid/errors.rb +105 -0
- data/lib/relaygrid/resources/channel_tokens.rb +35 -0
- data/lib/relaygrid/resources/deliveries.rb +101 -0
- data/lib/relaygrid/resources/message_templates.rb +33 -0
- data/lib/relaygrid/resources/messages.rb +63 -0
- data/lib/relaygrid/resources/notifications.rb +108 -0
- data/lib/relaygrid/resources/users.rb +117 -0
- data/lib/relaygrid/send_result.rb +78 -0
- data/lib/relaygrid/version.rb +5 -0
- data/lib/relaygrid/websocket_client.rb +214 -0
- data/lib/relaygrid.rb +74 -0
- data/sig/relaygrid.rbs +4 -0
- metadata +178 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module RelayGrid
|
|
8
|
+
# The entry point for every API call. Holds one Faraday connection and hands
|
|
9
|
+
# out the resource objects.
|
|
10
|
+
#
|
|
11
|
+
# client = RelayGrid::Client.new # global configuration
|
|
12
|
+
# client = RelayGrid::Client.new(api_key: "sk_other") # per-client override
|
|
13
|
+
#
|
|
14
|
+
# Instances are frozen once built, so a single client is safe to share across
|
|
15
|
+
# threads.
|
|
16
|
+
class Client
|
|
17
|
+
# Requests that may be replayed without changing server state. `notify` is
|
|
18
|
+
# deliberately absent: a blind retry sends the recipient a second real
|
|
19
|
+
# notification. See README ("Retries and idempotency").
|
|
20
|
+
IDEMPOTENT_METHODS = %i[get head options].freeze
|
|
21
|
+
|
|
22
|
+
RETRIABLE_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
23
|
+
|
|
24
|
+
RETRIABLE_EXCEPTIONS = [
|
|
25
|
+
Faraday::ConnectionFailed,
|
|
26
|
+
Faraday::TimeoutError,
|
|
27
|
+
# Required for RETRIABLE_STATUSES to have any effect: faraday-retry signals
|
|
28
|
+
# a retriable status by raising this, and only retries exceptions listed
|
|
29
|
+
# here. Leaving it out silently disables status-based retries.
|
|
30
|
+
Faraday::RetriableResponse,
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
attr_reader :configuration
|
|
34
|
+
|
|
35
|
+
def initialize(configuration = nil, **overrides)
|
|
36
|
+
base = configuration || RelayGrid.configuration
|
|
37
|
+
@configuration = base.merge(overrides).validate!
|
|
38
|
+
|
|
39
|
+
# Built up front so the frozen instance never has to memoize lazily.
|
|
40
|
+
@connection = build_connection
|
|
41
|
+
@resources = {}
|
|
42
|
+
|
|
43
|
+
freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def notifications
|
|
47
|
+
resource(:notifications) { Resources::Notifications.new(self) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def deliveries
|
|
51
|
+
resource(:deliveries) { Resources::Deliveries.new(self) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def messages
|
|
55
|
+
resource(:messages) { Resources::Messages.new(self) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def users
|
|
59
|
+
resource(:users) { Resources::Users.new(self) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def channel_tokens
|
|
63
|
+
resource(:channel_tokens) { Resources::ChannelTokens.new(self) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def message_templates
|
|
67
|
+
resource(:message_templates) { Resources::MessageTemplates.new(self) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Send a notification -- the one call the whole gem exists for.
|
|
71
|
+
# See Resources::Notifications#notify.
|
|
72
|
+
def notify(**kwargs)
|
|
73
|
+
notifications.notify(**kwargs)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def get(path, params: {})
|
|
77
|
+
request(:get, path, params: params)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def post(path, body: {})
|
|
81
|
+
request(:post, path, body: body)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def patch(path, body: {})
|
|
85
|
+
request(:patch, path, body: body)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def delete(path)
|
|
89
|
+
request(:delete, path)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
attr_reader :connection
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# Resource objects are stateless wrappers, so building one twice under a
|
|
97
|
+
# race is harmless -- which is what lets the client stay frozen.
|
|
98
|
+
def resource(name)
|
|
99
|
+
@resources[name] ||= yield
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_connection
|
|
103
|
+
Faraday.new(url: configuration.base_url) do |conn|
|
|
104
|
+
conn.request :json
|
|
105
|
+
# Only idempotent verbs are replayed, so this can never duplicate a send.
|
|
106
|
+
conn.request :retry,
|
|
107
|
+
max: configuration.max_retries,
|
|
108
|
+
interval: 0.25,
|
|
109
|
+
backoff_factor: 2,
|
|
110
|
+
interval_randomness: 0.5,
|
|
111
|
+
methods: IDEMPOTENT_METHODS,
|
|
112
|
+
retry_statuses: RETRIABLE_STATUSES,
|
|
113
|
+
exceptions: RETRIABLE_EXCEPTIONS
|
|
114
|
+
conn.response :json, content_type: /\bjson$/
|
|
115
|
+
conn.response :logger, configuration.logger, headers: false, bodies: false if configuration.logger
|
|
116
|
+
|
|
117
|
+
conn.headers["Authorization"] = "Bearer #{configuration.api_key}"
|
|
118
|
+
conn.headers["User-Agent"] = "relaygrid-ruby/#{RelayGrid::VERSION}"
|
|
119
|
+
|
|
120
|
+
conn.options.timeout = configuration.timeout
|
|
121
|
+
conn.options.open_timeout = configuration.open_timeout
|
|
122
|
+
# Certificates are always verified; ca_file only adds a trust anchor.
|
|
123
|
+
conn.ssl.ca_file = configuration.ca_file if configuration.ca_file
|
|
124
|
+
|
|
125
|
+
conn.adapter Faraday.default_adapter
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Single funnel for every call: the one place that turns transport failures
|
|
130
|
+
# and non-2xx responses into the RelayGrid::Error hierarchy, so no Faraday
|
|
131
|
+
# exception ever escapes the gem.
|
|
132
|
+
def request(method, path, params: nil, body: nil)
|
|
133
|
+
response =
|
|
134
|
+
case method
|
|
135
|
+
when :get then connection.get(path, params || {})
|
|
136
|
+
when :post then connection.post(path, body || {})
|
|
137
|
+
when :patch then connection.patch(path, body || {})
|
|
138
|
+
when :delete then connection.delete(path)
|
|
139
|
+
else raise ArgumentError, "unsupported HTTP method: #{method}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
return response.body if response.success?
|
|
143
|
+
|
|
144
|
+
raise APIError.from_response(response.status, response.body)
|
|
145
|
+
rescue Faraday::RetriableResponse => e
|
|
146
|
+
# faraday-retry raises this instead of returning the response when the
|
|
147
|
+
# status is retriable but no attempts are left -- and immediately, without
|
|
148
|
+
# retrying, for a non-idempotent method like the POST behind `notify`.
|
|
149
|
+
# Convert it like any other unsuccessful response.
|
|
150
|
+
raise APIError.from_response(*retriable_response_parts(e))
|
|
151
|
+
rescue Faraday::TimeoutError => e
|
|
152
|
+
raise TimeoutError, "Request to #{path} timed out: #{e.message}"
|
|
153
|
+
rescue Faraday::SSLError => e
|
|
154
|
+
raise ConnectionError, "TLS handshake with #{configuration.base_url} failed: #{e.message}"
|
|
155
|
+
rescue Faraday::ConnectionFailed => e
|
|
156
|
+
raise ConnectionError, "Could not connect to #{configuration.base_url}: #{e.message}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Faraday::RetriableResponse carries either a Faraday::Response or a raw env
|
|
160
|
+
# hash depending on where it was raised.
|
|
161
|
+
def retriable_response_parts(error)
|
|
162
|
+
response = error.response
|
|
163
|
+
|
|
164
|
+
if response.respond_to?(:status)
|
|
165
|
+
[response.status, response.body]
|
|
166
|
+
else
|
|
167
|
+
[response[:status], response[:body]]
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module RelayGrid
|
|
6
|
+
# Settings a Client reads. `RelayGrid.configure` sets the process-wide
|
|
7
|
+
# default; `Client.new(api_key: ...)` takes per-client overrides so a
|
|
8
|
+
# multi-account host app can talk to several accounts at once.
|
|
9
|
+
class Configuration
|
|
10
|
+
DEFAULT_BASE_URL = "https://relaygrid.dev"
|
|
11
|
+
|
|
12
|
+
# @return [String] account API key ("sk_...")
|
|
13
|
+
attr_accessor :api_key
|
|
14
|
+
# @return [String] API origin, e.g. "https://relaygrid.dev"
|
|
15
|
+
attr_accessor :base_url
|
|
16
|
+
# @return [Integer] seconds to wait for a response
|
|
17
|
+
attr_accessor :timeout
|
|
18
|
+
# @return [Integer] seconds to wait for the connection to open
|
|
19
|
+
attr_accessor :open_timeout
|
|
20
|
+
# @return [Logger, nil] when set, requests are logged (headers redacted)
|
|
21
|
+
attr_accessor :logger
|
|
22
|
+
# @return [String, nil] PEM bundle for a private CA. TLS certificates are
|
|
23
|
+
# always verified; this only adds trust anchors (self-signed local dev,
|
|
24
|
+
# internal deployments). There is deliberately no switch to turn
|
|
25
|
+
# verification off -- this client carries an API key and relays push
|
|
26
|
+
# channel tokens, so an unverified connection is never the right default.
|
|
27
|
+
attr_accessor :ca_file
|
|
28
|
+
# @return [String, nil] overrides the Action Cable URL derived from base_url
|
|
29
|
+
attr_accessor :ws_url
|
|
30
|
+
# @return [Integer] retry attempts for idempotent (GET) requests
|
|
31
|
+
attr_accessor :max_retries
|
|
32
|
+
|
|
33
|
+
def initialize(api_key: nil, base_url: nil, timeout: 30, open_timeout: 10, logger: nil,
|
|
34
|
+
ca_file: nil, ws_url: nil, max_retries: 3)
|
|
35
|
+
@api_key = api_key
|
|
36
|
+
@base_url = base_url || DEFAULT_BASE_URL
|
|
37
|
+
@timeout = timeout
|
|
38
|
+
@open_timeout = open_timeout
|
|
39
|
+
@logger = logger
|
|
40
|
+
@ca_file = ca_file
|
|
41
|
+
@ws_url = ws_url
|
|
42
|
+
@max_retries = max_retries
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A copy with the given attributes replaced. Client.new layers per-client
|
|
46
|
+
# overrides over the global default this way, without mutating it.
|
|
47
|
+
def merge(overrides = {})
|
|
48
|
+
dup.tap do |copy|
|
|
49
|
+
overrides.each do |attribute, value|
|
|
50
|
+
copy.public_send(:"#{attribute}=", value) unless value.nil?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Fail at construction rather than on the first request, so a misconfigured
|
|
56
|
+
# deploy surfaces at boot.
|
|
57
|
+
def validate!
|
|
58
|
+
raise ConfigurationError, "api_key is required" if blank?(api_key)
|
|
59
|
+
raise ConfigurationError, "base_url is required" if blank?(base_url)
|
|
60
|
+
|
|
61
|
+
uri = begin
|
|
62
|
+
URI.parse(base_url.to_s)
|
|
63
|
+
rescue URI::InvalidURIError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
68
|
+
raise ConfigurationError, "base_url must be an http(s) URL, got #{base_url.inspect}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if ca_file && !File.readable?(ca_file.to_s)
|
|
72
|
+
raise ConfigurationError, "ca_file is not readable: #{ca_file.inspect}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Action Cable endpoint, derived from base_url unless one was set explicitly
|
|
79
|
+
# ("https://relaygrid.dev" -> "wss://relaygrid.dev/cable").
|
|
80
|
+
def cable_url
|
|
81
|
+
return ws_url if ws_url
|
|
82
|
+
|
|
83
|
+
uri = URI.parse(base_url.to_s)
|
|
84
|
+
uri.scheme = uri.scheme == "https" ? "wss" : "ws"
|
|
85
|
+
uri.path = "/cable"
|
|
86
|
+
uri.query = nil
|
|
87
|
+
uri.to_s
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def blank?(value)
|
|
93
|
+
value.nil? || value.to_s.strip.empty?
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module RelayGrid
|
|
6
|
+
# One attempt to deliver a message over one channel (email, push, ...).
|
|
7
|
+
#
|
|
8
|
+
# Immutable: `#refresh` returns a new Delivery rather than mutating this one,
|
|
9
|
+
# so a Delivery handed to another thread never changes underneath it.
|
|
10
|
+
class Delivery
|
|
11
|
+
QUEUED = "queued"
|
|
12
|
+
SENT = "sent"
|
|
13
|
+
DELIVERED = "delivered"
|
|
14
|
+
FAILED = "failed"
|
|
15
|
+
BOUNCED = "bounced"
|
|
16
|
+
OPENED = "opened"
|
|
17
|
+
|
|
18
|
+
# States the server will not move away from on its own.
|
|
19
|
+
SUCCESS_STATUSES = [DELIVERED, OPENED].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :id, :message_id, :status, :channel_id, :channel_name, :channel_type,
|
|
22
|
+
:error_message, :friendly_error_message,
|
|
23
|
+
:sent_at, :delivered_at, :failed_at, :opened_at, :created_at, :updated_at,
|
|
24
|
+
:raw
|
|
25
|
+
|
|
26
|
+
def initialize(attributes, client: nil)
|
|
27
|
+
@raw = attributes
|
|
28
|
+
@client = client
|
|
29
|
+
|
|
30
|
+
@id = attributes["id"]
|
|
31
|
+
@message_id = attributes["message_id"]
|
|
32
|
+
@status = attributes["status"]
|
|
33
|
+
@error_message = attributes["error_message"]
|
|
34
|
+
@friendly_error_message = attributes["friendly_error_message"]
|
|
35
|
+
|
|
36
|
+
channel = attributes["notification_channel"] || {}
|
|
37
|
+
@channel_id = channel["id"]
|
|
38
|
+
@channel_name = channel["name"]
|
|
39
|
+
@channel_type = channel["channel_type"]
|
|
40
|
+
|
|
41
|
+
@sent_at = parse_time(attributes["sent_at"])
|
|
42
|
+
@delivered_at = parse_time(attributes["delivered_at"])
|
|
43
|
+
@failed_at = parse_time(attributes["failed_at"])
|
|
44
|
+
@opened_at = parse_time(attributes["opened_at"])
|
|
45
|
+
@created_at = parse_time(attributes["created_at"])
|
|
46
|
+
@updated_at = parse_time(attributes["updated_at"])
|
|
47
|
+
|
|
48
|
+
freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def queued?
|
|
52
|
+
status == QUEUED
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def sent?
|
|
56
|
+
status == SENT
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def delivered?
|
|
60
|
+
status == DELIVERED
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def failed?
|
|
64
|
+
status == FAILED
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def bounced?
|
|
68
|
+
status == BOUNCED
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def opened?
|
|
72
|
+
status == OPENED
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Reached a state the server considers final and successful.
|
|
76
|
+
#
|
|
77
|
+
# `failed` is deliberately *not* terminal: delivery jobs retry, so a failed
|
|
78
|
+
# row can still flip to `sent`. This mirrors the dashboard's polling
|
|
79
|
+
# semantics (see docs/DELIVERY_STATUS_PLAN.md in the API repo).
|
|
80
|
+
def success?
|
|
81
|
+
SUCCESS_STATUSES.include?(status)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def push?
|
|
85
|
+
channel_type == "push"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def email?
|
|
89
|
+
channel_type == "email"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [RelayGrid::Delivery] this delivery's current server-side state
|
|
93
|
+
def refresh
|
|
94
|
+
client.deliveries.get(id)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def to_h
|
|
98
|
+
raw
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def ==(other)
|
|
102
|
+
other.is_a?(Delivery) && other.id == id && other.status == status
|
|
103
|
+
end
|
|
104
|
+
alias eql? ==
|
|
105
|
+
|
|
106
|
+
def hash
|
|
107
|
+
[self.class, id, status].hash
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def inspect
|
|
111
|
+
"#<RelayGrid::Delivery id: #{id.inspect}, channel_type: #{channel_type.inspect}, " \
|
|
112
|
+
"status: #{status.inspect}>"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def client
|
|
118
|
+
@client || raise(Error, "This Delivery was built without a client and cannot be refreshed")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def parse_time(value)
|
|
122
|
+
return nil if value.nil? || value.to_s.empty?
|
|
123
|
+
|
|
124
|
+
Time.parse(value.to_s)
|
|
125
|
+
rescue ArgumentError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RelayGrid
|
|
4
|
+
# Every error the gem raises descends from this, so `rescue RelayGrid::Error`
|
|
5
|
+
# is always enough. Faraday's own exceptions are never allowed to escape.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Bad or missing client configuration; raised at construction, not on request.
|
|
9
|
+
class ConfigurationError < Error; end
|
|
10
|
+
|
|
11
|
+
# Transport-level failure -- the request never produced an HTTP response.
|
|
12
|
+
class ConnectionError < Error; end
|
|
13
|
+
|
|
14
|
+
# A ConnectionError specifically from a timeout. Retryable.
|
|
15
|
+
class TimeoutError < ConnectionError; end
|
|
16
|
+
|
|
17
|
+
# The server answered, but not with success.
|
|
18
|
+
class APIError < Error
|
|
19
|
+
# @return [Integer, nil] HTTP status
|
|
20
|
+
attr_reader :status
|
|
21
|
+
# @return [Hash, String, nil] parsed response body
|
|
22
|
+
attr_reader :body
|
|
23
|
+
|
|
24
|
+
def initialize(message = nil, status: nil, body: nil)
|
|
25
|
+
@status = status
|
|
26
|
+
@body = body
|
|
27
|
+
super(message || default_message)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The server's own `error` string when it sent one.
|
|
31
|
+
def error_message
|
|
32
|
+
body["error"] if body.is_a?(Hash)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Maps an HTTP response onto the right subclass. 404s are discriminated
|
|
36
|
+
# further by the server's message so callers can tell "no such template"
|
|
37
|
+
# from "no such user" without string-matching themselves.
|
|
38
|
+
def self.from_response(status, body)
|
|
39
|
+
klass =
|
|
40
|
+
case status
|
|
41
|
+
when 401, 403 then AuthenticationError
|
|
42
|
+
when 402 then LimitExceededError
|
|
43
|
+
when 404 then not_found_class(body)
|
|
44
|
+
when 422 then ValidationError
|
|
45
|
+
when 400..499 then APIError
|
|
46
|
+
when 500..599 then ServerError
|
|
47
|
+
else APIError
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
klass.new(status: status, body: body)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.not_found_class(body)
|
|
54
|
+
error = body.is_a?(Hash) ? body["error"].to_s : body.to_s
|
|
55
|
+
|
|
56
|
+
case error
|
|
57
|
+
when /message template/i then TemplateNotFoundError
|
|
58
|
+
when /notification user/i then UserNotFoundError
|
|
59
|
+
else NotFoundError
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
private_class_method :not_found_class
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def default_message
|
|
67
|
+
detail = error_message || body
|
|
68
|
+
"RelayGrid API error (HTTP #{status})#{": #{detail}" unless detail.nil?}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# 401/403 -- the API key is missing, invalid, inactive, expired, or its
|
|
73
|
+
# account is suspended.
|
|
74
|
+
class AuthenticationError < APIError; end
|
|
75
|
+
|
|
76
|
+
# 402 -- the account's monthly notification limit is exhausted.
|
|
77
|
+
class LimitExceededError < APIError; end
|
|
78
|
+
|
|
79
|
+
# 404 -- the referenced record does not exist within this account.
|
|
80
|
+
class NotFoundError < APIError; end
|
|
81
|
+
|
|
82
|
+
# 404 naming a message template.
|
|
83
|
+
class TemplateNotFoundError < NotFoundError; end
|
|
84
|
+
|
|
85
|
+
# 404 naming a notification user.
|
|
86
|
+
class UserNotFoundError < NotFoundError; end
|
|
87
|
+
|
|
88
|
+
# 422 -- the payload reached the server but was rejected.
|
|
89
|
+
class ValidationError < APIError; end
|
|
90
|
+
|
|
91
|
+
# 5xx -- a fault on the RelayGrid side. Safe to retry for reads.
|
|
92
|
+
class ServerError < APIError; end
|
|
93
|
+
|
|
94
|
+
# `wait_for_deliveries` gave up before every delivery reached a terminal
|
|
95
|
+
# state. Carries the deliveries as last seen so the caller can inspect them.
|
|
96
|
+
class TimeoutWaitingForDeliveries < Error
|
|
97
|
+
# @return [Array<RelayGrid::Delivery>]
|
|
98
|
+
attr_reader :deliveries
|
|
99
|
+
|
|
100
|
+
def initialize(message = nil, deliveries: [])
|
|
101
|
+
@deliveries = deliveries
|
|
102
|
+
super(message || "Timed out waiting for deliveries to settle: #{deliveries.map(&:id).join(', ')}")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RelayGrid
|
|
4
|
+
module Resources
|
|
5
|
+
# Mints the short-lived token an end user's device presents when subscribing
|
|
6
|
+
# to the realtime channel.
|
|
7
|
+
#
|
|
8
|
+
# A send already returns one (`SendResult#push_token`); this is how to get a
|
|
9
|
+
# fresh one once the hour is up, or without sending anything.
|
|
10
|
+
class ChannelTokens
|
|
11
|
+
def initialize(client)
|
|
12
|
+
@client = client
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param user_id [String] your identifier for the user (external_user_id)
|
|
16
|
+
# @return [Hash] `{ token:, expires_in:, expires_at: }`
|
|
17
|
+
# @raise [RelayGrid::UserNotFoundError] no such recipient in this account
|
|
18
|
+
def create(user_id:)
|
|
19
|
+
body = @client.post("/api/v1/channel_tokens", body: { external_user_id: user_id.to_s })
|
|
20
|
+
expires_in = body["expires_in"]
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
token: body["token"],
|
|
24
|
+
expires_in: expires_in,
|
|
25
|
+
expires_at: expires_in ? Time.now + expires_in.to_i : nil,
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [String] just the token
|
|
30
|
+
def token_for(user_id)
|
|
31
|
+
create(user_id: user_id)[:token]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RelayGrid
|
|
4
|
+
module Resources
|
|
5
|
+
# Delivery status lookup for the ids a send returned.
|
|
6
|
+
class Deliveries
|
|
7
|
+
# Matches the server's cap (Api::V1::DeliveriesController::MAX_BATCH_SIZE);
|
|
8
|
+
# longer id lists are split across several requests.
|
|
9
|
+
MAX_BATCH_SIZE = 100
|
|
10
|
+
|
|
11
|
+
def initialize(client)
|
|
12
|
+
@client = client
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param id [Integer, String]
|
|
16
|
+
# @return [RelayGrid::Delivery]
|
|
17
|
+
# @raise [RelayGrid::NotFoundError] no such delivery in this account
|
|
18
|
+
def get(id)
|
|
19
|
+
body = @client.get("/api/v1/deliveries/#{id}")
|
|
20
|
+
Delivery.new(body["delivery"], client: @client)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Batch lookup. Ids that don't resolve are simply absent from the result,
|
|
24
|
+
# so one stale id never fails a whole poll.
|
|
25
|
+
#
|
|
26
|
+
# @param ids [Array<Integer>]
|
|
27
|
+
# @return [Array<RelayGrid::Delivery>]
|
|
28
|
+
def get_all(ids)
|
|
29
|
+
ids = Array(ids).compact
|
|
30
|
+
return [] if ids.empty?
|
|
31
|
+
|
|
32
|
+
ids.each_slice(MAX_BATCH_SIZE).flat_map do |batch|
|
|
33
|
+
body = @client.get("/api/v1/deliveries", params: { ids: batch.join(",") })
|
|
34
|
+
Array(body["deliveries"]).map { |delivery| Delivery.new(delivery, client: @client) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Poll until every delivery reaches a success state or `timeout` elapses.
|
|
39
|
+
#
|
|
40
|
+
# BLOCKING. This sleeps the calling thread between polls. That is fine in a
|
|
41
|
+
# background job, a rake task, a script, or a test -- but do not call it
|
|
42
|
+
# from inside a web request: it pins a request thread for up to `timeout`
|
|
43
|
+
# seconds, and enough concurrent sends will exhaust the pool. In a request,
|
|
44
|
+
# return the delivery ids and let the browser poll `#get_all`, or subscribe
|
|
45
|
+
# to the realtime channel with `RelayGrid.websocket`.
|
|
46
|
+
#
|
|
47
|
+
# `failed` is not terminal here: delivery jobs retry, so a failed row can
|
|
48
|
+
# still flip to `sent` and then `delivered` inside the window. Waiting out
|
|
49
|
+
# the timeout on a genuinely failed delivery is the deliberate trade -- it
|
|
50
|
+
# mirrors the dashboard, and reporting a transient failure as final is the
|
|
51
|
+
# worse error.
|
|
52
|
+
#
|
|
53
|
+
# @param ids [Array<Integer>]
|
|
54
|
+
# @param timeout [Numeric] seconds
|
|
55
|
+
# @param interval [Numeric] seconds between polls
|
|
56
|
+
# @param raise_on_timeout [Boolean]
|
|
57
|
+
# @return [Array<RelayGrid::Delivery>] as last seen
|
|
58
|
+
# @raise [RelayGrid::TimeoutWaitingForDeliveries] when `raise_on_timeout`
|
|
59
|
+
def wait_for(ids, timeout: 30, interval: 2, raise_on_timeout: false)
|
|
60
|
+
ids = Array(ids).compact
|
|
61
|
+
return [] if ids.empty?
|
|
62
|
+
|
|
63
|
+
deadline = monotonic_now + timeout
|
|
64
|
+
deliveries = []
|
|
65
|
+
|
|
66
|
+
loop do
|
|
67
|
+
deliveries = get_all(ids)
|
|
68
|
+
return deliveries if deliveries.any? && deliveries.all?(&:success?)
|
|
69
|
+
|
|
70
|
+
# Never sleep past the deadline: the caller's timeout is honoured to
|
|
71
|
+
# within one request, not rounded up to the next whole interval.
|
|
72
|
+
remaining = deadline - monotonic_now
|
|
73
|
+
break if remaining <= 0
|
|
74
|
+
|
|
75
|
+
sleeper.call([interval, remaining].min)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
raise TimeoutWaitingForDeliveries.new(deliveries: deliveries) if raise_on_timeout
|
|
79
|
+
|
|
80
|
+
deliveries
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# `sleeper` and `clock` are separate seams so specs can drive the loop
|
|
86
|
+
# with a fake clock -- the fake sleeper advances the fake clock -- instead
|
|
87
|
+
# of sleeping for real.
|
|
88
|
+
def sleeper
|
|
89
|
+
@sleeper ||= ->(seconds) { sleep(seconds) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def clock
|
|
93
|
+
@clock ||= -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def monotonic_now
|
|
97
|
+
clock.call
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RelayGrid
|
|
4
|
+
module Resources
|
|
5
|
+
# Read access to the account's templates. `notify` takes a template *name*,
|
|
6
|
+
# so this is mostly for discovery -- listing what's available, or checking
|
|
7
|
+
# which attributes a template expects before sending.
|
|
8
|
+
class MessageTemplates
|
|
9
|
+
def initialize(client)
|
|
10
|
+
@client = client
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [Array<Hash>] every template in the account
|
|
14
|
+
def list
|
|
15
|
+
Array(@client.get("/api/v1/message_templates")["message_templates"])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param id [Integer]
|
|
19
|
+
# @return [Hash]
|
|
20
|
+
def get(id)
|
|
21
|
+
@client.get("/api/v1/message_templates/#{id}")["message_template"]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Look a template up by the same name `notify` takes.
|
|
25
|
+
#
|
|
26
|
+
# @param name [String]
|
|
27
|
+
# @return [Hash, nil]
|
|
28
|
+
def find_by_name(name)
|
|
29
|
+
list.find { |template| template["name"] == name.to_s }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|