mobiscroll-connect 1.1.0 → 1.5.0
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 +4 -4
- data/README.md +30 -2
- data/lib/mobiscroll/connect/client.rb +2 -1
- data/lib/mobiscroll/connect/errors.rb +28 -1
- data/lib/mobiscroll/connect/models.rb +83 -3
- data/lib/mobiscroll/connect/resources/webhooks.rb +42 -0
- data/lib/mobiscroll/connect/version.rb +1 -1
- data/lib/mobiscroll/connect.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b741101bdf4d7452a820bd7b988d9dff51f02cf4683d867fb332292ab0a4177e
|
|
4
|
+
data.tar.gz: 1516abffa7aea7972ebaf558c77bcd8999c397606bf66b9cf27f7519bc5198ea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz: '
|
|
6
|
+
metadata.gz: 5778815c08e0d3cda4217a0c0b559ef6ad9e840b7c83760409875a446c9bcfcd9d26ee96caa18c74ef4ba194e9d51e6974e1b32d3ce58daec68fbb2ab510b52c
|
|
7
|
+
data.tar.gz: '089dd3ea229d309050d63097ce83bc2b4f74a2c2513b1668a716154563b5a9d01e9e9a4ebe4ab384f1c9f9d83a3f7f804ac2dcbbc8681d831817f26a18412934'
|
data/README.md
CHANGED
|
@@ -35,7 +35,7 @@ client = Mobiscroll::Connect::Client.new(
|
|
|
35
35
|
```ruby
|
|
36
36
|
url = client.auth.generate_auth_url(
|
|
37
37
|
user_id: 'user-123',
|
|
38
|
-
lng: 'es', # optional: Connect page language
|
|
38
|
+
lng: 'es', # optional: Connect page language, see https://mobiscroll.com/docs/connect/localization#supported-languages
|
|
39
39
|
providers: [
|
|
40
40
|
Mobiscroll::Connect::Provider::GOOGLE,
|
|
41
41
|
Mobiscroll::Connect::Provider::MICROSOFT
|
|
@@ -69,7 +69,12 @@ client.set_credentials(
|
|
|
69
69
|
```ruby
|
|
70
70
|
status = client.auth.get_connection_status
|
|
71
71
|
status.connections.each do |provider, accounts|
|
|
72
|
-
accounts.each
|
|
72
|
+
accounts.each do |a|
|
|
73
|
+
puts "#{provider}: #{a.display}"
|
|
74
|
+
# Google's consent screen lets the user untick the calendar permission and still
|
|
75
|
+
# finish signing in. Such an account is connected but lists no calendars.
|
|
76
|
+
puts " #{a.id} must reconnect and allow calendar access" if a.calendar_permission_granted == false
|
|
77
|
+
end
|
|
73
78
|
end
|
|
74
79
|
```
|
|
75
80
|
|
|
@@ -165,6 +170,29 @@ client.events.delete(
|
|
|
165
170
|
)
|
|
166
171
|
```
|
|
167
172
|
|
|
173
|
+
## Webhooks
|
|
174
|
+
|
|
175
|
+
### Subscribe to calendar change notifications
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
result = client.webhooks.subscribe_webhook(
|
|
179
|
+
provider: Mobiscroll::Connect::Provider::GOOGLE,
|
|
180
|
+
calendar_id: 'primary'
|
|
181
|
+
)
|
|
182
|
+
puts result.channel_id
|
|
183
|
+
puts result.server_webhook_url
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Unsubscribe
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
client.webhooks.unsubscribe_webhook(
|
|
190
|
+
provider: 'google',
|
|
191
|
+
channel_id: result.channel_id,
|
|
192
|
+
resource_id: result.subscription.resource_id
|
|
193
|
+
)
|
|
194
|
+
```
|
|
195
|
+
|
|
168
196
|
## Error handling
|
|
169
197
|
|
|
170
198
|
All errors are subclasses of `Mobiscroll::Connect::Error`:
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Mobiscroll
|
|
4
4
|
module Connect
|
|
5
5
|
class Client
|
|
6
|
-
attr_reader :auth, :calendars, :events
|
|
6
|
+
attr_reader :auth, :calendars, :events, :webhooks
|
|
7
7
|
|
|
8
8
|
def initialize(client_id:, client_secret:, redirect_uri:,
|
|
9
9
|
base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
|
|
@@ -20,6 +20,7 @@ module Mobiscroll
|
|
|
20
20
|
@auth = Resources::Auth.new(@config, @api_client)
|
|
21
21
|
@calendars = Resources::Calendars.new(@config, @api_client)
|
|
22
22
|
@events = Resources::Events.new(@config, @api_client)
|
|
23
|
+
@webhooks = Resources::Webhooks.new(@config, @api_client)
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
def set_credentials(tokens)
|
|
@@ -17,6 +17,25 @@ module Mobiscroll
|
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Raised when no connected account has the calendar access the request needs.
|
|
21
|
+
#
|
|
22
|
+
# The user completed sign-in but did not grant the calendar permission — Google's
|
|
23
|
+
# consent screen presents it as a separate checkbox. This cannot be repaired
|
|
24
|
+
# server-side, because providers only issue permissions at consent time: the
|
|
25
|
+
# accounts in +accounts+ have to run the connect flow again and allow access.
|
|
26
|
+
#
|
|
27
|
+
# Subclasses AuthenticationError, so existing rescue clauses keep working.
|
|
28
|
+
class CalendarPermissionError < AuthenticationError
|
|
29
|
+
# Array of BlockedAccount structs that must reconnect.
|
|
30
|
+
attr_reader :accounts
|
|
31
|
+
|
|
32
|
+
def initialize(message = 'No connected account has calendar access', accounts: [])
|
|
33
|
+
super(message)
|
|
34
|
+
@code = 'CALENDAR_PERMISSION_REQUIRED'
|
|
35
|
+
@accounts = accounts
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
20
39
|
class NotFoundError < Error
|
|
21
40
|
def initialize(message = 'Resource not found')
|
|
22
41
|
super(message, code: 'NOT_FOUND_ERROR')
|
|
@@ -67,7 +86,15 @@ module Mobiscroll
|
|
|
67
86
|
|
|
68
87
|
case status
|
|
69
88
|
when 401, 403
|
|
70
|
-
|
|
89
|
+
# A 403 the caller can act on: the account connected but never granted
|
|
90
|
+
# calendar access, so it is raised as its own type naming the accounts.
|
|
91
|
+
hash = body.is_a?(Hash) ? body : {}
|
|
92
|
+
if status == 403 && (hash['code'] || hash[:code]) == 'calendar_permission_required'
|
|
93
|
+
raw = hash['accounts'] || hash[:accounts]
|
|
94
|
+
CalendarPermissionError.new(message, accounts: Array(raw).filter_map { |a| BlockedAccount.from_h(a) })
|
|
95
|
+
else
|
|
96
|
+
AuthenticationError.new(message)
|
|
97
|
+
end
|
|
71
98
|
when 404
|
|
72
99
|
NotFoundError.new(message)
|
|
73
100
|
when 400, 422
|
|
@@ -35,12 +35,50 @@ module Mobiscroll
|
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
#
|
|
39
|
-
|
|
38
|
+
# A connected account that withheld calendar access on its provider's consent screen.
|
|
39
|
+
BlockedAccount = Struct.new(:provider, :account, keyword_init: true) do
|
|
40
40
|
def self.from_h(hash)
|
|
41
41
|
return nil if hash.nil?
|
|
42
42
|
|
|
43
|
-
new(
|
|
43
|
+
new(
|
|
44
|
+
provider: hash['provider'] || hash[:provider],
|
|
45
|
+
account: hash['account'] || hash[:account]
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# One connected calendar account.
|
|
51
|
+
#
|
|
52
|
+
# `granted_scopes` are the scopes the provider actually granted, which are not
|
|
53
|
+
# necessarily the ones Connect asked for: Google's consent screen lets the user untick
|
|
54
|
+
# the calendar permission and still complete sign-in.
|
|
55
|
+
#
|
|
56
|
+
# `calendar_permission_granted` is false for exactly those accounts — connected, but
|
|
57
|
+
# no calendars can be read from them until the user reconnects and allows access. It
|
|
58
|
+
# is nil when the question does not apply (Apple and CalDav authenticate with a
|
|
59
|
+
# username and app password) or no scopes were recorded for the account.
|
|
60
|
+
# `sync_state` reports whether the stored credentials still work: 'active', or
|
|
61
|
+
# 'reauth_required' once the provider has rejected them. It answers a different question
|
|
62
|
+
# from `calendar_permission_granted`, which records what was agreed at consent time and
|
|
63
|
+
# never changes afterwards. An account with no observed failure is 'active'. Recovery is
|
|
64
|
+
# the same reconnect — it cannot be repaired server-side.
|
|
65
|
+
ConnectedAccount = Struct.new(
|
|
66
|
+
:id, :display, :granted_scopes, :calendar_permission_granted, :sync_state, :sync_state_updated_at,
|
|
67
|
+
keyword_init: true
|
|
68
|
+
) do
|
|
69
|
+
def self.from_h(hash)
|
|
70
|
+
return nil if hash.nil?
|
|
71
|
+
|
|
72
|
+
key = 'calendarPermissionGranted'
|
|
73
|
+
granted = hash.key?(key) ? hash[key] : hash[key.to_sym]
|
|
74
|
+
new(
|
|
75
|
+
id: hash['id'] || hash[:id],
|
|
76
|
+
display: hash['display'] || hash[:display],
|
|
77
|
+
granted_scopes: Array(hash['grantedScopes'] || hash[:grantedScopes]),
|
|
78
|
+
calendar_permission_granted: granted,
|
|
79
|
+
sync_state: hash['syncState'] || hash[:syncState] || 'active',
|
|
80
|
+
sync_state_updated_at: hash['syncStateUpdatedAt'] || hash[:syncStateUpdatedAt]
|
|
81
|
+
)
|
|
44
82
|
end
|
|
45
83
|
end
|
|
46
84
|
|
|
@@ -172,5 +210,47 @@ module Mobiscroll
|
|
|
172
210
|
)
|
|
173
211
|
end
|
|
174
212
|
end
|
|
213
|
+
|
|
214
|
+
# The channel details returned by Webhooks#subscribe_webhook. `resource_id` is
|
|
215
|
+
# present for Google; `expiration` is an ISO date string, present for some providers.
|
|
216
|
+
WebhookSubscription = Struct.new(:channel_id, :resource_id, :expiration, keyword_init: true) do
|
|
217
|
+
def self.from_h(hash)
|
|
218
|
+
return nil if hash.nil?
|
|
219
|
+
|
|
220
|
+
new(
|
|
221
|
+
channel_id: hash['channelId'] || hash[:channelId],
|
|
222
|
+
resource_id: hash['resourceId'] || hash[:resourceId],
|
|
223
|
+
expiration: hash['expiration'] || hash[:expiration]
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Response from Webhooks#subscribe_webhook.
|
|
229
|
+
SubscribeWebhookResponse = Struct.new(
|
|
230
|
+
:success, :provider, :subscription, :server_webhook_url, :channel_id, keyword_init: true
|
|
231
|
+
) do
|
|
232
|
+
def self.from_h(hash)
|
|
233
|
+
return nil if hash.nil?
|
|
234
|
+
|
|
235
|
+
new(
|
|
236
|
+
success: hash['success'] || hash[:success],
|
|
237
|
+
provider: hash['provider'] || hash[:provider],
|
|
238
|
+
subscription: WebhookSubscription.from_h(hash['subscription'] || hash[:subscription]),
|
|
239
|
+
server_webhook_url: hash['serverWebhookUrl'] || hash[:serverWebhookUrl],
|
|
240
|
+
channel_id: hash['channelId'] || hash[:channelId]
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Response from Webhooks#unsubscribe_webhook. `success` is true whenever the local
|
|
246
|
+
# mapping was removed, even if the provider-side unsubscribe itself failed — `message`
|
|
247
|
+
# explains what happened in that case.
|
|
248
|
+
UnsubscribeWebhookResponse = Struct.new(:success, :message, keyword_init: true) do
|
|
249
|
+
def self.from_h(hash)
|
|
250
|
+
return nil if hash.nil?
|
|
251
|
+
|
|
252
|
+
new(success: hash['success'] || hash[:success], message: hash['message'] || hash[:message])
|
|
253
|
+
end
|
|
254
|
+
end
|
|
175
255
|
end
|
|
176
256
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Mobiscroll
|
|
6
|
+
module Connect
|
|
7
|
+
module Resources
|
|
8
|
+
class Webhooks
|
|
9
|
+
def initialize(_config, api_client)
|
|
10
|
+
@api_client = api_client
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Subscribes to push notifications for changes on a calendar.
|
|
14
|
+
#
|
|
15
|
+
# `channel_id` is auto-generated server-side when omitted. `expiration` is a
|
|
16
|
+
# provider-specific timestamp (ms epoch) the caller can suggest.
|
|
17
|
+
def subscribe_webhook(provider:, calendar_id:, channel_id: nil, expiration: nil)
|
|
18
|
+
body = { 'provider' => provider, 'calendarId' => calendar_id }
|
|
19
|
+
body['channelId'] = channel_id if channel_id
|
|
20
|
+
body['expiration'] = expiration if expiration
|
|
21
|
+
|
|
22
|
+
parsed = @api_client.post('/subscribe-webhook', body: JSON.generate(body))
|
|
23
|
+
SubscribeWebhookResponse.from_h(parsed)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Unsubscribes a previously created webhook channel. `resource_id` is required by
|
|
27
|
+
# some providers (e.g. Google) to fully unsubscribe.
|
|
28
|
+
#
|
|
29
|
+
# The backend treats a 200 response as final even when the provider-side
|
|
30
|
+
# unsubscribe itself failed (e.g. an already-expired subscription) — the local
|
|
31
|
+
# mapping is removed either way, and `message` explains what happened.
|
|
32
|
+
def unsubscribe_webhook(provider:, channel_id:, resource_id: nil)
|
|
33
|
+
body = { 'provider' => provider, 'channelId' => channel_id }
|
|
34
|
+
body['resourceId'] = resource_id if resource_id
|
|
35
|
+
|
|
36
|
+
parsed = @api_client.post('/unsubscribe-webhook', body: JSON.generate(body))
|
|
37
|
+
UnsubscribeWebhookResponse.from_h(parsed)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/mobiscroll/connect.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative 'connect/api_client'
|
|
|
9
9
|
require_relative 'connect/resources/auth'
|
|
10
10
|
require_relative 'connect/resources/calendars'
|
|
11
11
|
require_relative 'connect/resources/events'
|
|
12
|
+
require_relative 'connect/resources/webhooks'
|
|
12
13
|
require_relative 'connect/client'
|
|
13
14
|
|
|
14
15
|
module Mobiscroll
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mobiscroll-connect
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mobiscroll
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -101,6 +101,7 @@ files:
|
|
|
101
101
|
- lib/mobiscroll/connect/resources/auth.rb
|
|
102
102
|
- lib/mobiscroll/connect/resources/calendars.rb
|
|
103
103
|
- lib/mobiscroll/connect/resources/events.rb
|
|
104
|
+
- lib/mobiscroll/connect/resources/webhooks.rb
|
|
104
105
|
- lib/mobiscroll/connect/version.rb
|
|
105
106
|
- mobiscroll-connect.gemspec
|
|
106
107
|
homepage: https://mobiscroll.com/connect
|