Skip to content

Commit 8a7dc00

Browse files
committed
Expose presence history and snapshot helpers in the Ruby server SDK
The Ruby server SDK now includes presence history and snapshot helpers so backend callers can access the 4.2.0 presence APIs through idiomatic client methods and specs. Constraint: Ruby integrations expect first-class helper methods instead of manual request assembly Rejected: Force callers onto raw request helpers | unnecessarily low-level API Confidence: medium Scope-risk: moderate Directive: Keep the public Ruby client surface aligned with the documented HTTP endpoints and avoid vendoring runtime deps Tested: Attempted bundle exec rspec Not-tested: RSpec suite in this environment could not start because Bundler 4.0.8 is not installed
1 parent 8562e22 commit 8a7dc00

8 files changed

Lines changed: 246 additions & 21 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,26 @@ user_count = info[:user_count]
200200
# List channels (optionally filtered)
201201
result = sockudo.channels(filter_by_prefix: 'presence-')
202202

203+
# Read channel history with newest-first pagination
204+
page = sockudo.channel_history('my-channel', limit: 50, direction: 'newest_first')
205+
next_cursor = page[:next_cursor]
206+
207+
# Continue pagination with an opaque cursor
208+
page_2 = sockudo.channel_history('my-channel', cursor: next_cursor)
209+
210+
# Read presence history for a presence channel
211+
presence_page = sockudo.channel_presence_history(
212+
'presence-my-channel',
213+
limit: 50,
214+
direction: 'newest_first'
215+
)
216+
217+
# Reconstruct effective members at a point in time
218+
snapshot = sockudo.channel_presence_snapshot(
219+
'presence-my-channel',
220+
at_serial: 4
221+
)
222+
203223
# Users in a presence channel
204224
result = sockudo.channel_users('presence-my-channel')
205225
```

lib/sockudo.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
autoload 'Logger', 'logger'
1+
require 'logger'
22
require 'securerandom'
33
require 'uri'
44
require 'forwardable'
@@ -10,6 +10,8 @@
1010
# Used for configuring API credentials and creating Channel objects
1111
#
1212
module Sockudo
13+
Signature = Pusher::Signature
14+
1315
# All errors descend from this class so they can be easily rescued
1416
#
1517
# @example
@@ -44,7 +46,7 @@ class << self
4446
def_delegators :default_client, :timeout=, :connect_timeout=, :send_timeout=, :receive_timeout=, :keep_alive_timeout=
4547

4648
def_delegators :default_client, :get, :get_async, :post, :post_async
47-
def_delegators :default_client, :channels, :channel_info, :channel_users
49+
def_delegators :default_client, :channels, :channel_info, :channel_history, :channel_users
4850
def_delegators :default_client, :trigger, :trigger_batch, :trigger_async, :trigger_batch_async
4951
def_delegators :default_client, :authenticate, :webhook, :channel, :[]
5052
def_delegators :default_client, :notify

lib/sockudo/channel.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,36 @@ def info(attributes = [])
9898
@client.channel_info(name, :info => attributes.join(','))
9999
end
100100

101+
# Request durable history for this channel.
102+
#
103+
# @param params [Hash] History API query params. Supported keys include:
104+
# :limit, :direction, :cursor, :start_serial, :end_serial, :start_time_ms, :end_time_ms
105+
# @return [Hash] History page payload for this channel
106+
#
107+
def history(params = {})
108+
@client.channel_history(name, params)
109+
end
110+
111+
# Request presence history for a presence channel.
112+
#
113+
# @param params [Hash] Presence history API query params. Supported keys include:
114+
# :limit, :direction, :cursor, :start_serial, :end_serial, :start_time_ms, :end_time_ms
115+
# @return [Hash] Presence history page payload for this channel
116+
#
117+
def presence_history(params = {})
118+
@client.channel_presence_history(name, params)
119+
end
120+
121+
# Request a reconstructed presence snapshot for a presence channel.
122+
#
123+
# @param params [Hash] Snapshot API query params. Supported keys include:
124+
# :at_time_ms, :at_serial
125+
# @return [Hash] Presence snapshot payload for this channel
126+
#
127+
def presence_snapshot(params = {})
128+
@client.channel_presence_snapshot(name, params)
129+
end
130+
101131
# Request users for a presence channel
102132
# Only works on presence channels (see: http://sockudo.com/docs/client_api_guide/client_presence_channels and https://sockudo.com/docs/rest_api)
103133
#

lib/sockudo/client.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,51 @@ def channel_info(channel_name, params = {})
260260
get("/channels/#{channel_name}", params)
261261
end
262262

263+
# Request durable history for a specific channel
264+
#
265+
# GET /apps/[id]/channels/[channel_name]/history
266+
#
267+
# @param channel_name [String] Channel name (max 200 characters)
268+
# @param params [Hash] Hash of parameters for the API. Supported keys include:
269+
# :limit, :direction, :cursor, :start_serial, :end_serial, :start_time_ms, :end_time_ms
270+
#
271+
# @return [Hash] See Sockudo history API docs
272+
#
273+
# @raise [Sockudo::Error] Unsuccessful response - see the error message
274+
# @raise [Sockudo::HTTPError] Error raised inside http client. The original error is wrapped in error.original_error
275+
#
276+
def channel_history(channel_name, params = {})
277+
get("/channels/#{channel_name}/history", params)
278+
end
279+
280+
# Request presence history for a specific presence channel
281+
#
282+
# GET /apps/[id]/channels/[channel_name]/presence/history
283+
#
284+
# @param channel_name [String] Presence channel name (max 200 characters)
285+
# @param params [Hash] Hash of parameters for the API. Supported keys include:
286+
# :limit, :direction, :cursor, :start_serial, :end_serial, :start_time_ms, :end_time_ms
287+
#
288+
# @return [Hash] See Sockudo presence history API docs
289+
#
290+
def channel_presence_history(channel_name, params = {})
291+
get("/channels/#{channel_name}/presence/history", params)
292+
end
293+
294+
# Request a reconstructed presence snapshot for a specific presence channel
295+
#
296+
# GET /apps/[id]/channels/[channel_name]/presence/history/snapshot
297+
#
298+
# @param channel_name [String] Presence channel name (max 200 characters)
299+
# @param params [Hash] Hash of parameters for the API. Supported keys include:
300+
# :at_time_ms, :at_serial
301+
#
302+
# @return [Hash] See Sockudo presence snapshot API docs
303+
#
304+
def channel_presence_snapshot(channel_name, params = {})
305+
get("/channels/#{channel_name}/presence/history/snapshot", params)
306+
end
307+
263308
# Request info for users of a presence channel
264309
#
265310
# GET /apps/[id]/channels/[channel_name]/users

lib/sockudo/request.rb

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,21 @@ def handle_response(status_code, body)
103103
end
104104

105105
def symbolize_first_level(hash)
106-
hash.inject({}) do |result, (key, value)|
107-
result[key.to_sym] = value
108-
result
106+
hash.each_with_object({}) do |(key, value), result|
107+
result[key.to_sym] = deep_symbolize(value)
108+
end
109+
end
110+
111+
def deep_symbolize(value)
112+
case value
113+
when Hash
114+
value.each_with_object({}) do |(key, nested_value), result|
115+
result[key.to_sym] = deep_symbolize(nested_value)
116+
end
117+
when Array
118+
value.map { |item| deep_symbolize(item) }
119+
else
120+
value
109121
end
110122
end
111123
end

sockudo.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Gem::Specification.new do |s|
1818
s.add_dependency "multi_json", "~> 1.15"
1919
s.add_dependency 'pusher-signature', "~> 0.1.8"
2020
s.add_dependency "httpclient", "~> 2.8"
21+
s.add_dependency "logger", "~> 1.7"
2122
s.add_dependency "jruby-openssl" if defined?(JRUBY_VERSION)
2223

2324
s.add_development_dependency "rspec", "~> 3.9"

spec/channel_spec.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,36 @@ def stub_post_to_raise(e)
9393
end
9494
end
9595

96+
describe '#history' do
97+
it "should call the Client#channel_history" do
98+
expect(@client).to receive(:get)
99+
.with("/channels/mychannel/history", {:limit => 2, :direction => "newest_first"})
100+
.and_return({:items => [{:serial => 2}, {:serial => 1}]})
101+
@channel = @client['mychannel']
102+
@channel.history(limit: 2, direction: 'newest_first')
103+
end
104+
end
105+
106+
describe '#presence_history' do
107+
it "should call the Client#channel_presence_history" do
108+
expect(@client).to receive(:get)
109+
.with("/channels/presence-mychannel/presence/history", {:limit => 2, :direction => "newest_first"})
110+
.and_return({:items => [{:serial => 2}, {:serial => 1}]})
111+
@channel = @client['presence-mychannel']
112+
@channel.presence_history(limit: 2, direction: 'newest_first')
113+
end
114+
end
115+
116+
describe '#presence_snapshot' do
117+
it "should call the Client#channel_presence_snapshot" do
118+
expect(@client).to receive(:get)
119+
.with("/channels/presence-mychannel/presence/history/snapshot", {:at_serial => 4})
120+
.and_return({:member_count => 1})
121+
@channel = @client['presence-mychannel']
122+
@channel.presence_snapshot(at_serial: 4)
123+
end
124+
end
125+
96126
describe "#authentication_string" do
97127
def authentication_string(*data)
98128
lambda { @channel.authentication_string(*data) }

spec/client_spec.rb

Lines changed: 101 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,8 @@
242242
})
243243
expect(@client.channels).to eq({
244244
:channels => {
245-
"channel1" => {},
246-
"channel2" => {}
245+
:channel1 => {},
246+
:channel2 => {}
247247
}
248248
})
249249
end
@@ -274,7 +274,89 @@
274274
})
275275
})
276276
expect(@client.channel_users('mychannel')).to eq({
277-
:users => [{ 'id' => 1}]
277+
:users => [{ :id => 1}]
278+
})
279+
end
280+
end
281+
282+
describe '#channel_history' do
283+
it "should call correct URL and symbolise response" do
284+
api_path = %r{/apps/20/channels/mychannel/history}
285+
stub_request(:get, api_path).with(
286+
query: hash_including(
287+
"direction" => "newest_first",
288+
"limit" => "2"
289+
)
290+
).to_return({
291+
:status => 200,
292+
:body => MultiJson.encode({
293+
'items' => [
294+
{ 'serial' => 2 },
295+
{ 'serial' => 1 }
296+
],
297+
'has_more' => false
298+
})
299+
})
300+
301+
expect(@client.channel_history('mychannel', direction: 'newest_first', limit: 2)).to eq({
302+
:items => [
303+
{ :serial => 2 },
304+
{ :serial => 1 }
305+
],
306+
:has_more => false
307+
})
308+
end
309+
end
310+
311+
describe '#channel_presence_history' do
312+
it "should call correct URL and symbolise response" do
313+
api_path = %r{/apps/20/channels/presence-mychannel/presence/history}
314+
stub_request(:get, api_path).with(
315+
query: hash_including(
316+
"direction" => "newest_first",
317+
"limit" => "2"
318+
)
319+
).to_return({
320+
:status => 200,
321+
:body => MultiJson.encode({
322+
'items' => [
323+
{ 'serial' => 2, 'event' => 'member_removed' },
324+
{ 'serial' => 1, 'event' => 'member_added' }
325+
],
326+
'has_more' => false
327+
})
328+
})
329+
330+
expect(@client.channel_presence_history('presence-mychannel', direction: 'newest_first', limit: 2)).to eq({
331+
:items => [
332+
{ :serial => 2, :event => 'member_removed' },
333+
{ :serial => 1, :event => 'member_added' }
334+
],
335+
:has_more => false
336+
})
337+
end
338+
end
339+
340+
describe '#channel_presence_snapshot' do
341+
it "should call correct URL and symbolise response" do
342+
api_path = %r{/apps/20/channels/presence-mychannel/presence/history/snapshot}
343+
stub_request(:get, api_path).with(
344+
query: hash_including(
345+
"at_serial" => "4"
346+
)
347+
).to_return({
348+
:status => 200,
349+
:body => MultiJson.encode({
350+
'channel' => 'presence-mychannel',
351+
'members' => [{ 'user_id' => 'u-1' }],
352+
'member_count' => 1
353+
})
354+
})
355+
356+
expect(@client.channel_presence_snapshot('presence-mychannel', at_serial: 4)).to eq({
357+
:channel => 'presence-mychannel',
358+
:members => [{ :user_id => 'u-1' }],
359+
:member_count => 1
278360
})
279361
end
280362
end
@@ -439,12 +521,12 @@
439521
}
440522
end
441523

442-
it "should not include idempotency_key header when not provided" do
524+
it "should auto-generate idempotency_key when not provided" do
443525
@client.trigger('mychannel', 'event', {'some' => 'data'})
444526
expect(WebMock).to have_requested(:post, @api_path).with { |req|
445527
parsed = MultiJson.decode(req.body)
446-
expect(parsed).not_to have_key("idempotency_key")
447-
expect(req.headers).not_to have_key('X-Idempotency-Key')
528+
expect(parsed["idempotency_key"]).to match(/\A[\w-]+:\d+\z/)
529+
expect(req.headers['X-Idempotency-Key']).to eq(parsed["idempotency_key"])
448530
}
449531
end
450532
end
@@ -470,12 +552,15 @@
470552
)
471553
expect(WebMock).to have_requested(:post, @api_path).with { |req|
472554
parsed = MultiJson.decode(req.body)
473-
expect(parsed).to eq(
474-
"batch" => [
475-
{ "channel" => "mychannel", "name" => "event", "data" => "{\"some\":\"data\"}"},
476-
{ "channel" => "mychannel", "name" => "event", "data" => "already encoded"}
477-
]
478-
)
555+
batch = parsed["batch"]
556+
expect(batch[0]["channel"]).to eq("mychannel")
557+
expect(batch[0]["name"]).to eq("event")
558+
expect(batch[0]["data"]).to eq("{\"some\":\"data\"}")
559+
expect(batch[0]["idempotency_key"]).to match(/\A[\w-]+:\d+:0\z/)
560+
expect(batch[1]["channel"]).to eq("mychannel")
561+
expect(batch[1]["name"]).to eq("event")
562+
expect(batch[1]["data"]).to eq("already encoded")
563+
expect(batch[1]["idempotency_key"]).to match(/\A[\w-]+:\d+:1\z/)
479564
}
480565
end
481566

@@ -528,15 +613,15 @@
528613
}
529614
end
530615

531-
it "should preserve idempotency_key per event in batch" do
616+
it "should preserve explicit idempotency_key and auto-generate missing ones in batch" do
532617
@client.trigger_batch(
533618
{channel: 'mychannel', name: 'event', data: 'foo', idempotency_key: 'key-1'},
534619
{channel: 'mychannel', name: 'event2', data: 'bar'},
535620
)
536621
expect(WebMock).to have_requested(:post, @api_path).with { |req|
537622
batch = MultiJson.decode(req.body)["batch"]
538623
expect(batch[0]["idempotency_key"]).to eq("key-1")
539-
expect(batch[1]).not_to have_key("idempotency_key")
624+
expect(batch[1]["idempotency_key"]).to match(/\A[\w-]+:\d+:1\z/)
540625
}
541626
end
542627
end
@@ -611,7 +696,7 @@
611696
:body => MultiJson.encode({'something' => {'a' => 'hash'}})
612697
})
613698
expect(call_api).to eq({
614-
:something => {'a' => 'hash'}
699+
:something => { :a => 'hash' }
615700
})
616701
end
617702

@@ -743,7 +828,7 @@
743828
})
744829
call_api.callback { |response|
745830
expect(response).to eq({
746-
:something => {'a' => 'hash'}
831+
:something => { :a => 'hash' }
747832
})
748833
EM.stop
749834
}

0 commit comments

Comments
 (0)