mobiscroll-connect 1.0.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.

Potentially problematic release.


This version of mobiscroll-connect might be problematic. Click here for more details.

checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9fad1da025f19a473c5c77e9ed4b671153e27b4e632f6401d5b0d4a5f9ef9762
4
+ data.tar.gz: 5701c68a5e0a91f639d9fc929d59808004b4097ce84eee9fb7d9a13bf5d7c2e1
5
+ SHA512:
6
+ metadata.gz: 0760023c86467c57dcd1581d85350238a2dda83a2b5d0e9ad44e46bf62d76fd7988f2892c8fc8d62a5918c71f8f5fbc77c70cd05b8c7d73e815ef6a84ea37b7f
7
+ data.tar.gz: f119340a29e9d1f24660c823a83702a60951275c46f81f89fc6cf5d6e8dfe07d0c066638fc51aeef0eefcfb6ec8cf35385040ea983da85fd0f96eec5bbfe0f39
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mobiscroll
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,222 @@
1
+ # Mobiscroll Connect Ruby SDK
2
+
3
+ Official Ruby client for the [Mobiscroll Connect API](https://connect.mobiscroll.com). Sync calendar events across Google Calendar, Microsoft Outlook, Apple Calendar, and CalDAV.
4
+
5
+ ## Installation
6
+
7
+ Add to your `Gemfile`:
8
+
9
+ ```ruby
10
+ gem 'mobiscroll-connect', '~> 1.0'
11
+ ```
12
+
13
+ Or install directly:
14
+
15
+ ```bash
16
+ gem install mobiscroll-connect
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```ruby
22
+ require 'mobiscroll-connect'
23
+
24
+ client = Mobiscroll::Connect::Client.new(
25
+ client_id: ENV['MOBISCROLL_CLIENT_ID'],
26
+ client_secret: ENV['MOBISCROLL_CLIENT_SECRET'],
27
+ redirect_uri: 'https://yourapp.com/oauth/callback'
28
+ )
29
+ ```
30
+
31
+ ## OAuth flow
32
+
33
+ ### 1. Generate the authorization URL
34
+
35
+ ```ruby
36
+ url = client.auth.generate_auth_url(
37
+ user_id: 'user-123',
38
+ providers: [
39
+ Mobiscroll::Connect::Provider::GOOGLE,
40
+ Mobiscroll::Connect::Provider::MICROSOFT
41
+ ]
42
+ )
43
+ # Redirect the user to `url`
44
+ ```
45
+
46
+ ### 2. Exchange the code for tokens
47
+
48
+ ```ruby
49
+ # In your /oauth/callback handler:
50
+ tokens = client.auth.get_token(params[:code])
51
+ # tokens.access_token, tokens.refresh_token, tokens.expires_in
52
+ ```
53
+
54
+ ### 3. Restore credentials on subsequent requests
55
+
56
+ ```ruby
57
+ client.set_credentials(
58
+ Mobiscroll::Connect::TokenResponse.new(
59
+ access_token: session[:access_token],
60
+ refresh_token: session[:refresh_token],
61
+ token_type: 'Bearer'
62
+ )
63
+ )
64
+ ```
65
+
66
+ ### 4. Check connection status
67
+
68
+ ```ruby
69
+ status = client.auth.get_connection_status
70
+ status.connections.each do |provider, accounts|
71
+ accounts.each { |a| puts "#{provider}: #{a.display}" }
72
+ end
73
+ ```
74
+
75
+ ### 5. Disconnect a provider
76
+
77
+ ```ruby
78
+ client.auth.disconnect(provider: Mobiscroll::Connect::Provider::GOOGLE)
79
+ ```
80
+
81
+ ## Token refresh
82
+
83
+ The SDK automatically refreshes expired access tokens. When a request returns 401 and a `refresh_token` is available, the SDK:
84
+
85
+ 1. Calls `POST /oauth/token` with `grant_type=refresh_token` (exactly once).
86
+ 2. Retries the original request with the new token.
87
+ 3. Raises `AuthenticationError` if the refresh also fails.
88
+
89
+ Concurrent 401s share a single in-flight refresh — only one `POST /oauth/token` is ever issued per `Client` instance at a time.
90
+
91
+ To persist refreshed tokens (e.g., back to a session or database):
92
+
93
+ ```ruby
94
+ client.on_tokens_refreshed do |tokens|
95
+ session[:access_token] = tokens.access_token
96
+ session[:refresh_token] = tokens.refresh_token if tokens.refresh_token
97
+ end
98
+ ```
99
+
100
+ ## Calendars
101
+
102
+ ```ruby
103
+ calendars = client.calendars.list
104
+ calendars.each do |cal|
105
+ puts "#{cal.provider} / #{cal.title} (#{cal.id})"
106
+ end
107
+ ```
108
+
109
+ ## Events
110
+
111
+ ### List events
112
+
113
+ ```ruby
114
+ result = client.events.list(
115
+ start: '2024-01-01T00:00:00Z',
116
+ end: '2024-03-31T23:59:59Z',
117
+ page_size: 50,
118
+ single_events: true,
119
+ calendar_ids: { 'google' => ['primary'] }
120
+ )
121
+ result.events.each { |e| puts e.title }
122
+ # result.next_page_token for pagination
123
+ ```
124
+
125
+ ### Create an event
126
+
127
+ ```ruby
128
+ event = client.events.create(
129
+ provider: Mobiscroll::Connect::Provider::GOOGLE,
130
+ calendar_id: 'primary',
131
+ title: 'Team Meeting',
132
+ start: '2024-02-01T10:00:00Z',
133
+ end: '2024-02-01T11:00:00Z',
134
+ description: 'Quarterly review',
135
+ recurrence: Mobiscroll::Connect::RecurrenceRule.new(
136
+ frequency: 'WEEKLY',
137
+ interval: 1,
138
+ count: 10
139
+ )
140
+ )
141
+ puts event.id
142
+ ```
143
+
144
+ ### Update an event
145
+
146
+ ```ruby
147
+ client.events.update(
148
+ provider: 'google',
149
+ calendar_id: 'primary',
150
+ event_id: 'evt-123',
151
+ title: 'Updated Title',
152
+ update_mode: 'this'
153
+ )
154
+ ```
155
+
156
+ ### Delete an event
157
+
158
+ ```ruby
159
+ client.events.delete(
160
+ provider: 'google',
161
+ calendar_id: 'primary',
162
+ event_id: 'evt-123',
163
+ delete_mode: 'all'
164
+ )
165
+ ```
166
+
167
+ ## Error handling
168
+
169
+ All errors are subclasses of `Mobiscroll::Connect::Error`:
170
+
171
+ ```ruby
172
+ begin
173
+ client.calendars.list
174
+ rescue Mobiscroll::Connect::AuthenticationError => e
175
+ puts "Auth failed: #{e.message}"
176
+ rescue Mobiscroll::Connect::RateLimitError => e
177
+ puts "Rate limited — retry after #{e.retry_after}s"
178
+ rescue Mobiscroll::Connect::ValidationError => e
179
+ puts "Bad request: #{e.message}, details: #{e.details}"
180
+ rescue Mobiscroll::Connect::NotFoundError
181
+ puts 'Resource not found'
182
+ rescue Mobiscroll::Connect::ServerError => e
183
+ puts "Server error #{e.status_code}"
184
+ rescue Mobiscroll::Connect::NetworkError => e
185
+ puts "Network error: #{e.message}"
186
+ rescue Mobiscroll::Connect::Error => e
187
+ puts "SDK error: #{e.message} (#{e.code})"
188
+ end
189
+ ```
190
+
191
+ | Error class | HTTP status | Extra attributes |
192
+ |---|---|---|
193
+ | `AuthenticationError` | 401, 403 | — |
194
+ | `ValidationError` | 400, 422 | `details` |
195
+ | `NotFoundError` | 404 | — |
196
+ | `RateLimitError` | 429 | `retry_after` (seconds) |
197
+ | `ServerError` | 5xx | `status_code` |
198
+ | `NetworkError` | transport | `cause` |
199
+
200
+ ## Minimal demo app
201
+
202
+ See [`minimal-app/`](minimal-app/) for a Sinatra web app demonstrating the full OAuth flow. Run it with:
203
+
204
+ ```bash
205
+ cd minimal-app
206
+ bundle install
207
+ cp .env.example .env
208
+ bundle exec rackup -p 8080
209
+ ```
210
+
211
+ ## Development
212
+
213
+ ```bash
214
+ bundle install
215
+ bundle exec rspec # tests
216
+ bundle exec rubocop # lint
217
+ gem build mobiscroll-connect.gemspec
218
+ ```
219
+
220
+ ## License
221
+
222
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'faraday'
5
+ require 'json'
6
+ require 'monitor'
7
+
8
+ module Mobiscroll
9
+ module Connect
10
+ # Internal HTTP layer. Mirrors sdks/node/src/client.ts and sdks/go/transport.go:
11
+ # - injects `Authorization: Bearer <access_token>` on every API request
12
+ # - on 401 with a stored refresh_token, refreshes once and retries the original
13
+ # call exactly once. Concurrent 401s share a single in-flight refresh.
14
+ # - maps non-2xx responses to typed errors via `Connect.map_response_error`.
15
+ #
16
+ # The token-exchange / refresh Faraday connection is held separately so it
17
+ # cannot recurse into the 401 retry loop.
18
+ class ApiClient
19
+ attr_reader :config
20
+
21
+ def initialize(config)
22
+ @config = config
23
+ @credentials = nil
24
+ @monitor = Monitor.new
25
+ @refresh_cond = @monitor.new_cond
26
+ @refresh_in_flight = false
27
+ @refresh_result = nil
28
+ @refresh_error = nil
29
+ @on_tokens_refreshed = config.on_tokens_refreshed
30
+
31
+ @api_conn = build_connection(api: true)
32
+ @token_conn = build_connection(api: false)
33
+ end
34
+
35
+ def set_credentials(tokens)
36
+ @monitor.synchronize { @credentials = tokens }
37
+ end
38
+
39
+ def credentials
40
+ @monitor.synchronize { @credentials }
41
+ end
42
+
43
+ def on_tokens_refreshed(&block)
44
+ @on_tokens_refreshed = block
45
+ end
46
+
47
+ def get(path, query: nil, headers: nil)
48
+ execute(:get, path, query: query, body: nil, headers: headers)
49
+ end
50
+
51
+ def post(path, body: nil, query: nil, headers: nil)
52
+ execute(:post, path, query: query, body: body, headers: headers)
53
+ end
54
+
55
+ def put(path, body: nil, query: nil, headers: nil)
56
+ execute(:put, path, query: query, body: body, headers: headers)
57
+ end
58
+
59
+ def delete(path, query: nil, headers: nil)
60
+ execute(:delete, path, query: query, body: nil, headers: headers)
61
+ end
62
+
63
+ # POST application/x-www-form-urlencoded against the token endpoint with
64
+ # Basic auth + CLIENT_ID header. Does not participate in the 401 retry
65
+ # loop because it uses `@token_conn`.
66
+ def post_form(path, form)
67
+ creds = Base64.strict_encode64("#{@config.client_id}:#{@config.client_secret}")
68
+ response = @token_conn.post(path.sub(%r{\A/}, '')) do |req|
69
+ req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
70
+ req.headers['Authorization'] = "Basic #{creds}"
71
+ req.headers['CLIENT_ID'] = @config.client_id
72
+ req.body = URI.encode_www_form(form)
73
+ end
74
+ parsed = parse_body(response.body)
75
+ raise_for_status(response, parsed)
76
+ parsed
77
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
78
+ raise NetworkError.new(e.message, cause: e)
79
+ end
80
+
81
+ private
82
+
83
+ def execute(method, path, query:, body:, headers:, retried: false)
84
+ response = perform(method, path, query: query, body: body, headers: headers)
85
+ parsed = parse_body(response.body)
86
+
87
+ if response.status == 401 && @credentials&.refresh_token && !retried
88
+ new_tokens = refresh_access_token!
89
+ raise AuthenticationError, 'Failed to refresh token' if new_tokens.nil?
90
+
91
+ return execute(method, path, query: query, body: body, headers: headers, retried: true)
92
+ end
93
+
94
+ raise_for_status(response, parsed)
95
+ parsed
96
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
97
+ raise NetworkError.new(e.message, cause: e)
98
+ end
99
+
100
+ def perform(method, path, query:, body:, headers:)
101
+ @api_conn.public_send(method, path.sub(%r{\A/}, '')) do |req|
102
+ req.params.update(query) if query.is_a?(Hash) && !query.empty?
103
+ token = @credentials&.access_token
104
+ req.headers['Authorization'] = "Bearer #{token}" if token && !token.empty?
105
+ headers&.each { |k, v| req.headers[k] = v }
106
+ req.body = body.is_a?(String) ? body : JSON.generate(body) if body
107
+ end
108
+ end
109
+
110
+ def parse_body(body)
111
+ return nil if body.nil? || (body.respond_to?(:empty?) && body.empty?)
112
+ return body if body.is_a?(Hash) || body.is_a?(Array)
113
+
114
+ JSON.parse(body.to_s)
115
+ rescue JSON::ParserError
116
+ nil
117
+ end
118
+
119
+ def raise_for_status(response, parsed)
120
+ return if response.status < 400
121
+
122
+ err = Connect.map_response_error(response.status, parsed, response.headers)
123
+ raise(err) if err
124
+
125
+ raise Error, "HTTP #{response.status}"
126
+ end
127
+
128
+ # Refreshes the access token. Concurrent callers share one in-flight
129
+ # request — the first caller does the work, subsequent callers wait on
130
+ # the same condition variable and read the cached result. Returns the
131
+ # refreshed TokenResponse, or nil if refresh failed.
132
+ def refresh_access_token!
133
+ do_refresh = false
134
+ @monitor.synchronize do
135
+ if @refresh_in_flight
136
+ @refresh_cond.wait_while { @refresh_in_flight }
137
+ raise @refresh_error if @refresh_error
138
+
139
+ return @refresh_result
140
+ end
141
+
142
+ @refresh_in_flight = true
143
+ @refresh_result = nil
144
+ @refresh_error = nil
145
+ do_refresh = true
146
+ end
147
+
148
+ return unless do_refresh
149
+
150
+ begin
151
+ new_tokens = perform_refresh
152
+ @monitor.synchronize do
153
+ @credentials = (@credentials ? @credentials.merged_with(new_tokens) : new_tokens)
154
+ @refresh_result = @credentials
155
+ end
156
+ @on_tokens_refreshed&.call(@credentials)
157
+ @credentials
158
+ rescue StandardError => e
159
+ @monitor.synchronize { @refresh_error = e }
160
+ nil
161
+ ensure
162
+ @monitor.synchronize do
163
+ @refresh_in_flight = false
164
+ @refresh_cond.broadcast
165
+ end
166
+ end
167
+ end
168
+
169
+ def perform_refresh
170
+ rt = @credentials&.refresh_token
171
+ raise AuthenticationError, 'No refresh token available' if rt.nil? || rt.empty?
172
+
173
+ form = {
174
+ 'grant_type' => 'refresh_token',
175
+ 'refresh_token' => rt,
176
+ 'redirect_uri' => @config.redirect_uri
177
+ }
178
+ parsed = post_form('/oauth/token', form)
179
+ TokenResponse.from_h(parsed)
180
+ end
181
+
182
+ def build_connection(api:)
183
+ Faraday.new(url: @config.base_url) do |f|
184
+ f.options.timeout = @config.timeout
185
+ f.options.open_timeout = @config.timeout
186
+ f.headers['Content-Type'] = 'application/json' if api
187
+ f.headers['Accept'] = 'application/json'
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mobiscroll
4
+ module Connect
5
+ class Client
6
+ attr_reader :auth, :calendars, :events
7
+
8
+ def initialize(client_id:, client_secret:, redirect_uri:,
9
+ base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
10
+ on_tokens_refreshed: nil)
11
+ @config = Config.new(
12
+ client_id: client_id,
13
+ client_secret: client_secret,
14
+ redirect_uri: redirect_uri,
15
+ base_url: base_url,
16
+ timeout: timeout,
17
+ on_tokens_refreshed: on_tokens_refreshed
18
+ )
19
+ @api_client = ApiClient.new(@config)
20
+ @auth = Resources::Auth.new(@config, @api_client)
21
+ @calendars = Resources::Calendars.new(@config, @api_client)
22
+ @events = Resources::Events.new(@config, @api_client)
23
+ end
24
+
25
+ def set_credentials(tokens)
26
+ @api_client.set_credentials(tokens)
27
+ end
28
+
29
+ def credentials
30
+ @api_client.credentials
31
+ end
32
+
33
+ def on_tokens_refreshed(&)
34
+ @api_client.on_tokens_refreshed(&)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mobiscroll
4
+ module Connect
5
+ DEFAULT_BASE_URL = 'https://connect.mobiscroll.com/api'
6
+ DEFAULT_TIMEOUT = 30
7
+
8
+ class Config
9
+ attr_reader :client_id, :client_secret, :redirect_uri, :base_url, :timeout
10
+ attr_accessor :on_tokens_refreshed
11
+
12
+ def initialize(client_id:, client_secret:, redirect_uri:,
13
+ base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
14
+ on_tokens_refreshed: nil)
15
+ raise Error, 'client_id is required' if client_id.nil? || client_id.empty?
16
+ raise Error, 'client_secret is required' if client_secret.nil? || client_secret.empty?
17
+ raise Error, 'redirect_uri is required' if redirect_uri.nil? || redirect_uri.empty?
18
+
19
+ @client_id = client_id
20
+ @client_secret = client_secret
21
+ @redirect_uri = redirect_uri
22
+ @base_url = base_url
23
+ @timeout = timeout
24
+ @on_tokens_refreshed = on_tokens_refreshed
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mobiscroll
4
+ module Connect
5
+ class Error < StandardError
6
+ attr_reader :code
7
+
8
+ def initialize(message = nil, code: nil)
9
+ super(message)
10
+ @code = code
11
+ end
12
+ end
13
+
14
+ class AuthenticationError < Error
15
+ def initialize(message = 'Authentication failed')
16
+ super(message, code: 'AUTHENTICATION_ERROR')
17
+ end
18
+ end
19
+
20
+ class NotFoundError < Error
21
+ def initialize(message = 'Resource not found')
22
+ super(message, code: 'NOT_FOUND_ERROR')
23
+ end
24
+ end
25
+
26
+ class ValidationError < Error
27
+ attr_reader :details
28
+
29
+ def initialize(message = 'Validation failed', details: nil)
30
+ super(message, code: 'VALIDATION_ERROR')
31
+ @details = details
32
+ end
33
+ end
34
+
35
+ class RateLimitError < Error
36
+ attr_reader :retry_after
37
+
38
+ def initialize(message = 'Rate limit exceeded', retry_after: nil)
39
+ super(message, code: 'RATE_LIMIT_ERROR')
40
+ @retry_after = retry_after
41
+ end
42
+ end
43
+
44
+ class ServerError < Error
45
+ attr_reader :status_code
46
+
47
+ def initialize(message = 'Server error', status_code: nil)
48
+ super(message, code: 'SERVER_ERROR')
49
+ @status_code = status_code
50
+ end
51
+ end
52
+
53
+ class NetworkError < Error
54
+ attr_reader :cause
55
+
56
+ def initialize(message = 'Network error', cause: nil)
57
+ super(message, code: 'NETWORK_ERROR')
58
+ @cause = cause
59
+ end
60
+ end
61
+
62
+ # Maps an HTTP response (status + body hash + headers) to the matching
63
+ # typed error. Returns nil for 2xx responses.
64
+ def self.map_response_error(status, body, headers)
65
+ message = body.is_a?(Hash) ? (body['message'] || body[:message]) : nil
66
+ message ||= "HTTP #{status}"
67
+
68
+ case status
69
+ when 401, 403
70
+ AuthenticationError.new(message)
71
+ when 404
72
+ NotFoundError.new(message)
73
+ when 400, 422
74
+ details = body.is_a?(Hash) ? (body['details'] || body[:details]) : nil
75
+ ValidationError.new(message, details: details)
76
+ when 429
77
+ retry_after = headers && (headers['retry-after'] || headers['Retry-After'])
78
+ retry_after_int = retry_after&.to_i
79
+ RateLimitError.new(message, retry_after: retry_after_int)
80
+ when 500..599
81
+ ServerError.new(message, status_code: status)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mobiscroll
4
+ module Connect
5
+ # OAuth2 token payload returned by the API.
6
+ TokenResponse = Struct.new(:access_token, :token_type, :expires_in, :refresh_token, keyword_init: true) do
7
+ def self.from_h(hash)
8
+ return nil if hash.nil?
9
+
10
+ new(
11
+ access_token: hash['access_token'] || hash[:access_token],
12
+ token_type: hash['token_type'] || hash[:token_type],
13
+ expires_in: hash['expires_in'] || hash[:expires_in],
14
+ refresh_token: hash['refresh_token'] || hash[:refresh_token]
15
+ )
16
+ end
17
+
18
+ # Overlay `incoming` on top of self, preserving the existing
19
+ # refresh_token if `incoming` omits one.
20
+ def merged_with(incoming)
21
+ return incoming if nil?
22
+
23
+ rt = incoming.refresh_token
24
+ rt = refresh_token if rt.nil? || rt.empty?
25
+ TokenResponse.new(
26
+ access_token: incoming.access_token,
27
+ token_type: incoming.token_type || token_type,
28
+ expires_in: incoming.expires_in || expires_in,
29
+ refresh_token: rt
30
+ )
31
+ end
32
+
33
+ def to_h
34
+ super.compact
35
+ end
36
+ end
37
+
38
+ # One connected account under a provider.
39
+ ConnectedAccount = Struct.new(:id, :display, keyword_init: true) do
40
+ def self.from_h(hash)
41
+ return nil if hash.nil?
42
+
43
+ new(id: hash['id'] || hash[:id], display: hash['display'] || hash[:display])
44
+ end
45
+ end
46
+
47
+ # Result of Auth#get_connection_status. `connections` is keyed by lowercase
48
+ # provider name to match the API wire form.
49
+ ConnectionStatus = Struct.new(:connections, :limit_reached, keyword_init: true) do
50
+ def self.from_h(hash)
51
+ return new(connections: {}, limit_reached: false) if hash.nil?
52
+
53
+ raw = hash['connections'] || hash[:connections] || {}
54
+ connections = raw.each_with_object({}) do |(provider, accounts), acc|
55
+ acc[provider.to_s] = Array(accounts).map { |a| ConnectedAccount.from_h(a) }
56
+ end
57
+ new(connections: connections, limit_reached: hash['limitReached'] || hash[:limitReached] || false)
58
+ end
59
+ end
60
+
61
+ DisconnectResponse = Struct.new(:success, :message, keyword_init: true) do
62
+ def self.from_h(hash)
63
+ return nil if hash.nil?
64
+
65
+ new(success: hash['success'] || hash[:success], message: hash['message'] || hash[:message])
66
+ end
67
+ end
68
+
69
+ # A calendar exposed by one of the supported providers.
70
+ Calendar = Struct.new(:provider, :id, :title, :time_zone, :color, :description, :original, keyword_init: true) do
71
+ def self.from_h(hash)
72
+ return nil if hash.nil?
73
+
74
+ new(
75
+ provider: hash['provider'] || hash[:provider],
76
+ id: hash['id'] || hash[:id],
77
+ title: hash['title'] || hash[:title],
78
+ time_zone: hash['timeZone'] || hash[:timeZone] || hash['time_zone'],
79
+ color: hash['color'] || hash[:color],
80
+ description: hash['description'] || hash[:description],
81
+ original: hash['original'] || hash[:original]
82
+ )
83
+ end
84
+ end
85
+
86
+ Attendee = Struct.new(:email, :status, :organizer, keyword_init: true) do
87
+ def self.from_h(hash)
88
+ return nil if hash.nil?
89
+
90
+ new(
91
+ email: hash['email'] || hash[:email],
92
+ status: hash['status'] || hash[:status],
93
+ organizer: hash['organizer'] || hash[:organizer]
94
+ )
95
+ end
96
+
97
+ def to_h
98
+ result = { 'email' => email }
99
+ result['status'] = status unless status.nil?
100
+ result['organizer'] = organizer unless organizer.nil?
101
+ result
102
+ end
103
+ end
104
+
105
+ # An event returned by the API. Field set mirrors the Node SDK exactly.
106
+ CalendarEvent = Struct.new(
107
+ :provider, :id, :calendar_id, :title, :description, :start, :end_time, :all_day,
108
+ :recurring_event_id, :color, :location, :attendees, :custom, :conference,
109
+ :conference_data, :availability, :privacy, :status, :last_modified, :link, :original,
110
+ keyword_init: true
111
+ ) do
112
+ def self.from_h(hash)
113
+ return nil if hash.nil?
114
+
115
+ attendees = hash['attendees'] || hash[:attendees]
116
+ attendees = attendees.map { |a| Attendee.from_h(a) } if attendees.is_a?(Array)
117
+
118
+ new(
119
+ provider: hash['provider'] || hash[:provider],
120
+ id: hash['id'] || hash[:id],
121
+ calendar_id: hash['calendarId'] || hash[:calendarId],
122
+ title: hash['title'] || hash[:title],
123
+ description: hash['description'] || hash[:description],
124
+ start: hash['start'] || hash[:start],
125
+ end_time: hash['end'] || hash[:end],
126
+ all_day: hash['allDay'] || hash[:allDay],
127
+ recurring_event_id: hash['recurringEventId'] || hash[:recurringEventId],
128
+ color: hash['color'] || hash[:color],
129
+ location: hash['location'] || hash[:location],
130
+ attendees: attendees,
131
+ custom: hash['custom'] || hash[:custom],
132
+ conference: hash['conference'] || hash[:conference],
133
+ conference_data: hash['conferenceData'] || hash[:conferenceData],
134
+ availability: hash['availability'] || hash[:availability],
135
+ privacy: hash['privacy'] || hash[:privacy],
136
+ status: hash['status'] || hash[:status],
137
+ last_modified: hash['lastModified'] || hash[:lastModified],
138
+ link: hash['link'] || hash[:link],
139
+ original: hash['original'] || hash[:original]
140
+ )
141
+ end
142
+ end
143
+
144
+ # iCal-style recurrence rule. `frequency` is one of "DAILY", "WEEKLY",
145
+ # "MONTHLY", "YEARLY".
146
+ RecurrenceRule = Struct.new(
147
+ :frequency, :interval, :count, :until, :by_day, :by_month_day, :by_month,
148
+ keyword_init: true
149
+ ) do
150
+ def to_wire
151
+ wire = { 'frequency' => frequency }
152
+ wire['interval'] = interval unless interval.nil?
153
+ wire['count'] = count unless count.nil?
154
+ wire['until'] = self[:until] unless self[:until].nil?
155
+ wire['byDay'] = by_day unless by_day.nil?
156
+ wire['byMonthDay'] = by_month_day unless by_month_day.nil?
157
+ wire['byMonth'] = by_month unless by_month.nil?
158
+ wire
159
+ end
160
+ end
161
+
162
+ # Paginated response from Events#list.
163
+ EventsListResponse = Struct.new(:events, :page_size, :next_page_token, keyword_init: true) do
164
+ def self.from_h(hash)
165
+ return new(events: [], page_size: nil, next_page_token: nil) if hash.nil?
166
+
167
+ events = (hash['events'] || hash[:events] || []).map { |e| CalendarEvent.from_h(e) }
168
+ new(
169
+ events: events,
170
+ page_size: hash['pageSize'] || hash[:pageSize],
171
+ next_page_token: hash['nextPageToken'] || hash[:nextPageToken]
172
+ )
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mobiscroll
4
+ module Connect
5
+ module Provider
6
+ GOOGLE = 'google'
7
+ MICROSOFT = 'microsoft'
8
+ APPLE = 'apple'
9
+ CALDAV = 'caldav'
10
+
11
+ ALL = [GOOGLE, MICROSOFT, APPLE, CALDAV].freeze
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Mobiscroll
6
+ module Connect
7
+ module Resources
8
+ class Auth
9
+ def initialize(config, api_client)
10
+ @config = config
11
+ @api_client = api_client
12
+ end
13
+
14
+ def generate_auth_url(user_id:, scope: nil, state: nil, providers: nil)
15
+ params = {
16
+ 'response_type' => 'code',
17
+ 'client_id' => @config.client_id,
18
+ 'redirect_uri' => @config.redirect_uri,
19
+ 'user_id' => user_id
20
+ }
21
+ params['scope'] = scope if scope
22
+ params['state'] = state if state
23
+
24
+ query = URI.encode_www_form(params)
25
+
26
+ if providers && !providers.empty?
27
+ provider_params = providers.map { |p| "providers=#{URI.encode_www_form_component(p)}" }.join('&')
28
+ query = "#{query}&#{provider_params}"
29
+ end
30
+
31
+ "#{@config.base_url}/oauth/authorize?#{query}"
32
+ end
33
+
34
+ def get_token(code)
35
+ form = {
36
+ 'grant_type' => 'authorization_code',
37
+ 'code' => code,
38
+ 'redirect_uri' => @config.redirect_uri
39
+ }
40
+ parsed = @api_client.post_form('/oauth/token', form)
41
+ tokens = TokenResponse.from_h(parsed)
42
+ @api_client.set_credentials(tokens)
43
+ tokens
44
+ end
45
+
46
+ def set_credentials(tokens)
47
+ @api_client.set_credentials(tokens)
48
+ end
49
+
50
+ def get_connection_status
51
+ begin
52
+ parsed = @api_client.get('/oauth/connection-status')
53
+ rescue NotFoundError
54
+ parsed = @api_client.get('/connection-status')
55
+ end
56
+ ConnectionStatus.from_h(parsed)
57
+ end
58
+
59
+ def disconnect(provider:, account: nil)
60
+ query = { 'provider' => provider }
61
+ query['account'] = account if account
62
+
63
+ begin
64
+ parsed = @api_client.post('/oauth/disconnect', query: query, body: {})
65
+ rescue NotFoundError
66
+ parsed = @api_client.post('/disconnect', query: query, body: {})
67
+ end
68
+ DisconnectResponse.from_h(parsed)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mobiscroll
4
+ module Connect
5
+ module Resources
6
+ class Calendars
7
+ def initialize(_config, api_client)
8
+ @api_client = api_client
9
+ end
10
+
11
+ def list
12
+ parsed = @api_client.get('/calendars')
13
+ return [] if parsed.nil?
14
+
15
+ Array(parsed).map { |c| Calendar.from_h(c) }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Mobiscroll
6
+ module Connect
7
+ module Resources
8
+ class Events
9
+ def initialize(_config, api_client)
10
+ @api_client = api_client
11
+ end
12
+
13
+ def list(start: nil, end: nil, calendar_ids: nil, page_size: nil,
14
+ next_page_token: nil, single_events: nil)
15
+ end_time = binding.local_variable_get(:end)
16
+ query = {}
17
+ query['start'] = start if start
18
+ query['end'] = end_time if end_time
19
+ query['pageSize'] = page_size if page_size
20
+ query['nextPageToken'] = next_page_token if next_page_token
21
+ query['singleEvents'] = single_events.to_s unless single_events.nil?
22
+
23
+ if calendar_ids && !calendar_ids.empty?
24
+ wire = calendar_ids.transform_keys(&:to_s)
25
+ query['calendarIds'] = JSON.generate(wire)
26
+ end
27
+
28
+ parsed = @api_client.get('/events', query: query)
29
+ EventsListResponse.from_h(parsed)
30
+ end
31
+
32
+ def create(provider:, calendar_id:, title:, start:, end:, **opts)
33
+ end_time = binding.local_variable_get(:end)
34
+ body = build_event_body(opts.merge(
35
+ provider: provider,
36
+ calendar_id: calendar_id,
37
+ title: title,
38
+ start: start,
39
+ end: end_time
40
+ ))
41
+ parsed = @api_client.post('/event', body: JSON.generate(body))
42
+ CalendarEvent.from_h(parsed)
43
+ end
44
+
45
+ def update(provider:, calendar_id:, event_id:, **opts)
46
+ body = build_event_body(opts.merge(
47
+ provider: provider,
48
+ calendar_id: calendar_id,
49
+ event_id: event_id
50
+ ))
51
+ parsed = @api_client.put('/event', body: JSON.generate(body))
52
+ CalendarEvent.from_h(parsed)
53
+ end
54
+
55
+ def delete(provider:, calendar_id:, event_id:, recurring_event_id: nil, delete_mode: nil)
56
+ query = {
57
+ 'provider' => provider,
58
+ 'calendarId' => calendar_id,
59
+ 'eventId' => event_id
60
+ }
61
+ query['recurringEventId'] = recurring_event_id if recurring_event_id
62
+ query['deleteMode'] = delete_mode if delete_mode
63
+
64
+ @api_client.delete('/event', query: query)
65
+ nil
66
+ end
67
+
68
+ private
69
+
70
+ def build_event_body(params)
71
+ body = {}
72
+ body['provider'] = params[:provider] if params.key?(:provider)
73
+ body['calendarId'] = params[:calendar_id] if params.key?(:calendar_id)
74
+ body['eventId'] = params[:event_id] if params.key?(:event_id)
75
+ body['title'] = params[:title] if params.key?(:title)
76
+ body['start'] = params[:start] if params.key?(:start)
77
+ body['end'] = params[:end] if params.key?(:end)
78
+ body['allDay'] = params[:all_day] if params.key?(:all_day)
79
+ body['description'] = params[:description] if params.key?(:description)
80
+ body['color'] = params[:color] if params.key?(:color)
81
+ body['location'] = params[:location] if params.key?(:location)
82
+ body['recurringEventId'] = params[:recurring_event_id] if params.key?(:recurring_event_id)
83
+ body['updateMode'] = params[:update_mode] if params.key?(:update_mode)
84
+ body['availability'] = params[:availability] if params.key?(:availability)
85
+ body['privacy'] = params[:privacy] if params.key?(:privacy)
86
+
87
+ if params.key?(:recurrence)
88
+ body['recurrence'] =
89
+ params[:recurrence].is_a?(RecurrenceRule) ? params[:recurrence].to_wire : params[:recurrence]
90
+ end
91
+
92
+ if params.key?(:attendees)
93
+ body['attendees'] = params[:attendees].map { |a| a.is_a?(Attendee) ? a.to_h : a }
94
+ end
95
+
96
+ body['conference'] = params[:conference] if params.key?(:conference)
97
+ body['custom'] = params[:custom] if params.key?(:custom)
98
+ body
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mobiscroll
4
+ module Connect
5
+ VERSION = '1.0.0'
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'connect/version'
4
+ require_relative 'connect/errors'
5
+ require_relative 'connect/provider'
6
+ require_relative 'connect/config'
7
+ require_relative 'connect/models'
8
+ require_relative 'connect/api_client'
9
+ require_relative 'connect/resources/auth'
10
+ require_relative 'connect/resources/calendars'
11
+ require_relative 'connect/resources/events'
12
+ require_relative 'connect/client'
13
+
14
+ module Mobiscroll
15
+ module Connect
16
+ end
17
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'mobiscroll/connect'
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/mobiscroll/connect/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'mobiscroll-connect'
7
+ spec.version = Mobiscroll::Connect::VERSION
8
+ spec.authors = ['Mobiscroll']
9
+ spec.email = ['support@mobiscroll.com']
10
+
11
+ spec.summary = 'Official Ruby SDK for the Mobiscroll Connect API.'
12
+ spec.description = 'Ruby client for the Mobiscroll Connect API. Unified ' \
13
+ 'OAuth, calendars, and events across Google Calendar, ' \
14
+ 'Microsoft Outlook, Apple Calendar, and CalDAV.'
15
+ spec.homepage = 'https://mobiscroll.com/connect'
16
+ spec.license = 'MIT'
17
+
18
+ spec.required_ruby_version = '>= 3.1'
19
+
20
+ spec.metadata = {
21
+ 'homepage_uri' => spec.homepage,
22
+ 'source_code_uri' => 'https://github.com/acidb/mobiscroll-connect-sdks',
23
+ 'bug_tracker_uri' => 'https://github.com/acidb/mobiscroll-connect-sdks/issues',
24
+ 'changelog_uri' => 'https://github.com/acidb/mobiscroll-connect-sdks/blob/main/sdks/ruby/CHANGELOG.md',
25
+ 'rubygems_mfa_required' => 'true'
26
+ }
27
+
28
+ spec.files = Dir[
29
+ 'lib/**/*.rb',
30
+ 'README.md',
31
+ 'LICENSE',
32
+ 'mobiscroll-connect.gemspec'
33
+ ]
34
+ spec.require_paths = ['lib']
35
+
36
+ spec.add_dependency 'base64', '~> 0.2'
37
+ spec.add_dependency 'faraday', '~> 2.9'
38
+
39
+ spec.add_development_dependency 'rspec', '~> 3.13'
40
+ spec.add_development_dependency 'rubocop', '~> 1.65'
41
+ spec.add_development_dependency 'webmock', '~> 3.23'
42
+ end
metadata ADDED
@@ -0,0 +1,134 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mobiscroll-connect
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Mobiscroll
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.9'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.9'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.13'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.13'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.65'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.65'
69
+ - !ruby/object:Gem::Dependency
70
+ name: webmock
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.23'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.23'
83
+ description: Ruby client for the Mobiscroll Connect API. Unified OAuth, calendars,
84
+ and events across Google Calendar, Microsoft Outlook, Apple Calendar, and CalDAV.
85
+ email:
86
+ - support@mobiscroll.com
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - LICENSE
92
+ - README.md
93
+ - lib/mobiscroll-connect.rb
94
+ - lib/mobiscroll/connect.rb
95
+ - lib/mobiscroll/connect/api_client.rb
96
+ - lib/mobiscroll/connect/client.rb
97
+ - lib/mobiscroll/connect/config.rb
98
+ - lib/mobiscroll/connect/errors.rb
99
+ - lib/mobiscroll/connect/models.rb
100
+ - lib/mobiscroll/connect/provider.rb
101
+ - lib/mobiscroll/connect/resources/auth.rb
102
+ - lib/mobiscroll/connect/resources/calendars.rb
103
+ - lib/mobiscroll/connect/resources/events.rb
104
+ - lib/mobiscroll/connect/version.rb
105
+ - mobiscroll-connect.gemspec
106
+ homepage: https://mobiscroll.com/connect
107
+ licenses:
108
+ - MIT
109
+ metadata:
110
+ homepage_uri: https://mobiscroll.com/connect
111
+ source_code_uri: https://github.com/acidb/mobiscroll-connect-sdks
112
+ bug_tracker_uri: https://github.com/acidb/mobiscroll-connect-sdks/issues
113
+ changelog_uri: https://github.com/acidb/mobiscroll-connect-sdks/blob/main/sdks/ruby/CHANGELOG.md
114
+ rubygems_mfa_required: 'true'
115
+ post_install_message:
116
+ rdoc_options: []
117
+ require_paths:
118
+ - lib
119
+ required_ruby_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '3.1'
124
+ required_rubygems_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '0'
129
+ requirements: []
130
+ rubygems_version: 3.5.3
131
+ signing_key:
132
+ specification_version: 4
133
+ summary: Official Ruby SDK for the Mobiscroll Connect API.
134
+ test_files: []