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.
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RelayGrid
4
+ module Resources
5
+ # The receive side: reading a recipient's inbox and marking messages off.
6
+ #
7
+ # `user_id` is always *your* identifier for the user (the API's
8
+ # `external_user_id`), never a RelayGrid internal id.
9
+ class Messages
10
+ def initialize(client)
11
+ @client = client
12
+ end
13
+
14
+ # Unseen messages for a recipient, newest first. The polling counterpart
15
+ # to `RelayGrid.websocket`, and what an inbox view renders.
16
+ #
17
+ # @param user_id [String]
18
+ # @return [Array<Hash>]
19
+ # @raise [RelayGrid::UserNotFoundError] no such recipient in this account
20
+ def new_for(user_id:)
21
+ body = @client.get(
22
+ "/api/v1/messages/new_messages_for",
23
+ params: { external_user_id: user_id.to_s }
24
+ )
25
+
26
+ Array(body["messages"])
27
+ end
28
+
29
+ # @param id [Integer] message id
30
+ # @return [Hash] the message with its deliveries
31
+ def get(id)
32
+ @client.get("/api/v1/messages/#{id}")["message"]
33
+ end
34
+
35
+ # Mark a message read. With a channel id, the matching delivery is marked
36
+ # opened (which is what drives open-rate analytics); without one, only the
37
+ # message's own seen flag is set.
38
+ #
39
+ # @return [Hash] the updated message
40
+ def mark_as_seen(id, notification_channel_id: nil)
41
+ body = {}
42
+ body[:notification_channel_id] = notification_channel_id if notification_channel_id
43
+
44
+ @client.patch("/api/v1/messages/#{id}/mark_as_seen", body: body)["message"]
45
+ end
46
+
47
+ # Confirm a push message reached the device. Normally the browser/device
48
+ # does this over the websocket; this is the HTTP equivalent.
49
+ #
50
+ # @return [Boolean]
51
+ def mark_as_delivered(id)
52
+ @client.patch("/api/v1/messages/#{id}/mark_as_delivered")["success"] == true
53
+ end
54
+
55
+ # Rendered subject and body with the message's attributes substituted in.
56
+ #
57
+ # @return [Hash] `{ "rendered_subject" => ..., "rendered_body" => ... }`
58
+ def render(id)
59
+ @client.get("/api/v1/messages/#{id}/render_message")
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RelayGrid
4
+ module Resources
5
+ # Sending. The developer contract the gem exists for: a user, a template
6
+ # name, and the attributes the template needs.
7
+ class Notifications
8
+ # Recipient details a send may carry alongside the id. `email` becomes the
9
+ # contact email deliveries resolve their recipient through.
10
+ RECIPIENT_ATTRIBUTES = %i[first_name middle_name last_name email].freeze
11
+
12
+ def initialize(client)
13
+ @client = client
14
+ end
15
+
16
+ # Send a notification, creating or updating the recipient on the way.
17
+ #
18
+ # @param user [Hash] `{ id:, first_name:, last_name:, middle_name:,
19
+ # email: }`. `id` is the *your-system* identifier -- it maps to the API's
20
+ # `external_user_id`. RelayGrid's internal ids are never exposed here.
21
+ # May also be a bare String/Integer id for a recipient that already
22
+ # exists, in which case nothing about them is sent.
23
+ #
24
+ # `email` is optional but load-bearing: email deliveries resolve their
25
+ # recipient through the contact stored for that channel type, so a send
26
+ # over an email channel for a user with no email on file produces a
27
+ # delivery that fails with "no contact on file". Push needs no contact.
28
+ # Passing an empty string removes the contact.
29
+ # @param template [String] the message template's name
30
+ # @param attributes [Hash] values for the template's `{{placeholders}}`
31
+ # @return [RelayGrid::SendResult]
32
+ #
33
+ # @raise [RelayGrid::TemplateNotFoundError] no such template in the account
34
+ # @raise [RelayGrid::LimitExceededError] the monthly notification limit is spent
35
+ # @raise [RelayGrid::ValidationError] the payload was rejected
36
+ #
37
+ # @example
38
+ # result = RelayGrid.client.notify(
39
+ # user: { id: "user-123", first_name: "Ada", last_name: "Lovelace" },
40
+ # template: "order_shipped",
41
+ # attributes: { order_number: "1042", eta: "tomorrow" }
42
+ # )
43
+ # result.delivery_ids # => [201, 202]
44
+ def notify(user:, template:, attributes: {})
45
+ message = {
46
+ message_template_name: template.to_s,
47
+ message_attributes_attributes: format_attributes(attributes),
48
+ }
49
+ message.merge!(recipient_params(user))
50
+
51
+ SendResult.new(@client.post("/api/v1/messages", body: { message: message }), client: @client)
52
+ end
53
+
54
+ private
55
+
56
+ # A Hash upserts the recipient server-side (one round trip instead of
57
+ # registering the user first); a bare id is a strict lookup.
58
+ def recipient_params(user)
59
+ return { external_user_id: user.to_s } unless user.is_a?(Hash)
60
+
61
+ user = normalize_keys(user)
62
+ external_user_id = user[:id] || user[:external_user_id]
63
+
64
+ if external_user_id.nil? || external_user_id.to_s.empty?
65
+ raise ArgumentError, "user must include an :id (your own identifier for the recipient)"
66
+ end
67
+
68
+ nested = { external_user_id: external_user_id.to_s }
69
+ # Only keys the caller actually supplied are forwarded: the server leaves
70
+ # anything absent untouched, so omitting a name doesn't blank it out and
71
+ # omitting an email doesn't drop the contact.
72
+ RECIPIENT_ATTRIBUTES.each do |name|
73
+ nested[name] = user[name].to_s unless user[name].nil?
74
+ end
75
+
76
+ { user: nested }
77
+ end
78
+
79
+ # The API takes attributes as [{ name:, value: }], which is awkward to
80
+ # write by hand, so a plain Hash is accepted and converted.
81
+ def format_attributes(attributes)
82
+ return [] if attributes.nil? || attributes.empty?
83
+
84
+ case attributes
85
+ when Hash
86
+ attributes.map { |name, value| { name: name.to_s, value: value.to_s } }
87
+ when Array
88
+ attributes.map do |attribute|
89
+ attribute = normalize_keys(attribute)
90
+ unless attribute.is_a?(Hash) && attribute.key?(:name)
91
+ raise ArgumentError, "array attributes must be hashes with a :name key"
92
+ end
93
+
94
+ { name: attribute[:name].to_s, value: attribute[:value].to_s }
95
+ end
96
+ else
97
+ raise ArgumentError, "attributes must be a Hash or an Array of {name:, value:} hashes"
98
+ end
99
+ end
100
+
101
+ def normalize_keys(hash)
102
+ return hash unless hash.is_a?(Hash)
103
+
104
+ hash.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RelayGrid
4
+ module Resources
5
+ # Recipient management. `notify` upserts the recipient for you, so these are
6
+ # only needed to manage users outside a send -- backfilling, renaming, or
7
+ # honouring a deletion request.
8
+ #
9
+ # Everywhere here, `id` means *your* identifier for the user (the API's
10
+ # `external_user_id`). RelayGrid's internal ids stay internal.
11
+ class Users
12
+ def initialize(client)
13
+ @client = client
14
+ end
15
+
16
+ # @return [Array<Hash>] every recipient in the account
17
+ def list
18
+ Array(@client.get("/api/v1/notification_users")["notification_users"])
19
+ end
20
+
21
+ # @param id [String] your identifier for the user
22
+ # @return [Hash, nil]
23
+ def get(id)
24
+ list.find { |user| user["external_user_id"] == id.to_s }
25
+ end
26
+
27
+ # Create the recipient, or update their names if they already exist.
28
+ #
29
+ # @return [Hash] the notification user
30
+ def upsert(id:, first_name:, last_name:, middle_name: nil)
31
+ existing = get(id)
32
+ attributes = { first_name: first_name, last_name: last_name }
33
+ attributes[:middle_name] = middle_name unless middle_name.nil?
34
+
35
+ if existing
36
+ body = @client.patch(
37
+ "/api/v1/notification_users/#{existing['id']}",
38
+ body: { notification_user: attributes }
39
+ )
40
+ else
41
+ body = @client.post(
42
+ "/api/v1/notification_users",
43
+ body: { notification_user: attributes.merge(external_user_id: id.to_s) }
44
+ )
45
+ end
46
+
47
+ body["notification_user"]
48
+ end
49
+
50
+ # Soft-delete the recipient. Their message history is retained.
51
+ #
52
+ # @return [Boolean] false when there was no such user
53
+ def delete(id)
54
+ existing = get(id)
55
+ return false unless existing
56
+
57
+ @client.delete("/api/v1/notification_users/#{existing['id']}")
58
+ true
59
+ end
60
+
61
+ # -----------------------------------------------------------------------
62
+ # Contacts
63
+ #
64
+ # Email deliveries resolve their recipient through the contact stored for
65
+ # that channel *type* -- one address per user, whichever channel of that
66
+ # type delivers the message. `notify` upserts it from the `user:` payload,
67
+ # so these are for managing contacts outside a send: backfilling an
68
+ # existing user base, or reacting to someone changing their address.
69
+ # -----------------------------------------------------------------------
70
+
71
+ # @param id [String] your identifier for the user
72
+ # @return [Array<Hash>] the user's live contacts
73
+ # @raise [RelayGrid::UserNotFoundError]
74
+ def contacts(id)
75
+ Array(@client.get("#{contacts_path(id)}")["contacts"])
76
+ end
77
+
78
+ # Create or repoint the user's contact for one channel type.
79
+ #
80
+ # @param channel_type [String] "email"
81
+ # @param value [String] the address deliveries on that channel are sent to
82
+ # @return [Hash] the contact
83
+ # @raise [RelayGrid::ValidationError] the value was rejected
84
+ def set_contact(id, channel_type:, value:)
85
+ path = contacts_path(id)
86
+ existing = contacts(id).find { |contact| contact["channel_type"] == channel_type.to_s }
87
+ body = { contact: { channel_type: channel_type.to_s, value: value } }
88
+
89
+ if existing
90
+ @client.patch("#{path}/#{existing['id']}", body: body)["contact"]
91
+ else
92
+ @client.post(path, body: body)["contact"]
93
+ end
94
+ end
95
+
96
+ # @return [Boolean] false when there was no such contact
97
+ def delete_contact(id, channel_type:)
98
+ existing = contacts(id).find { |contact| contact["channel_type"] == channel_type.to_s }
99
+ return false unless existing
100
+
101
+ @client.delete("#{contacts_path(id)}/#{existing['id']}")
102
+ true
103
+ end
104
+
105
+ private
106
+
107
+ # Contacts hang off the user's *internal* id, which callers never hold, so
108
+ # it is resolved from their own identifier first.
109
+ def contacts_path(id)
110
+ user = get(id)
111
+ raise UserNotFoundError.new("Notification user '#{id}' not found", status: 404) unless user
112
+
113
+ "/api/v1/notification_users/#{user['id']}/contacts"
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RelayGrid
4
+ # What `RelayGrid.client.notify` hands back: the message that was created, the
5
+ # deliveries queued for it, and -- when the template has a live push channel
6
+ # -- the short-lived token the recipient's device needs to subscribe to the
7
+ # realtime channel.
8
+ class SendResult
9
+ attr_reader :message_id, :push_token, :push_token_expires_at, :deliveries,
10
+ :rendered_subject, :rendered_body, :raw
11
+
12
+ def initialize(body, client:)
13
+ @raw = body
14
+ @client = client
15
+
16
+ message = body["message"] || {}
17
+ @message_id = message["id"]
18
+ @rendered_subject = message["rendered_subject"]
19
+ @rendered_body = message["rendered_body"]
20
+
21
+ @push_token = body["push_token"]
22
+ expires_in = body["push_token_expires_in"]
23
+ @push_token_expires_at = expires_in ? Time.now + expires_in.to_i : nil
24
+
25
+ @deliveries = Array(message["deliveries"]).map { |d| Delivery.new(d, client: client) }.freeze
26
+
27
+ freeze
28
+ end
29
+
30
+ # Ids to hand to `client.deliveries.get_all` later. The primary reason a
31
+ # caller keeps anything from a send.
32
+ def delivery_ids
33
+ deliveries.map(&:id)
34
+ end
35
+
36
+ # True when the template dispatched over a push channel, so `push_token` is
37
+ # present and worth handing down to the recipient's device.
38
+ def push?
39
+ !push_token.nil?
40
+ end
41
+
42
+ def delivery_for(channel_type)
43
+ deliveries.find { |delivery| delivery.channel_type == channel_type.to_s }
44
+ end
45
+
46
+ # Poll until every delivery settles into a success state, or `timeout`
47
+ # seconds elapse.
48
+ #
49
+ # `failed` is not treated as terminal inside the window: delivery jobs
50
+ # retry, so a failed row can still flip to `sent` and then `delivered`.
51
+ # That mirrors the dashboard's semantics.
52
+ #
53
+ # @param timeout [Numeric] seconds to keep polling
54
+ # @param interval [Numeric] seconds between polls
55
+ # @param raise_on_timeout [Boolean] raise TimeoutWaitingForDeliveries
56
+ # instead of returning the last-seen deliveries
57
+ # @return [Array<RelayGrid::Delivery>] deliveries as last seen
58
+ def wait_for_deliveries(timeout: 30, interval: 2, raise_on_timeout: false)
59
+ return deliveries if deliveries.empty?
60
+
61
+ @client.deliveries.wait_for(
62
+ delivery_ids,
63
+ timeout: timeout,
64
+ interval: interval,
65
+ raise_on_timeout: raise_on_timeout
66
+ )
67
+ end
68
+
69
+ def to_h
70
+ raw
71
+ end
72
+
73
+ def inspect
74
+ "#<RelayGrid::SendResult message_id: #{message_id.inspect}, " \
75
+ "delivery_ids: #{delivery_ids.inspect}, push: #{push?}>"
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RelayGrid
4
+ VERSION = "0.1.1"
5
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "websocket-client-simple"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module RelayGrid
8
+ # Realtime delivery of a recipient's messages over Action Cable.
9
+ #
10
+ # ws = RelayGrid.websocket(user_id: "user-123")
11
+ # ws.on_message { |message| puts message["rendered_subject"] }
12
+ # ws.connect
13
+ #
14
+ # Authentication is the same short-lived channel token the send response
15
+ # returns -- it authorises exactly one recipient's stream. Tokens last an
16
+ # hour, so a fresh one is minted on every (re)connect rather than being held
17
+ # for the life of the object.
18
+ class WebsocketClient
19
+ CHANNEL = "MessagesChannel"
20
+
21
+ # Action Cable's own frame types, which are protocol noise rather than
22
+ # messages a subscriber cares about.
23
+ CONTROL_TYPES = %w[welcome ping confirm_subscription reject_subscription disconnect].freeze
24
+
25
+ attr_reader :user_id
26
+
27
+ def initialize(user_id:, client: nil, configuration: nil, reconnect: true)
28
+ @user_id = user_id.to_s
29
+ @client = client || RelayGrid.client
30
+ @configuration = configuration || @client.configuration
31
+ @reconnect = reconnect
32
+
33
+ @message_callbacks = []
34
+ @error_callbacks = []
35
+ @connect_callbacks = []
36
+ @close_callbacks = []
37
+
38
+ @mutex = Mutex.new
39
+ @connected = false
40
+ @subscribed = false
41
+ @closing = false
42
+ end
43
+
44
+ # @yield [Hash] the message payload, as `messages.new_for` returns them
45
+ def on_message(&block)
46
+ @message_callbacks << block
47
+ self
48
+ end
49
+
50
+ # @yield [Exception]
51
+ def on_error(&block)
52
+ @error_callbacks << block
53
+ self
54
+ end
55
+
56
+ def on_connect(&block)
57
+ @connect_callbacks << block
58
+ self
59
+ end
60
+
61
+ def on_close(&block)
62
+ @close_callbacks << block
63
+ self
64
+ end
65
+
66
+ # Open the connection and subscribe.
67
+ #
68
+ # @param blocking [Boolean] block the calling thread until disconnected.
69
+ # Useful for a standalone consumer process; never do this in a web
70
+ # request.
71
+ def connect(blocking: false)
72
+ @closing = false
73
+ open_socket
74
+
75
+ wait_until_closed if blocking
76
+
77
+ self
78
+ end
79
+
80
+ def disconnect
81
+ @closing = true
82
+ set(:@subscribed, false)
83
+ set(:@connected, false)
84
+ @socket&.close
85
+ @socket = nil
86
+ self
87
+ end
88
+
89
+ def connected?
90
+ read(:@connected)
91
+ end
92
+
93
+ def subscribed?
94
+ read(:@subscribed)
95
+ end
96
+
97
+ private
98
+
99
+ def open_socket
100
+ # Minted per connection: a token from an earlier connect may well have
101
+ # expired by the time a reconnect happens.
102
+ token = @client.channel_tokens.token_for(@user_id)
103
+ identifier = { channel: CHANNEL, channel_token: token }.to_json
104
+
105
+ @socket = WebSocket::Client::Simple.connect(@configuration.cable_url)
106
+ attach_handlers(@socket, identifier)
107
+ @socket
108
+ end
109
+
110
+ def attach_handlers(socket, identifier)
111
+ subscriber = self
112
+
113
+ socket.on :open do
114
+ subscriber.send(:handle_open, socket, identifier)
115
+ end
116
+
117
+ socket.on :message do |frame|
118
+ subscriber.send(:handle_frame, frame)
119
+ end
120
+
121
+ socket.on :close do
122
+ subscriber.send(:handle_close)
123
+ end
124
+
125
+ socket.on :error do |error|
126
+ subscriber.send(:handle_error, error)
127
+ end
128
+ end
129
+
130
+ def handle_open(socket, identifier)
131
+ set(:@connected, true)
132
+ socket.send({ command: "subscribe", identifier: identifier }.to_json)
133
+ dispatch(@connect_callbacks)
134
+ end
135
+
136
+ def handle_frame(frame)
137
+ data = frame.respond_to?(:data) ? frame.data : frame
138
+ return if data.nil? || data.empty?
139
+
140
+ payload = JSON.parse(data)
141
+ return unless payload.is_a?(Hash)
142
+
143
+ case payload["type"]
144
+ when "confirm_subscription"
145
+ set(:@subscribed, true)
146
+ return
147
+ when "reject_subscription"
148
+ set(:@subscribed, false)
149
+ handle_error(Error.new("Subscription rejected for user #{@user_id}"))
150
+ return
151
+ when *CONTROL_TYPES
152
+ return
153
+ end
154
+
155
+ # Action Cable wraps the broadcast in "message"; the server broadcasts
156
+ # `{ message: <payload> }`, so the message itself is one level further in.
157
+ envelope = payload["message"]
158
+ return unless envelope.is_a?(Hash)
159
+
160
+ message = envelope["message"] || envelope
161
+ dispatch(@message_callbacks, message)
162
+ rescue JSON::ParserError
163
+ nil # a frame we can't parse is not worth tearing the connection down for
164
+ end
165
+
166
+ def handle_close
167
+ set(:@connected, false)
168
+ set(:@subscribed, false)
169
+ dispatch(@close_callbacks)
170
+
171
+ reconnect! if @reconnect && !@closing
172
+ end
173
+
174
+ def handle_error(error)
175
+ set(:@connected, false)
176
+
177
+ # Nothing is watching for exceptions on the socket's own thread, so an
178
+ # unhandled raise here would vanish silently. Hand it to the caller's
179
+ # callback, or re-raise only if they never registered one.
180
+ raise error if @error_callbacks.empty? && error.is_a?(Exception)
181
+
182
+ dispatch(@error_callbacks, error)
183
+ end
184
+
185
+ def reconnect!
186
+ sleep(1)
187
+ open_socket
188
+ rescue StandardError => e
189
+ handle_error(e)
190
+ end
191
+
192
+ def wait_until_closed
193
+ sleep(0.1) while connected? || (@reconnect && !@closing)
194
+ end
195
+
196
+ def dispatch(callbacks, *args)
197
+ callbacks.each do |callback|
198
+ callback.call(*args)
199
+ rescue StandardError => e
200
+ # One misbehaving subscriber must not take down the connection or stop
201
+ # the remaining callbacks.
202
+ @configuration.logger&.error("RelayGrid websocket callback failed: #{e.message}")
203
+ end
204
+ end
205
+
206
+ def set(ivar, value)
207
+ @mutex.synchronize { instance_variable_set(ivar, value) }
208
+ end
209
+
210
+ def read(ivar)
211
+ @mutex.synchronize { instance_variable_get(ivar) }
212
+ end
213
+ end
214
+ end
data/lib/relaygrid.rb ADDED
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "relaygrid/version"
4
+ require_relative "relaygrid/errors"
5
+ require_relative "relaygrid/configuration"
6
+ require_relative "relaygrid/delivery"
7
+ require_relative "relaygrid/send_result"
8
+ require_relative "relaygrid/client"
9
+ require_relative "relaygrid/websocket_client"
10
+ require_relative "relaygrid/resources/notifications"
11
+ require_relative "relaygrid/resources/deliveries"
12
+ require_relative "relaygrid/resources/messages"
13
+ require_relative "relaygrid/resources/message_templates"
14
+ require_relative "relaygrid/resources/users"
15
+ require_relative "relaygrid/resources/channel_tokens"
16
+
17
+ # Ruby client for the RelayGrid / RelayGrid notification API.
18
+ #
19
+ # RelayGrid.configure do |config|
20
+ # config.api_key = ENV["RELAYGRID_API_KEY"]
21
+ # config.base_url = ENV.fetch("RELAYGRID_BASE_URL", "https://relaygrid.dev")
22
+ # end
23
+ #
24
+ # result = RelayGrid.client.notify(
25
+ # user: { id: "user-123", first_name: "Ada", last_name: "Lovelace" },
26
+ # template: "order_shipped",
27
+ # attributes: { order_number: "1042" }
28
+ # )
29
+ module RelayGrid
30
+ class << self
31
+ attr_writer :configuration
32
+
33
+ def configuration
34
+ @configuration ||= Configuration.new
35
+ end
36
+
37
+ # Set the process-wide defaults. The cached default client is dropped so the
38
+ # next call picks the new settings up (clients are frozen and never mutate).
39
+ def configure
40
+ yield(configuration)
41
+ @client = nil
42
+ configuration
43
+ end
44
+
45
+ # The default client, built from the global configuration.
46
+ #
47
+ # @return [RelayGrid::Client]
48
+ def client
49
+ @client ||= Client.new(configuration)
50
+ end
51
+
52
+ # Send a notification with the default client.
53
+ # See Resources::Notifications#notify.
54
+ #
55
+ # @return [RelayGrid::SendResult]
56
+ def notify(**kwargs)
57
+ client.notify(**kwargs)
58
+ end
59
+
60
+ # A realtime subscriber for one recipient's messages.
61
+ #
62
+ # @param user_id [String] your identifier for the user
63
+ # @return [RelayGrid::WebsocketClient]
64
+ def websocket(user_id:, **options)
65
+ WebsocketClient.new(user_id: user_id, client: client, **options)
66
+ end
67
+
68
+ # Drop the global configuration and cached client. Intended for test suites.
69
+ def reset!
70
+ @configuration = Configuration.new
71
+ @client = nil
72
+ end
73
+ end
74
+ end
data/sig/relaygrid.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module RelayGrid
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end