async-caldav 1.2.3 → 1.3.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/lib/async/caldav/client/addressbook.rb +21 -9
- data/lib/async/caldav/client/calendar.rb +39 -15
- data/lib/async/caldav/client.rb +423 -371
- data/lib/async/caldav/forward_auth.rb +61 -54
- data/lib/async/caldav/handlers/delete.rb +28 -30
- data/lib/async/caldav/handlers/get.rb +45 -47
- data/lib/async/caldav/handlers/head.rb +16 -18
- data/lib/async/caldav/handlers/mkcol.rb +64 -68
- data/lib/async/caldav/handlers/move.rb +99 -90
- data/lib/async/caldav/handlers/options.rb +15 -17
- data/lib/async/caldav/handlers/propfind.rb +140 -139
- data/lib/async/caldav/handlers/proppatch.rb +95 -90
- data/lib/async/caldav/handlers/put.rb +139 -119
- data/lib/async/caldav/handlers/report.rb +192 -161
- data/lib/async/caldav/server.rb +1029 -994
- data/lib/async/caldav/storage/filesystem.rb +247 -220
- data/lib/async/caldav/storage/mock.rb +254 -246
- data/lib/async/caldav/version.rb +1 -1
- metadata +19 -5
data/lib/async/caldav/client.rb
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "scampi"
|
|
5
|
-
require "async/caldav"
|
|
6
3
|
require "net/http"
|
|
7
4
|
require "uri"
|
|
8
5
|
require "rexml/document"
|
|
@@ -32,17 +29,21 @@ module Async
|
|
|
32
29
|
|
|
33
30
|
def self.open(base_url, **opts)
|
|
34
31
|
client = new(base_url, **opts)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
if block_given?
|
|
33
|
+
begin
|
|
34
|
+
yield client
|
|
35
|
+
ensure
|
|
36
|
+
client.close
|
|
37
|
+
end
|
|
38
|
+
else
|
|
39
|
+
client
|
|
41
40
|
end
|
|
42
41
|
end
|
|
43
42
|
|
|
44
43
|
def close
|
|
45
|
-
|
|
44
|
+
if @http&.started?
|
|
45
|
+
@http&.finish
|
|
46
|
+
end
|
|
46
47
|
@http = nil
|
|
47
48
|
end
|
|
48
49
|
|
|
@@ -57,7 +58,9 @@ module Async
|
|
|
57
58
|
def calendars
|
|
58
59
|
path = "/calendars/#{@user}/"
|
|
59
60
|
status, _, body = request('PROPFIND', path, headers: { 'Depth' => '1' })
|
|
60
|
-
|
|
61
|
+
unless status == 207
|
|
62
|
+
raise Error, "PROPFIND failed: #{status}"
|
|
63
|
+
end
|
|
61
64
|
|
|
62
65
|
parse_collections(body, path, type: :calendar)
|
|
63
66
|
end
|
|
@@ -65,7 +68,9 @@ module Async
|
|
|
65
68
|
def addressbooks
|
|
66
69
|
path = "/addressbooks/#{@user}/"
|
|
67
70
|
status, _, body = request('PROPFIND', path, headers: { 'Depth' => '1' })
|
|
68
|
-
|
|
71
|
+
unless status == 207
|
|
72
|
+
raise Error, "PROPFIND failed: #{status}"
|
|
73
|
+
end
|
|
69
74
|
|
|
70
75
|
parse_collections(body, path, type: :addressbook)
|
|
71
76
|
end
|
|
@@ -87,14 +92,20 @@ module Async
|
|
|
87
92
|
x.tag!("c:mkcalendar", "xmlns:d" => "DAV:", "xmlns:c" => "urn:ietf:params:xml:ns:caldav") do
|
|
88
93
|
x.tag!("d:set") do
|
|
89
94
|
x.tag!("d:prop") do
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
if displayname || name
|
|
96
|
+
x.tag!("d:displayname", displayname || name)
|
|
97
|
+
end
|
|
98
|
+
if description
|
|
99
|
+
x.tag!("c:calendar-description", description)
|
|
100
|
+
end
|
|
92
101
|
end
|
|
93
102
|
end
|
|
94
103
|
end
|
|
95
104
|
|
|
96
105
|
status, = request('MKCALENDAR', path, body: x.target!, headers: { 'Content-Type' => 'text/xml' })
|
|
97
|
-
|
|
106
|
+
unless status == 201
|
|
107
|
+
raise Error, "MKCALENDAR failed: #{status}"
|
|
108
|
+
end
|
|
98
109
|
|
|
99
110
|
Calendar.new(self, path, displayname: displayname || name, description: description, color: color)
|
|
100
111
|
end
|
|
@@ -113,7 +124,9 @@ module Async
|
|
|
113
124
|
end
|
|
114
125
|
|
|
115
126
|
status, = request('MKCOL', path, body: x.target!, headers: { 'Content-Type' => 'text/xml' })
|
|
116
|
-
|
|
127
|
+
unless status == 201
|
|
128
|
+
raise Error, "MKCOL failed: #{status}"
|
|
129
|
+
end
|
|
117
130
|
|
|
118
131
|
Addressbook.new(self, path, displayname: displayname || name)
|
|
119
132
|
end
|
|
@@ -122,14 +135,21 @@ module Async
|
|
|
122
135
|
|
|
123
136
|
def request(method, path, body: nil, headers: {})
|
|
124
137
|
http = connect
|
|
125
|
-
req = build_request(
|
|
138
|
+
req = build_request(
|
|
139
|
+
method,
|
|
140
|
+
path,
|
|
141
|
+
body,
|
|
142
|
+
headers,
|
|
143
|
+
)
|
|
126
144
|
response = http.request(req)
|
|
127
145
|
|
|
128
146
|
status = response.code.to_i
|
|
129
147
|
resp_headers = {}
|
|
130
148
|
response.each_header { |k, v| resp_headers[k] = v }
|
|
131
149
|
|
|
132
|
-
|
|
150
|
+
if status == 401
|
|
151
|
+
raise Unauthorized, "401 Unauthorized"
|
|
152
|
+
end
|
|
133
153
|
|
|
134
154
|
[status, resp_headers, response.body || '']
|
|
135
155
|
end
|
|
@@ -137,445 +157,477 @@ module Async
|
|
|
137
157
|
# --- Response parsing (used by Calendar/Addressbook) ---
|
|
138
158
|
|
|
139
159
|
def parse_multistatus_items(xml, data_tag:)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
160
|
+
[].tap do |items|
|
|
161
|
+
xml.scan(/<[^>]*response[^>]*>(.*?)<\/[^>]*response>/m).each do |match|
|
|
162
|
+
resp = match[0]
|
|
163
|
+
href = resp.match(/<[^>]*href[^>]*>([^<]+)</)[1]&.strip rescue nil
|
|
164
|
+
unless href
|
|
165
|
+
next
|
|
166
|
+
end
|
|
145
167
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
168
|
+
etag = resp.match(/<[^>]*getetag[^>]*>([^<]+)</)[1]&.strip rescue nil
|
|
169
|
+
data = resp.match(/<[^>]*#{data_tag}[^>]*>(.*?)<\/[^>]*#{data_tag}>/m)
|
|
170
|
+
if data
|
|
171
|
+
body = data[1].strip
|
|
172
|
+
else
|
|
173
|
+
body = nil
|
|
174
|
+
end
|
|
149
175
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
176
|
+
# Unescape XML entities in the body
|
|
177
|
+
if body
|
|
178
|
+
body = body.gsub('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"')
|
|
179
|
+
end
|
|
154
180
|
|
|
155
|
-
|
|
181
|
+
items << { path: href, body: body, etag: etag }
|
|
182
|
+
end
|
|
156
183
|
end
|
|
157
|
-
items
|
|
158
184
|
end
|
|
159
185
|
|
|
160
186
|
def parse_sync_items(xml)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
187
|
+
[].tap do |items|
|
|
188
|
+
xml.scan(/<[^>]*response[^>]*>(.*?)<\/[^>]*response>/m).each do |match|
|
|
189
|
+
resp = match[0]
|
|
190
|
+
href = resp.match(/<[^>]*href[^>]*>([^<]+)</)[1]&.strip rescue nil
|
|
191
|
+
unless href
|
|
192
|
+
next
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
if resp.include?('404')
|
|
196
|
+
items << { path: href, status: 404 }
|
|
197
|
+
else
|
|
198
|
+
etag = resp.match(/<[^>]*getetag[^>]*>([^<]+)</)[1]&.strip rescue nil
|
|
199
|
+
items << { path: href, status: 200, etag: etag }
|
|
200
|
+
end
|
|
172
201
|
end
|
|
173
202
|
end
|
|
174
|
-
items
|
|
175
203
|
end
|
|
176
204
|
|
|
177
205
|
def parse_collection_props(xml)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
206
|
+
{}.tap do |props|
|
|
207
|
+
props[:displayname] = Protocol::Caldav::Xml.extract_value(xml, 'displayname')
|
|
208
|
+
props[:description] = Protocol::Caldav::Xml.extract_value(xml, 'calendar-description')
|
|
209
|
+
props[:color] = Protocol::Caldav::Xml.extract_value(xml, 'calendar-color')
|
|
210
|
+
ctag = Protocol::Caldav::Xml.extract_value(xml, 'getctag')
|
|
211
|
+
if ctag
|
|
212
|
+
props[:ctag] = ctag
|
|
213
|
+
end
|
|
214
|
+
end
|
|
185
215
|
end
|
|
186
216
|
|
|
187
217
|
private
|
|
188
218
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
def build_request(method, path, body, headers)
|
|
199
|
-
klass = case method
|
|
200
|
-
when 'GET' then Net::HTTP::Get
|
|
201
|
-
when 'PUT' then Net::HTTP::Put
|
|
202
|
-
when 'DELETE' then Net::HTTP::Delete
|
|
203
|
-
when 'HEAD' then Net::HTTP::Head
|
|
204
|
-
when 'OPTIONS' then Net::HTTP::Options
|
|
205
|
-
when 'PROPFIND' then propfind_class
|
|
206
|
-
when 'PROPPATCH' then proppatch_class
|
|
207
|
-
when 'MKCOL' then mkcol_class
|
|
208
|
-
when 'MKCALENDAR' then mkcalendar_class
|
|
209
|
-
when 'REPORT' then report_class
|
|
210
|
-
when 'MOVE' then move_class
|
|
211
|
-
else raise Error, "Unknown method: #{method}"
|
|
219
|
+
def connect
|
|
220
|
+
if @http&.started?
|
|
221
|
+
@http
|
|
222
|
+
else
|
|
223
|
+
@http = Net::HTTP.new(@uri.host, @uri.port)
|
|
224
|
+
@http.use_ssl = (@uri.scheme == 'https')
|
|
225
|
+
@http.start
|
|
226
|
+
@http
|
|
227
|
+
end
|
|
212
228
|
end
|
|
213
229
|
|
|
214
|
-
|
|
215
|
-
|
|
230
|
+
def build_request(method, path, body, headers)
|
|
231
|
+
case method
|
|
232
|
+
when 'GET'
|
|
233
|
+
klass = Net::HTTP::Get
|
|
234
|
+
when 'PUT'
|
|
235
|
+
klass = Net::HTTP::Put
|
|
236
|
+
when 'DELETE'
|
|
237
|
+
klass = Net::HTTP::Delete
|
|
238
|
+
when 'HEAD'
|
|
239
|
+
klass = Net::HTTP::Head
|
|
240
|
+
when 'OPTIONS'
|
|
241
|
+
klass = Net::HTTP::Options
|
|
242
|
+
when 'PROPFIND'
|
|
243
|
+
klass = propfind_class
|
|
244
|
+
when 'PROPPATCH'
|
|
245
|
+
klass = proppatch_class
|
|
246
|
+
when 'MKCOL'
|
|
247
|
+
klass = mkcol_class
|
|
248
|
+
when 'MKCALENDAR'
|
|
249
|
+
klass = mkcalendar_class
|
|
250
|
+
when 'REPORT'
|
|
251
|
+
klass = report_class
|
|
252
|
+
when 'MOVE'
|
|
253
|
+
klass = move_class
|
|
254
|
+
else
|
|
255
|
+
klass = raise Error, "Unknown method: #{method}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
req = klass.new(path)
|
|
259
|
+
if body
|
|
260
|
+
req.body = body
|
|
261
|
+
end
|
|
216
262
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
263
|
+
# Auth
|
|
264
|
+
if @password
|
|
265
|
+
req.basic_auth(@user, @password)
|
|
266
|
+
else
|
|
267
|
+
req['Remote-User'] = @user
|
|
268
|
+
end
|
|
223
269
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
270
|
+
# Default + extra + per-request headers
|
|
271
|
+
@extra_headers.each { |k, v| req[k] = v }
|
|
272
|
+
headers.each { |k, v| req[k] = v }
|
|
227
273
|
|
|
228
|
-
|
|
229
|
-
|
|
274
|
+
req
|
|
275
|
+
end
|
|
230
276
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
277
|
+
def parse_collections(xml, parent_path, type:)
|
|
278
|
+
[].tap do |collections|
|
|
279
|
+
xml.scan(/<[^>]*response[^>]*>(.*?)<\/[^>]*response>/m).each do |match|
|
|
280
|
+
resp = match[0]
|
|
281
|
+
href = resp.match(/<[^>]*href[^>]*>([^<]+)</)[1]&.strip rescue nil
|
|
282
|
+
unless href
|
|
283
|
+
next
|
|
284
|
+
end
|
|
285
|
+
if href == parent_path
|
|
286
|
+
next
|
|
287
|
+
end # skip the parent itself
|
|
288
|
+
|
|
289
|
+
# Check resource type
|
|
290
|
+
is_calendar = resp.include?('calendar/')
|
|
291
|
+
is_addressbook = resp.include?('addressbook/')
|
|
292
|
+
|
|
293
|
+
if type == :calendar && is_calendar
|
|
294
|
+
displayname = Protocol::Caldav::Xml.extract_value(resp, 'displayname')
|
|
295
|
+
description = Protocol::Caldav::Xml.extract_value(resp, 'calendar-description')
|
|
296
|
+
color = Protocol::Caldav::Xml.extract_value(resp, 'calendar-color')
|
|
297
|
+
collections << Calendar.new(self, href, displayname: displayname, description: description, color: color)
|
|
298
|
+
elsif type == :addressbook && is_addressbook
|
|
299
|
+
displayname = Protocol::Caldav::Xml.extract_value(resp, 'displayname')
|
|
300
|
+
collections << Addressbook.new(self, href, displayname: displayname)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
251
303
|
end
|
|
252
304
|
end
|
|
253
|
-
collections
|
|
254
|
-
end
|
|
255
305
|
|
|
256
306
|
# Custom HTTP method classes for WebDAV
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
307
|
+
def propfind_class
|
|
308
|
+
@propfind_class ||= Class.new(Net::HTTPRequest) do
|
|
309
|
+
const_set(:METHOD, 'PROPFIND')
|
|
310
|
+
const_set(:REQUEST_HAS_BODY, true)
|
|
311
|
+
const_set(:RESPONSE_HAS_BODY, true)
|
|
312
|
+
end
|
|
262
313
|
end
|
|
263
|
-
end
|
|
264
314
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
315
|
+
def proppatch_class
|
|
316
|
+
@proppatch_class ||= Class.new(Net::HTTPRequest) do
|
|
317
|
+
const_set(:METHOD, 'PROPPATCH')
|
|
318
|
+
const_set(:REQUEST_HAS_BODY, true)
|
|
319
|
+
const_set(:RESPONSE_HAS_BODY, true)
|
|
320
|
+
end
|
|
270
321
|
end
|
|
271
|
-
end
|
|
272
322
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
323
|
+
def mkcol_class
|
|
324
|
+
@mkcol_class ||= Class.new(Net::HTTPRequest) do
|
|
325
|
+
const_set(:METHOD, 'MKCOL')
|
|
326
|
+
const_set(:REQUEST_HAS_BODY, true)
|
|
327
|
+
const_set(:RESPONSE_HAS_BODY, true)
|
|
328
|
+
end
|
|
278
329
|
end
|
|
279
|
-
end
|
|
280
330
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
331
|
+
def mkcalendar_class
|
|
332
|
+
@mkcalendar_class ||= Class.new(Net::HTTPRequest) do
|
|
333
|
+
const_set(:METHOD, 'MKCALENDAR')
|
|
334
|
+
const_set(:REQUEST_HAS_BODY, true)
|
|
335
|
+
const_set(:RESPONSE_HAS_BODY, true)
|
|
336
|
+
end
|
|
286
337
|
end
|
|
287
|
-
end
|
|
288
338
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
339
|
+
def report_class
|
|
340
|
+
@report_class ||= Class.new(Net::HTTPRequest) do
|
|
341
|
+
const_set(:METHOD, 'REPORT')
|
|
342
|
+
const_set(:REQUEST_HAS_BODY, true)
|
|
343
|
+
const_set(:RESPONSE_HAS_BODY, true)
|
|
344
|
+
end
|
|
294
345
|
end
|
|
295
|
-
end
|
|
296
346
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
347
|
+
def move_class
|
|
348
|
+
@move_class ||= Class.new(Net::HTTPRequest) do
|
|
349
|
+
const_set(:METHOD, 'MOVE')
|
|
350
|
+
const_set(:REQUEST_HAS_BODY, false)
|
|
351
|
+
const_set(:RESPONSE_HAS_BODY, true)
|
|
352
|
+
end
|
|
302
353
|
end
|
|
303
|
-
end
|
|
304
354
|
end
|
|
305
355
|
end
|
|
306
356
|
end
|
|
307
357
|
|
|
308
358
|
|
|
309
|
-
|
|
310
|
-
# Mock transport that routes requests through the Server directly
|
|
311
|
-
class MockTransport
|
|
312
|
-
def initialize
|
|
313
|
-
@storage = Async::Caldav::Storage::Mock.new
|
|
314
|
-
@server = Async::Caldav::Server.new(storage: @storage)
|
|
315
|
-
# Pre-create parent collections
|
|
316
|
-
%w[/calendars/ /calendars/admin/ /addressbooks/ /addressbooks/admin/].each do |p|
|
|
317
|
-
@storage.create_collection(p)
|
|
318
|
-
end
|
|
319
|
-
end
|
|
359
|
+
__END__
|
|
320
360
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
rack_key = "HTTP_#{k.upcase.tr('-', '_')}"
|
|
332
|
-
env[rack_key] = v
|
|
333
|
-
end
|
|
334
|
-
env['CONTENT_TYPE'] = headers['Content-Type'] if headers['Content-Type']
|
|
335
|
-
@server.call(env)
|
|
361
|
+
require_relative "../caldav"
|
|
362
|
+
|
|
363
|
+
# Mock transport that routes requests through the Server directly
|
|
364
|
+
class MockTransport
|
|
365
|
+
def initialize
|
|
366
|
+
@storage = Async::Caldav::Storage::Mock.new
|
|
367
|
+
@server = Async::Caldav::Server.new(storage: @storage)
|
|
368
|
+
# Pre-create parent collections
|
|
369
|
+
%w[/calendars/ /calendars/admin/ /addressbooks/ /addressbooks/admin/].each do |p|
|
|
370
|
+
@storage.create_collection(p)
|
|
336
371
|
end
|
|
337
372
|
end
|
|
338
373
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
374
|
+
attr_reader :storage
|
|
375
|
+
|
|
376
|
+
def request(method, path, body, headers, user)
|
|
377
|
+
env = {
|
|
378
|
+
'REQUEST_METHOD' => method,
|
|
379
|
+
'PATH_INFO' => path,
|
|
380
|
+
'rack.input' => StringIO.new(body || ''),
|
|
381
|
+
'dav.user' => user
|
|
382
|
+
}
|
|
383
|
+
headers.each do |k, v|
|
|
384
|
+
rack_key = "HTTP_#{k.upcase.tr('-', '_')}"
|
|
385
|
+
env[rack_key] = v
|
|
345
386
|
end
|
|
387
|
+
env['CONTENT_TYPE'] = headers['Content-Type'] if headers['Content-Type']
|
|
388
|
+
@server.call(env)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
346
391
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
392
|
+
# Client subclass that uses MockTransport instead of Net::HTTP
|
|
393
|
+
class TestClient < Async::Caldav::Client
|
|
394
|
+
def initialize(transport, user:)
|
|
395
|
+
@transport = transport
|
|
396
|
+
@user = user
|
|
397
|
+
@extra_headers = {}
|
|
398
|
+
end
|
|
352
399
|
|
|
353
|
-
|
|
400
|
+
def request(method, path, body: nil, headers: {})
|
|
401
|
+
status, resp_headers, resp_body = @transport.request(method, path, body, headers, @user)
|
|
402
|
+
body_str = resp_body.is_a?(Array) ? resp_body.join : resp_body
|
|
403
|
+
[status, resp_headers, body_str]
|
|
354
404
|
end
|
|
355
405
|
|
|
356
|
-
|
|
406
|
+
def close; end
|
|
407
|
+
end
|
|
357
408
|
|
|
358
|
-
|
|
359
|
-
def make_client
|
|
360
|
-
transport = MockTransport.new
|
|
361
|
-
[TestClient.new(transport, user: 'admin'), transport]
|
|
362
|
-
end
|
|
409
|
+
require 'stringio'
|
|
363
410
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
411
|
+
describe "Async::Caldav::Client" do
|
|
412
|
+
def make_client
|
|
413
|
+
transport = MockTransport.new
|
|
414
|
+
[TestClient.new(transport, user: 'admin'), transport]
|
|
415
|
+
end
|
|
368
416
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
cal.path.should.equal '/calendars/admin/work/'
|
|
374
|
-
cal.displayname.should.equal 'Work'
|
|
375
|
-
end
|
|
417
|
+
it "principal returns user path" do
|
|
418
|
+
client, = make_client
|
|
419
|
+
client.principal.should.include '/admin/'
|
|
420
|
+
end
|
|
376
421
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
end
|
|
422
|
+
it "create_calendar returns Calendar" do
|
|
423
|
+
client, = make_client
|
|
424
|
+
cal = client.create_calendar('work', displayname: 'Work')
|
|
425
|
+
cal.should.be.instance_of Async::Caldav::Client::Calendar
|
|
426
|
+
cal.path.should.equal '/calendars/admin/work/'
|
|
427
|
+
cal.displayname.should.equal 'Work'
|
|
428
|
+
end
|
|
385
429
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
430
|
+
it "calendars returns list of Calendar objects" do
|
|
431
|
+
client, = make_client
|
|
432
|
+
client.create_calendar('cal1', displayname: 'Cal 1')
|
|
433
|
+
client.create_calendar('cal2', displayname: 'Cal 2')
|
|
434
|
+
cals = client.calendars
|
|
435
|
+
cals.length.should.equal 2
|
|
436
|
+
cals.all? { |c| c.is_a?(Async::Caldav::Client::Calendar) }.should.equal true
|
|
437
|
+
end
|
|
392
438
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
end
|
|
439
|
+
it "create_addressbook returns Addressbook" do
|
|
440
|
+
client, = make_client
|
|
441
|
+
ab = client.create_addressbook('contacts', displayname: 'Contacts')
|
|
442
|
+
ab.should.be.instance_of Async::Caldav::Client::Addressbook
|
|
443
|
+
ab.path.should.equal '/addressbooks/admin/contacts/'
|
|
444
|
+
end
|
|
400
445
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
446
|
+
it "addressbooks returns list of Addressbook objects" do
|
|
447
|
+
client, = make_client
|
|
448
|
+
client.create_addressbook('ab1', displayname: 'AB 1')
|
|
449
|
+
abs = client.addressbooks
|
|
450
|
+
abs.length.should.equal 1
|
|
451
|
+
abs[0].displayname.should.equal 'AB 1'
|
|
452
|
+
end
|
|
406
453
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
# We can't use TestClient.open directly since it calls new with base_url
|
|
412
|
-
# but this tests the block pattern
|
|
413
|
-
yielded = true
|
|
414
|
-
end
|
|
415
|
-
yielded.should.equal true
|
|
416
|
-
end
|
|
454
|
+
it "calendar returns Calendar for name" do
|
|
455
|
+
client, = make_client
|
|
456
|
+
cal = client.calendar('work')
|
|
457
|
+
cal.path.should.equal '/calendars/admin/work/'
|
|
417
458
|
end
|
|
418
459
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
460
|
+
it "open with block yields client and closes" do
|
|
461
|
+
transport = MockTransport.new
|
|
462
|
+
yielded = nil
|
|
463
|
+
TestClient.open('http://localhost', user: 'admin') do |c|
|
|
464
|
+
# We can't use TestClient.open directly since it calls new with base_url
|
|
465
|
+
# but this tests the block pattern
|
|
466
|
+
yielded = true
|
|
423
467
|
end
|
|
468
|
+
yielded.should.equal true
|
|
469
|
+
end
|
|
470
|
+
end
|
|
424
471
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
result[:etag].should.not.be.nil
|
|
431
|
-
end
|
|
472
|
+
describe "Async::Caldav::Client::Calendar" do
|
|
473
|
+
def make_client
|
|
474
|
+
transport = MockTransport.new
|
|
475
|
+
[TestClient.new(transport, user: 'admin'), transport]
|
|
476
|
+
end
|
|
432
477
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
end
|
|
478
|
+
it "put_event creates event and returns etag" do
|
|
479
|
+
client, = make_client
|
|
480
|
+
cal = client.create_calendar('work')
|
|
481
|
+
result = cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nEND:VCALENDAR")
|
|
482
|
+
result[:status].should.equal 201
|
|
483
|
+
result[:etag].should.not.be.nil
|
|
484
|
+
end
|
|
441
485
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
486
|
+
it "get_event retrieves event body and etag" do
|
|
487
|
+
client, = make_client
|
|
488
|
+
cal = client.create_calendar('work')
|
|
489
|
+
cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nSUMMARY:Test\r\nEND:VCALENDAR")
|
|
490
|
+
result = cal.get_event('ev.ics')
|
|
491
|
+
result[:body].should.include 'SUMMARY:Test'
|
|
492
|
+
result[:etag].should.not.be.nil
|
|
493
|
+
end
|
|
450
494
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
495
|
+
it "events returns items from REPORT" do
|
|
496
|
+
client, = make_client
|
|
497
|
+
cal = client.create_calendar('work')
|
|
498
|
+
cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nSUMMARY:Meeting\r\nEND:VCALENDAR")
|
|
499
|
+
items = cal.events
|
|
500
|
+
items.length.should.equal 1
|
|
501
|
+
items[0][:path].should.include 'ev.ics'
|
|
502
|
+
end
|
|
458
503
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
504
|
+
it "delete_event removes event" do
|
|
505
|
+
client, = make_client
|
|
506
|
+
cal = client.create_calendar('work')
|
|
507
|
+
cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nEND:VCALENDAR")
|
|
508
|
+
cal.delete_event('ev.ics').should.equal true
|
|
509
|
+
lambda { cal.get_event('ev.ics') }.should.raise Async::Caldav::Client::NotFound
|
|
510
|
+
end
|
|
464
511
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
512
|
+
it "delete removes collection" do
|
|
513
|
+
client, = make_client
|
|
514
|
+
cal = client.create_calendar('work')
|
|
515
|
+
cal.delete.should.equal true
|
|
516
|
+
end
|
|
470
517
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
518
|
+
it "put_event with if_match sends precondition" do
|
|
519
|
+
client, = make_client
|
|
520
|
+
cal = client.create_calendar('work')
|
|
521
|
+
result = cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nEND:VCALENDAR")
|
|
522
|
+
etag = result[:etag]
|
|
474
523
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}.should.raise Async::Caldav::Client::PreconditionFailed
|
|
479
|
-
end
|
|
524
|
+
# Update with correct etag works
|
|
525
|
+
result2 = cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nSUMMARY:V2\r\nEND:VCALENDAR", if_match: etag)
|
|
526
|
+
result2[:status].should.equal 204
|
|
480
527
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
cal
|
|
484
|
-
|
|
528
|
+
# Update with wrong etag fails
|
|
529
|
+
lambda {
|
|
530
|
+
cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nSUMMARY:V3\r\nEND:VCALENDAR", if_match: '"wrong"')
|
|
531
|
+
}.should.raise Async::Caldav::Client::PreconditionFailed
|
|
532
|
+
end
|
|
485
533
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
534
|
+
it "put_event with if_none_match * prevents overwrite" do
|
|
535
|
+
client, = make_client
|
|
536
|
+
cal = client.create_calendar('work')
|
|
537
|
+
cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nEND:VCALENDAR")
|
|
490
538
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
539
|
+
lambda {
|
|
540
|
+
cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nEND:VCALENDAR", if_none_match: '*')
|
|
541
|
+
}.should.raise Async::Caldav::Client::PreconditionFailed
|
|
542
|
+
end
|
|
495
543
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
544
|
+
it "put_event with duplicate UID raises Conflict" do
|
|
545
|
+
client, = make_client
|
|
546
|
+
cal = client.create_calendar('work')
|
|
547
|
+
cal.put_event('a.ics', "BEGIN:VCALENDAR\r\nUID:same\r\nEND:VCALENDAR")
|
|
500
548
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
549
|
+
lambda {
|
|
550
|
+
cal.put_event('b.ics', "BEGIN:VCALENDAR\r\nUID:same\r\nEND:VCALENDAR")
|
|
551
|
+
}.should.raise Async::Caldav::Client::Conflict
|
|
552
|
+
end
|
|
505
553
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
554
|
+
it "sync returns items and token" do
|
|
555
|
+
client, = make_client
|
|
556
|
+
cal = client.create_calendar('work')
|
|
557
|
+
cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nEND:VCALENDAR")
|
|
510
558
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
559
|
+
items, token = cal.sync
|
|
560
|
+
items.length.should.equal 1
|
|
561
|
+
token.should.not.be.nil
|
|
562
|
+
end
|
|
515
563
|
|
|
516
|
-
|
|
564
|
+
it "sync with token returns incremental changes" do
|
|
565
|
+
client, = make_client
|
|
566
|
+
cal = client.create_calendar('work')
|
|
567
|
+
cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nEND:VCALENDAR")
|
|
517
568
|
|
|
518
|
-
|
|
519
|
-
items, token2 = cal.sync(token: token)
|
|
520
|
-
items.length.should.equal 0
|
|
569
|
+
_, token = cal.sync
|
|
521
570
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
items.length.should.equal 1
|
|
526
|
-
items[0][:path].should.include 'ev2.ics'
|
|
527
|
-
end
|
|
571
|
+
# No changes
|
|
572
|
+
items, token2 = cal.sync(token: token)
|
|
573
|
+
items.length.should.equal 0
|
|
528
574
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
end
|
|
575
|
+
# Add item
|
|
576
|
+
cal.put_event('ev2.ics', "BEGIN:VCALENDAR\r\nUID:2\r\nEND:VCALENDAR")
|
|
577
|
+
items, = cal.sync(token: token2)
|
|
578
|
+
items.length.should.equal 1
|
|
579
|
+
items[0][:path].should.include 'ev2.ics'
|
|
535
580
|
end
|
|
536
581
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
582
|
+
it "proppatch updates properties" do
|
|
583
|
+
client, = make_client
|
|
584
|
+
cal = client.create_calendar('work', displayname: 'Old')
|
|
585
|
+
cal.proppatch(displayname: 'New')
|
|
586
|
+
cal.displayname.should.equal 'New'
|
|
587
|
+
end
|
|
588
|
+
end
|
|
542
589
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
result[:etag].should.not.be.nil
|
|
549
|
-
end
|
|
590
|
+
describe "Async::Caldav::Client::Addressbook" do
|
|
591
|
+
def make_client
|
|
592
|
+
transport = MockTransport.new
|
|
593
|
+
[TestClient.new(transport, user: 'admin'), transport]
|
|
594
|
+
end
|
|
550
595
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
596
|
+
it "put_contact creates contact and returns etag" do
|
|
597
|
+
client, = make_client
|
|
598
|
+
ab = client.create_addressbook('contacts')
|
|
599
|
+
result = ab.put_contact('alice.vcf', "BEGIN:VCARD\r\nUID:1\r\nFN:Alice\r\nEND:VCARD")
|
|
600
|
+
result[:status].should.equal 201
|
|
601
|
+
result[:etag].should.not.be.nil
|
|
602
|
+
end
|
|
558
603
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
604
|
+
it "get_contact retrieves contact" do
|
|
605
|
+
client, = make_client
|
|
606
|
+
ab = client.create_addressbook('contacts')
|
|
607
|
+
ab.put_contact('alice.vcf', "BEGIN:VCARD\r\nUID:1\r\nFN:Alice\r\nEND:VCARD")
|
|
608
|
+
result = ab.get_contact('alice.vcf')
|
|
609
|
+
result[:body].should.include 'FN:Alice'
|
|
610
|
+
end
|
|
566
611
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
612
|
+
it "contacts returns items from REPORT" do
|
|
613
|
+
client, = make_client
|
|
614
|
+
ab = client.create_addressbook('contacts')
|
|
615
|
+
ab.put_contact('alice.vcf', "BEGIN:VCARD\r\nUID:1\r\nFN:Alice\r\nEND:VCARD")
|
|
616
|
+
items = ab.contacts
|
|
617
|
+
items.length.should.equal 1
|
|
618
|
+
end
|
|
574
619
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
620
|
+
it "delete_contact removes contact" do
|
|
621
|
+
client, = make_client
|
|
622
|
+
ab = client.create_addressbook('contacts')
|
|
623
|
+
ab.put_contact('alice.vcf', "BEGIN:VCARD\r\nUID:1\r\nFN:Alice\r\nEND:VCARD")
|
|
624
|
+
ab.delete_contact('alice.vcf').should.equal true
|
|
625
|
+
lambda { ab.get_contact('alice.vcf') }.should.raise Async::Caldav::Client::NotFound
|
|
580
626
|
end
|
|
581
|
-
|
|
627
|
+
|
|
628
|
+
it "delete removes collection" do
|
|
629
|
+
client, = make_client
|
|
630
|
+
ab = client.create_addressbook('contacts')
|
|
631
|
+
ab.delete.should.equal true
|
|
632
|
+
end
|
|
633
|
+
end
|