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 +7 -0
- data/LICENSE +21 -0
- data/README.md +222 -0
- data/lib/mobiscroll/connect/api_client.rb +192 -0
- data/lib/mobiscroll/connect/client.rb +38 -0
- data/lib/mobiscroll/connect/config.rb +28 -0
- data/lib/mobiscroll/connect/errors.rb +85 -0
- data/lib/mobiscroll/connect/models.rb +176 -0
- data/lib/mobiscroll/connect/provider.rb +14 -0
- data/lib/mobiscroll/connect/resources/auth.rb +73 -0
- data/lib/mobiscroll/connect/resources/calendars.rb +20 -0
- data/lib/mobiscroll/connect/resources/events.rb +103 -0
- data/lib/mobiscroll/connect/version.rb +7 -0
- data/lib/mobiscroll/connect.rb +17 -0
- data/lib/mobiscroll-connect.rb +3 -0
- data/mobiscroll-connect.gemspec +42 -0
- metadata +134 -0
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,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,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,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: []
|