caldav.rb 0.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.
- checksums.yaml +7 -0
- data/CHANGELOG +13 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +161 -0
- data/Rakefile +9 -0
- data/caldav.rb.gemspec +50 -0
- data/lib/CalDAV/MultiStatus.rb +33 -0
- data/lib/CalDAV/Resource.rb +68 -0
- data/lib/CalDAV/VERSION.rb +8 -0
- data/lib/CalDAV.rb +135 -0
- data/lib/Net/HTTP/Mkcalendar.rb +16 -0
- data/test/CalDAV/Resource_test.rb +96 -0
- data/test/CalDAV_test.rb +182 -0
- data/test/Net/HTTP/Mkcalendar_test.rb +21 -0
- data/test/helper.rb +13 -0
- data/test/integration_test.rb +52 -0
- data/test/vcr_helper.rb +55 -0
- data/test/vcr_helper_test.rb +31 -0
- metadata +141 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 25dd8ca7b31b78c727d868379e569f2a9a1715ca4f00d7378aa0fdba1bf534aa
|
|
4
|
+
data.tar.gz: fbd981192af07191ba341e9087966b509c04976fbb985d8152b0e1a67340198b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 69bb382d65bf98f71c1ccc3fad7b110a034edad6906efab5530b46ff3a4bcd0e78d0852864fadfa5c116c96b89803ed46e79755ba548fb817243b74287cc3e70
|
|
7
|
+
data.tar.gz: e6c8231c951220f020b36d1b6330713fb31ecf5908b26c0e0e1190754f9bbd4159a70d518d39d83526e6e8f1ea33e529debaac8438c1dda6bff12456686202c1
|
data/CHANGELOG
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
## 20260620
|
|
4
|
+
|
|
5
|
+
0.0.0: Initial release.
|
|
6
|
+
|
|
7
|
+
1. + CalDAV connection class (< WebDAV) providing the four CalDAV-specific protocol verbs: mkcalendar, calendar_query, calendar_multiget, freebusy_query.
|
|
8
|
+
2. + Net::HTTP::Mkcalendar request class. Not provided by Ruby's standard library. Mirrors webdav's Net::HTTP::Report. The REPORT method (used by calendar_query, calendar_multiget, freebusy_query) is already provided by webdav 0.2.0 and is reused.
|
|
9
|
+
3. + CalDAV::MultiStatus (< WebDAV::MultiStatus): type-preserving subclass. Overrides the private WebDAV::MultiStatus#parse_response to wrap each resource in a CalDAV::Resource. Interim coupling to a private webdav method; dissolves when webdav objectifies resources at the factoring stage.
|
|
10
|
+
4. + CalDAV::Resource: wraps a webdav resource hash and adds CalDAV-namespace navigation accessors: calendar_data, calendar_description, supported_calendar_component_set, calendar_timezone, is_calendar?. Strictly navigation, not parsing — accessors return raw iCalendar strings/values; iCalendar parsing into domain objects is Layer 2.
|
|
11
|
+
5. + Discovery helpers: CalDAV#current_user_principal, #calendar_home_set, #calendars. Return paths/strings. The object-oriented discovery API is Layer 2 (future release).
|
|
12
|
+
6. + Layer 1 (Protocol) of the planned three-layer ecosystem. Layer 2 (Objects: Event/Calendar/Principal value objects with iCalendar parsing) and Layer 3 (Queryable: Namo-backed Calendar) are future releases — reserved require paths are caldav.rb/objects and caldav.rb/queryable respectively.
|
|
13
|
+
7. + Read-only compatibility. Writes (requiring If-Match conditional headers, which webdav's #put does not yet expose), OAuth, sync-collection, and server-quirk handling are deferred.
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 thoran
|
|
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,161 @@
|
|
|
1
|
+
# caldav.rb
|
|
2
|
+
|
|
3
|
+
A CalDAV client library for Ruby, built on [webdav](https://github.com/thoran/webdav) and implementing RFC 4791. This release ships **Layer 1 (Protocol)** of a planned three-layer ecosystem.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
gem install caldav.rb
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or in your Gemfile:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
gem 'caldav.rb'
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## Concepts
|
|
20
|
+
|
|
21
|
+
caldav.rb is designed in three layers, each a strict superset of the one below. This release ships Layer 1 only; the higher layers are future releases with reserved require paths.
|
|
22
|
+
|
|
23
|
+
- **Layer 1 — Protocol** (this release). The CalDAV verbs, multistatus responses, and namespace-aware navigation accessors. The equivalent of a raw protocol library: it returns CalDAV-typed responses but does not parse iCalendar payloads or construct domain objects. Most users will want Layer 2 once it ships; until then, the protocol layer is usable directly by reading `resource.calendar_data` and parsing it with the `icalendar` gem.
|
|
24
|
+
- **Layer 2 — Objects** (future, `require 'caldav.rb/objects'`). `CalDAV::Event`, `CalDAV::Calendar`, and `CalDAV::Principal` value objects with iCalendar parsing and convenience predicates.
|
|
25
|
+
- **Layer 3 — Queryable** (future, `require 'caldav.rb/queryable'`). A [Namo](https://github.com/thoran/namo)-backed `CalDAV::Query::Calendar` exposing events as queryable rows with derived columns.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require 'caldav'
|
|
32
|
+
|
|
33
|
+
caldav = CalDAV.new('https://caldav.example.com/dav/', username: 'user', password: 'pass')
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Discovery
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
principal = caldav.current_user_principal
|
|
40
|
+
home = caldav.calendar_home_set(principal)
|
|
41
|
+
caldav.calendars(home).each{|href| puts href}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Each discovery helper defaults its argument to the result of the previous step, so `caldav.calendars` alone walks principal → home-set → calendars.
|
|
45
|
+
|
|
46
|
+
Discovery begins by PROPFINDing the **path of the base URL**. Many servers do not expose the current-user-principal at `/`, so point the base URL at the server's CalDAV context — e.g. `https://caldav.example.com/dav/` rather than `https://caldav.example.com/`. (RFC 6764 `.well-known/caldav` redirects are not auto-followed for PROPFIND in this release.) You can also override the starting point per call: `caldav.current_user_principal('/dav/')`.
|
|
47
|
+
|
|
48
|
+
### Querying a calendar
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
result = caldav.calendar_query('/calendars/user/work/', body: query_xml)
|
|
52
|
+
result.resources.each do |resource|
|
|
53
|
+
puts resource.href
|
|
54
|
+
puts resource.calendar_data # the raw iCalendar string
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Fetching specific events
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
result = caldav.calendar_multiget('/calendars/user/work/', body: multiget_xml)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Free/busy
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
response = caldav.freebusy_query('/calendars/user/work/', body: freebusy_xml)
|
|
68
|
+
puts response.body # VFREEBUSY iCalendar data — not a multistatus
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Creating a calendar
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
caldav.mkcalendar('/calendars/user/new/', body: mkcalendar_xml)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
## Methods
|
|
79
|
+
|
|
80
|
+
### Protocol verbs (RFC 4791)
|
|
81
|
+
|
|
82
|
+
- `mkcalendar(path, body:)` — §5.3.1. Create a calendar collection. Returns a `WebDAV::Response`.
|
|
83
|
+
- `calendar_query(path, body:, depth:)` — §7.8. REPORT with a `<c:calendar-query>` body. Returns a `CalDAV::MultiStatus`.
|
|
84
|
+
- `calendar_multiget(path, body:, depth:)` — §7.9. REPORT with a `<c:calendar-multiget>` body. Returns a `CalDAV::MultiStatus`.
|
|
85
|
+
- `freebusy_query(path, body:, depth:)` — §7.10. REPORT with a `<c:free-busy-query>` body. Returns a raw `WebDAV::Response` carrying VFREEBUSY iCalendar data — **not** a multistatus. This asymmetry is inherent to the CalDAV spec.
|
|
86
|
+
|
|
87
|
+
### Discovery
|
|
88
|
+
|
|
89
|
+
- `current_user_principal(path = base URL path)` — returns the principal URL string. PROPFINDs the base URL's path by default; pass a path to start discovery elsewhere.
|
|
90
|
+
- `calendar_home_set(principal = current_user_principal)` — returns the calendar-home-set URL string.
|
|
91
|
+
- `calendars(home = calendar_home_set)` — returns an array of calendar collection URL strings.
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
## Responses
|
|
95
|
+
|
|
96
|
+
The REPORT verbs return `CalDAV::MultiStatus`, a type-preserving subclass of `WebDAV::MultiStatus` whose `resources` are `CalDAV::Resource` objects. Each `CalDAV::Resource` adds CalDAV-namespace navigation accessors over the underlying webdav resource:
|
|
97
|
+
|
|
98
|
+
- `href` — the resource URL
|
|
99
|
+
- `calendar_data` — the iCalendar string from `<c:calendar-data>`
|
|
100
|
+
- `calendar_description` — `<c:calendar-description>`
|
|
101
|
+
- `supported_calendar_component_set` — `<c:supported-calendar-component-set>`
|
|
102
|
+
- `calendar_timezone` — the VTIMEZONE string from `<c:calendar-timezone>`
|
|
103
|
+
- `is_calendar?` — true when `<d:resourcetype>` includes `<c:calendar/>`
|
|
104
|
+
|
|
105
|
+
These are strictly navigation: they return raw strings and values, never parsed iCalendar objects. Parsing is Layer 2's job.
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
## Limitations
|
|
109
|
+
|
|
110
|
+
This is a Layer 1 protocol release. Known boundaries:
|
|
111
|
+
|
|
112
|
+
- **Read-only.** Conditional writes need `If-Match`, which webdav's `put` does not yet expose; writes arrive with Layer 2.
|
|
113
|
+
- **No iCalendar parsing.** Use the `icalendar` gem on `resource.calendar_data` if you need parsed events now.
|
|
114
|
+
- **Basic auth only.** No OAuth.
|
|
115
|
+
- **Tested against one real CalDAV server.** Other servers should work but are unverified.
|
|
116
|
+
- **No sync-collection.** Incremental sync is deferred.
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
## Dependencies
|
|
120
|
+
|
|
121
|
+
- [webdav](https://github.com/thoran/webdav)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
## Testing
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
rake
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Unit tests stub at the request boundary and need no network. A separate set of
|
|
131
|
+
integration tests (`test/integration_test.rb`) run against a real CalDAV server
|
|
132
|
+
via [VCR](https://github.com/vcr/vcr): they record real interactions into
|
|
133
|
+
host- and credential-scrubbed cassettes under `test/cassettes/` on first run, then
|
|
134
|
+
replay offline. Without a cassette and without credentials they skip, so the
|
|
135
|
+
default suite stays green.
|
|
136
|
+
|
|
137
|
+
To record against a live account, supply the server and credentials through the
|
|
138
|
+
environment and run `rake`:
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
CALDAV_URL='https://your-caldav-host/' \
|
|
142
|
+
CALDAV_USERNAME='you@example.com' \
|
|
143
|
+
CALDAV_PASSWORD='app-password' \
|
|
144
|
+
rake
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
See [test/cassettes/README.md](test/cassettes/README.md) for details.
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
## Contributing
|
|
151
|
+
|
|
152
|
+
1. Fork it [https://github.com/thoran/caldav.rb/fork](https://github.com/thoran/caldav.rb/fork)
|
|
153
|
+
2. Create your feature branch (git checkout -b my-new-feature)
|
|
154
|
+
3. Commit your changes (git commit -am 'Add some feature')
|
|
155
|
+
4. Push to the branch (git push origin my-new-feature)
|
|
156
|
+
5. Create a new pull request
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
## Licence
|
|
160
|
+
|
|
161
|
+
MIT
|
data/Rakefile
ADDED
data/caldav.rb.gemspec
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require_relative './lib/CalDAV/VERSION'
|
|
2
|
+
|
|
3
|
+
class Gem::Specification
|
|
4
|
+
def dependencies=(gems)
|
|
5
|
+
gems.each{|gem| add_dependency(*gem)}
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def development_dependencies=(gems)
|
|
9
|
+
gems.each{|gem| add_development_dependency(*gem)}
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
Gem::Specification.new do |spec|
|
|
14
|
+
spec.name = 'caldav.rb'
|
|
15
|
+
spec.version = CalDAV::VERSION
|
|
16
|
+
|
|
17
|
+
spec.summary = "A Ruby CalDAV client library."
|
|
18
|
+
spec.description = "A Ruby CalDAV client library, built on webdav (RFC 4791)."
|
|
19
|
+
|
|
20
|
+
spec.author = 'thoran'
|
|
21
|
+
spec.email = 'code@thoran.com'
|
|
22
|
+
spec.homepage = 'https://github.com/thoran/caldav'
|
|
23
|
+
spec.license = 'MIT'
|
|
24
|
+
|
|
25
|
+
spec.required_ruby_version = '>= 3.2'
|
|
26
|
+
spec.require_paths = ['lib']
|
|
27
|
+
|
|
28
|
+
spec.files = [
|
|
29
|
+
'caldav.rb.gemspec',
|
|
30
|
+
'CHANGELOG',
|
|
31
|
+
'Gemfile',
|
|
32
|
+
'LICENSE',
|
|
33
|
+
'Rakefile',
|
|
34
|
+
'README.md',
|
|
35
|
+
Dir['lib/**/*.rb'],
|
|
36
|
+
Dir['test/**/*.rb']
|
|
37
|
+
].flatten
|
|
38
|
+
|
|
39
|
+
spec.dependencies = [
|
|
40
|
+
['webdav', '~> 0.2']
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
spec.development_dependencies = [
|
|
44
|
+
['minitest', '~> 6.0'],
|
|
45
|
+
['minitest-mock'],
|
|
46
|
+
['rake'],
|
|
47
|
+
['vcr', '~> 6.0'],
|
|
48
|
+
['webmock', '~> 3.0']
|
|
49
|
+
]
|
|
50
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# CalDAV/MultiStatus.rb
|
|
2
|
+
# CalDAV::MultiStatus
|
|
3
|
+
|
|
4
|
+
require 'webdav'
|
|
5
|
+
|
|
6
|
+
require_relative './Resource'
|
|
7
|
+
|
|
8
|
+
class CalDAV < WebDAV
|
|
9
|
+
class MultiStatus < WebDAV::MultiStatus
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def parse_response(response_element)
|
|
14
|
+
CalDAV::Resource.new(super)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Interim: webdav 0.2.0's parse_properties does `text || to_s`, so a nested
|
|
18
|
+
# property (resourcetype, current-user-principal, calendar-home-set) whose
|
|
19
|
+
# server pretty-prints the XML yields the inter-tag whitespace instead of the
|
|
20
|
+
# markup, dropping the nested elements. Treat blank text as absent so such
|
|
21
|
+
# properties fall through to their serialised form, as they do for compact
|
|
22
|
+
# servers. Dissolves when webdav is reworked.
|
|
23
|
+
def parse_properties(prop_element)
|
|
24
|
+
return {} unless prop_element
|
|
25
|
+
prop_element.elements.to_a.each_with_object({}) do |property_element, result|
|
|
26
|
+
result[property_element.namespace] ||= {}
|
|
27
|
+
text = property_element.text
|
|
28
|
+
value = text && !text.strip.empty? ? text : property_element.to_s
|
|
29
|
+
result[property_element.namespace][property_element.name] = value
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# CalDAV/Resource.rb
|
|
2
|
+
# CalDAV::Resource
|
|
3
|
+
|
|
4
|
+
require 'webdav'
|
|
5
|
+
|
|
6
|
+
class CalDAV < WebDAV
|
|
7
|
+
class Resource
|
|
8
|
+
attr_reader :hash
|
|
9
|
+
|
|
10
|
+
def href
|
|
11
|
+
hash[:href]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def status
|
|
15
|
+
hash[:status]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def propstats
|
|
19
|
+
hash[:propstats]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Navigation accessors. Strictly navigation, not parsing: they return the
|
|
23
|
+
# underlying iCalendar string or value.
|
|
24
|
+
|
|
25
|
+
def calendar_data
|
|
26
|
+
property(NAMESPACE, 'calendar-data')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def calendar_description
|
|
30
|
+
property(NAMESPACE, 'calendar-description')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def supported_calendar_component_set
|
|
34
|
+
property(NAMESPACE, 'supported-calendar-component-set')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def calendar_timezone
|
|
38
|
+
property(NAMESPACE, 'calendar-timezone')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Matches a <calendar> element (under any namespace prefix) in the serialised
|
|
42
|
+
# resourcetype, rather than a bare 'calendar' substring: the substring also
|
|
43
|
+
# matches calendar-proxy-read/write collections, which are not calendars.
|
|
44
|
+
def is_calendar?
|
|
45
|
+
resource_type.match?(%r{<(?:\w+:)?calendar(?:\s[^>]*)?/?>})
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def initialize(hash)
|
|
51
|
+
@hash = hash
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def properties
|
|
55
|
+
@properties ||= propstats.each_with_object({}) do |propstat, result|
|
|
56
|
+
result.merge!(propstat[:properties])
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def property(namespace, name)
|
|
61
|
+
properties.dig(namespace, name)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def resource_type
|
|
65
|
+
property(DAV_NAMESPACE, 'resourcetype').to_s
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/CalDAV.rb
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# CalDAV.rb
|
|
2
|
+
# CalDAV
|
|
3
|
+
|
|
4
|
+
require 'webdav'
|
|
5
|
+
|
|
6
|
+
require_relative './CalDAV/Resource'
|
|
7
|
+
require_relative './CalDAV/MultiStatus'
|
|
8
|
+
require_relative './Net/HTTP/Mkcalendar'
|
|
9
|
+
|
|
10
|
+
class CalDAV < WebDAV
|
|
11
|
+
NAMESPACE = 'urn:ietf:params:xml:ns:caldav'
|
|
12
|
+
DAV_NAMESPACE = 'DAV:'
|
|
13
|
+
|
|
14
|
+
# Protocol verbs (RFC 4791)
|
|
15
|
+
|
|
16
|
+
# RFC 4791 §5.3.1. Creates a calendar collection.
|
|
17
|
+
def mkcalendar(path, body: nil)
|
|
18
|
+
response = request(:mkcalendar, path, body: body)
|
|
19
|
+
handle_response(response)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# RFC 4791 §7.8. REPORT with a <c:calendar-query> body.
|
|
23
|
+
def calendar_query(path, body:, depth: '1')
|
|
24
|
+
report_as_caldav(path, body: body, depth: depth)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# RFC 4791 §7.9. REPORT with a <c:calendar-multiget> body.
|
|
28
|
+
def calendar_multiget(path, body:, depth: '0')
|
|
29
|
+
report_as_caldav(path, body: body, depth: depth)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# RFC 4791 §7.10. REPORT with a <c:free-busy-query> body. Asymmetric: returns
|
|
33
|
+
# the raw response carrying iCalendar VFREEBUSY data, not a multistatus.
|
|
34
|
+
def freebusy_query(path, body:, depth: '0')
|
|
35
|
+
response = request(:report, path, body: body, headers: {'Depth' => depth})
|
|
36
|
+
handle_response(response)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Discovery helpers. Return paths/strings. And object-oriented discovery API
|
|
40
|
+
# (Principal/Calendar objects) is is planned.
|
|
41
|
+
|
|
42
|
+
def current_user_principal(path = discovery_path)
|
|
43
|
+
resource = propfind_as_caldav(path, body: current_user_principal_body, depth: '0').resources.first
|
|
44
|
+
resource&.then{|r| href_property(r, DAV_NAMESPACE, 'current-user-principal')}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def calendar_home_set(principal = current_user_principal)
|
|
48
|
+
resource = propfind_as_caldav(principal, body: calendar_home_set_body, depth: '0').resources.first
|
|
49
|
+
resource&.then{|r| href_property(r, NAMESPACE, 'calendar-home-set')}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def calendars(home = calendar_home_set)
|
|
53
|
+
resources = propfind_as_caldav(home, body: calendars_body, depth: '1').resources
|
|
54
|
+
resources.select{|resource| resource.is_calendar?}.collect{|resource| resource.href}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Send a REPORT and return the CalDAV-typed multistatus rather than the plain
|
|
60
|
+
# WebDAV one. Constructed from the raw response so the CalDAV resource wrappers
|
|
61
|
+
# are produced by CalDAV::MultiStatus's overridden parse_response.
|
|
62
|
+
def report_as_caldav(path, body:, depth:)
|
|
63
|
+
response = request(:report, path, body: body, headers: {'Depth' => depth})
|
|
64
|
+
raise WebDAV::Error.new(response) if response.code.to_i >= 400
|
|
65
|
+
CalDAV::MultiStatus.new(response)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# As report_as_caldav, but for PROPFIND. Needed because the inherited #propfind
|
|
69
|
+
# returns a plain WebDAV::MultiStatus whose resources are bare hashes, not
|
|
70
|
+
# CalDAV::Resource wrappers. Discovery relies on the wrappers (#is_calendar?,
|
|
71
|
+
# #href), so it routes through here.
|
|
72
|
+
def propfind_as_caldav(path, body:, depth:)
|
|
73
|
+
response = request(:propfind, path, body: body, headers: {'Depth' => depth})
|
|
74
|
+
raise WebDAV::Error.new(response) if response.code.to_i >= 400
|
|
75
|
+
CalDAV::MultiStatus.new(response)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The discovery properties (current-user-principal, calendar-home-set) wrap
|
|
79
|
+
# their value in a nested <d:href>. webdav 0.2.0 renders a nested-element
|
|
80
|
+
# property as serialised markup, so the bare URL is lifted back out here.
|
|
81
|
+
# Caveat: when a server pretty-prints, webdav's `text || to_s` returns the
|
|
82
|
+
# leading whitespace text node and the href is lost upstream; recovering that
|
|
83
|
+
# waits for the webdav-objectifies-resources rework.
|
|
84
|
+
def href_property(resource, namespace, name)
|
|
85
|
+
resource.propstats.each do |propstat|
|
|
86
|
+
href = extract_href(propstat[:properties].dig(namespace, name))
|
|
87
|
+
return href if href
|
|
88
|
+
end
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def extract_href(value)
|
|
93
|
+
return nil unless value
|
|
94
|
+
match = value.match(%r{<(?:\w+:)?href[^>]*>(.*?)</(?:\w+:)?href>}m)
|
|
95
|
+
href = (match ? match[1] : value).strip
|
|
96
|
+
href.empty? ? nil : href
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Discovery starts from the path of the configured base URL, so a server that
|
|
100
|
+
# does not expose the current-user-principal at '/' is reached by giving its
|
|
101
|
+
# CalDAV context path in the base URL (e.g. https://host/dav/). Falls back to
|
|
102
|
+
# '/' when the base URL has no path.
|
|
103
|
+
def discovery_path
|
|
104
|
+
@uri.path.empty? ? '/' : @uri.path
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def current_user_principal_body
|
|
108
|
+
<<~XML
|
|
109
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
110
|
+
<d:propfind xmlns:d="DAV:">
|
|
111
|
+
<d:prop><d:current-user-principal/></d:prop>
|
|
112
|
+
</d:propfind>
|
|
113
|
+
XML
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def calendar_home_set_body
|
|
117
|
+
<<~XML
|
|
118
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
119
|
+
<d:propfind xmlns:d="DAV:" xmlns:c="#{NAMESPACE}">
|
|
120
|
+
<d:prop><c:calendar-home-set/></d:prop>
|
|
121
|
+
</d:propfind>
|
|
122
|
+
XML
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def calendars_body
|
|
126
|
+
<<~XML
|
|
127
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
128
|
+
<d:propfind xmlns:d="DAV:">
|
|
129
|
+
<d:prop>
|
|
130
|
+
<d:resourcetype/>
|
|
131
|
+
</d:prop>
|
|
132
|
+
</d:propfind>
|
|
133
|
+
XML
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# test/CalDAV/Resource_test.rb
|
|
2
|
+
|
|
3
|
+
require_relative '../helper'
|
|
4
|
+
|
|
5
|
+
describe CalDAV::Resource do
|
|
6
|
+
let(:calendar_xml) do
|
|
7
|
+
<<~XML
|
|
8
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
9
|
+
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
10
|
+
<d:response>
|
|
11
|
+
<d:href>/calendars/user/work/</d:href>
|
|
12
|
+
<d:propstat>
|
|
13
|
+
<d:prop>
|
|
14
|
+
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
|
15
|
+
<c:calendar-description>Work calendar</c:calendar-description>
|
|
16
|
+
</d:prop>
|
|
17
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
18
|
+
</d:propstat>
|
|
19
|
+
</d:response>
|
|
20
|
+
<d:response>
|
|
21
|
+
<d:href>/calendars/user/work/event.ics</d:href>
|
|
22
|
+
<d:propstat>
|
|
23
|
+
<d:prop>
|
|
24
|
+
<d:getetag>"abc123"</d:getetag>
|
|
25
|
+
<c:calendar-data>BEGIN:VCALENDAR</c:calendar-data>
|
|
26
|
+
</d:prop>
|
|
27
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
28
|
+
</d:propstat>
|
|
29
|
+
</d:response>
|
|
30
|
+
<d:response>
|
|
31
|
+
<d:href>/calendars/user/calendar-proxy-read/</d:href>
|
|
32
|
+
<d:propstat>
|
|
33
|
+
<d:prop>
|
|
34
|
+
<d:resourcetype><d:collection/><cs:calendar-proxy-read xmlns:cs="http://calendarserver.org/ns/"/></d:resourcetype>
|
|
35
|
+
</d:prop>
|
|
36
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
37
|
+
</d:propstat>
|
|
38
|
+
</d:response>
|
|
39
|
+
</d:multistatus>
|
|
40
|
+
XML
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
let(:multistatus){CalDAV::MultiStatus.new(MockResponse.new(code: '207', message: 'Multi-Status', body: calendar_xml))}
|
|
44
|
+
let(:calendar_resource){multistatus.resources[0]}
|
|
45
|
+
let(:event_resource){multistatus.resources[1]}
|
|
46
|
+
let(:proxy_resource){multistatus.resources[2]}
|
|
47
|
+
|
|
48
|
+
it "wraps resources as CalDAV::Resource" do
|
|
49
|
+
_(calendar_resource).must_be_kind_of CalDAV::Resource
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "exposes href" do
|
|
53
|
+
_(calendar_resource.href).must_equal '/calendars/user/work/'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "reads calendar-description" do
|
|
57
|
+
_(calendar_resource.calendar_description).must_equal 'Work calendar'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "reads calendar-data" do
|
|
61
|
+
_(event_resource.calendar_data).must_equal 'BEGIN:VCALENDAR'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "identifies a calendar collection" do
|
|
65
|
+
_(calendar_resource.is_calendar?).must_equal true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "identifies a non-calendar resource" do
|
|
69
|
+
_(event_resource.is_calendar?).must_equal false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "does not mistake a calendar-proxy collection for a calendar" do
|
|
73
|
+
_(proxy_resource.is_calendar?).must_equal false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "identifies a calendar from a pretty-printed (indented) resourcetype" do
|
|
77
|
+
pretty = CalDAV::MultiStatus.new(MockResponse.new(code: '207', message: 'Multi-Status', body: <<~XML))
|
|
78
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
79
|
+
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
80
|
+
<d:response>
|
|
81
|
+
<d:href>/dav/calendars/user/x/work/</d:href>
|
|
82
|
+
<d:propstat>
|
|
83
|
+
<d:prop>
|
|
84
|
+
<d:resourcetype>
|
|
85
|
+
<d:collection/>
|
|
86
|
+
<c:calendar/>
|
|
87
|
+
</d:resourcetype>
|
|
88
|
+
</d:prop>
|
|
89
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
90
|
+
</d:propstat>
|
|
91
|
+
</d:response>
|
|
92
|
+
</d:multistatus>
|
|
93
|
+
XML
|
|
94
|
+
_(pretty.resources.first.is_calendar?).must_equal true
|
|
95
|
+
end
|
|
96
|
+
end
|
data/test/CalDAV_test.rb
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# test/caldav_test.rb
|
|
2
|
+
|
|
3
|
+
require_relative './helper'
|
|
4
|
+
|
|
5
|
+
describe CalDAV do
|
|
6
|
+
let(:base_uri){'https://caldav.example.com/'}
|
|
7
|
+
let(:caldav){CalDAV.new(base_uri, username: 'user', password: 'pass')}
|
|
8
|
+
|
|
9
|
+
let(:multistatus_response) do
|
|
10
|
+
MockResponse.new(
|
|
11
|
+
code: '207',
|
|
12
|
+
message: 'Multi-Status',
|
|
13
|
+
body: <<~XML,
|
|
14
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
15
|
+
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
16
|
+
<d:response>
|
|
17
|
+
<d:href>/calendars/user/work/event.ics</d:href>
|
|
18
|
+
<d:propstat>
|
|
19
|
+
<d:prop>
|
|
20
|
+
<d:getetag>"abc123"</d:getetag>
|
|
21
|
+
<c:calendar-data>BEGIN:VCALENDAR</c:calendar-data>
|
|
22
|
+
</d:prop>
|
|
23
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
24
|
+
</d:propstat>
|
|
25
|
+
</d:response>
|
|
26
|
+
</d:multistatus>
|
|
27
|
+
XML
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
let(:created_response){MockResponse.new(code: '201', message: 'Created', body: '')}
|
|
32
|
+
|
|
33
|
+
describe "#mkcalendar" do
|
|
34
|
+
it "returns a Response" do
|
|
35
|
+
caldav.stub(:request, created_response) do
|
|
36
|
+
result = caldav.mkcalendar('/calendars/user/new/')
|
|
37
|
+
_(result).must_be_kind_of WebDAV::Response
|
|
38
|
+
_(result.code).must_equal 201
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe "#calendar_query" do
|
|
44
|
+
it "returns a CalDAV::MultiStatus" do
|
|
45
|
+
caldav.stub(:request, multistatus_response) do
|
|
46
|
+
result = caldav.calendar_query('/calendars/user/work/', body: '<c:calendar-query/>')
|
|
47
|
+
_(result).must_be_kind_of CalDAV::MultiStatus
|
|
48
|
+
_(result.resources.first).must_be_kind_of CalDAV::Resource
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
describe "#calendar_multiget" do
|
|
54
|
+
it "returns a CalDAV::MultiStatus" do
|
|
55
|
+
caldav.stub(:request, multistatus_response) do
|
|
56
|
+
result = caldav.calendar_multiget('/calendars/user/work/', body: '<c:calendar-multiget/>')
|
|
57
|
+
_(result).must_be_kind_of CalDAV::MultiStatus
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
describe "#freebusy_query" do
|
|
63
|
+
it "returns a raw Response carrying VFREEBUSY" do
|
|
64
|
+
freebusy = MockResponse.new(code: '200', message: 'OK', body: 'BEGIN:VCALENDAR')
|
|
65
|
+
caldav.stub(:request, freebusy) do
|
|
66
|
+
result = caldav.freebusy_query('/calendars/user/work/', body: '<c:free-busy-query/>')
|
|
67
|
+
_(result).must_be_kind_of WebDAV::Response
|
|
68
|
+
_(result.body).must_equal 'BEGIN:VCALENDAR'
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe "#current_user_principal" do
|
|
74
|
+
let(:response) do
|
|
75
|
+
MockResponse.new(code: '207', message: 'Multi-Status', body: <<~XML)
|
|
76
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
77
|
+
<d:multistatus xmlns:d="DAV:">
|
|
78
|
+
<d:response>
|
|
79
|
+
<d:href>/</d:href>
|
|
80
|
+
<d:propstat>
|
|
81
|
+
<d:prop><d:current-user-principal><d:href>/dav/principals/user/abc/</d:href></d:current-user-principal></d:prop>
|
|
82
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
83
|
+
</d:propstat>
|
|
84
|
+
</d:response>
|
|
85
|
+
</d:multistatus>
|
|
86
|
+
XML
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "returns the principal href as a bare URL string" do
|
|
90
|
+
caldav.stub(:request, response) do
|
|
91
|
+
_(caldav.current_user_principal).must_equal '/dav/principals/user/abc/'
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "PROPFINDs the base URL's path by default, not '/'" do
|
|
96
|
+
dav = CalDAV.new('https://caldav.example.com/dav/', username: 'u', password: 'p')
|
|
97
|
+
captured = nil
|
|
98
|
+
responder = ->(verb, path, **kw){captured = path; response}
|
|
99
|
+
dav.stub(:request, responder) do
|
|
100
|
+
dav.current_user_principal
|
|
101
|
+
end
|
|
102
|
+
_(captured).must_equal '/dav/'
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "extracts the principal from a pretty-printed (indented) response" do
|
|
106
|
+
pretty = MockResponse.new(code: '207', message: 'Multi-Status', body: <<~XML)
|
|
107
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
108
|
+
<d:multistatus xmlns:d="DAV:">
|
|
109
|
+
<d:response>
|
|
110
|
+
<d:href>/dav/</d:href>
|
|
111
|
+
<d:propstat>
|
|
112
|
+
<d:prop>
|
|
113
|
+
<d:current-user-principal>
|
|
114
|
+
<d:href>/dav/principals/user/abc/</d:href>
|
|
115
|
+
</d:current-user-principal>
|
|
116
|
+
</d:prop>
|
|
117
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
118
|
+
</d:propstat>
|
|
119
|
+
</d:response>
|
|
120
|
+
</d:multistatus>
|
|
121
|
+
XML
|
|
122
|
+
caldav.stub(:request, pretty) do
|
|
123
|
+
_(caldav.current_user_principal).must_equal '/dav/principals/user/abc/'
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
describe "#calendar_home_set" do
|
|
129
|
+
let(:response) do
|
|
130
|
+
MockResponse.new(code: '207', message: 'Multi-Status', body: <<~XML)
|
|
131
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
132
|
+
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
133
|
+
<d:response>
|
|
134
|
+
<d:href>/dav/principals/user/abc/</d:href>
|
|
135
|
+
<d:propstat>
|
|
136
|
+
<d:prop><c:calendar-home-set><d:href>/dav/calendars/user/abc/</d:href></c:calendar-home-set></d:prop>
|
|
137
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
138
|
+
</d:propstat>
|
|
139
|
+
</d:response>
|
|
140
|
+
</d:multistatus>
|
|
141
|
+
XML
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it "returns the home-set href as a bare URL string" do
|
|
145
|
+
caldav.stub(:request, response) do
|
|
146
|
+
_(caldav.calendar_home_set('/dav/principals/user/abc/')).must_equal '/dav/calendars/user/abc/'
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
describe "#calendars" do
|
|
152
|
+
let(:response) do
|
|
153
|
+
MockResponse.new(code: '207', message: 'Multi-Status', body: <<~XML)
|
|
154
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
155
|
+
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
|
|
156
|
+
<d:response>
|
|
157
|
+
<d:href>/dav/calendars/user/abc/</d:href>
|
|
158
|
+
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
|
159
|
+
</d:response>
|
|
160
|
+
<d:response>
|
|
161
|
+
<d:href>/dav/calendars/user/abc/work/</d:href>
|
|
162
|
+
<d:propstat><d:prop><d:resourcetype><d:collection/><c:calendar/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
|
163
|
+
</d:response>
|
|
164
|
+
<d:response>
|
|
165
|
+
<d:href>/dav/calendars/user/abc/inbox/</d:href>
|
|
166
|
+
<d:propstat><d:prop><d:resourcetype><d:collection/><c:schedule-inbox/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
|
167
|
+
</d:response>
|
|
168
|
+
<d:response>
|
|
169
|
+
<d:href>/dav/calendars/user/abc/proxy/</d:href>
|
|
170
|
+
<d:propstat><d:prop><d:resourcetype><d:collection/><cs:calendar-proxy-read/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
|
171
|
+
</d:response>
|
|
172
|
+
</d:multistatus>
|
|
173
|
+
XML
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it "returns only the calendar collection hrefs as bare URL strings" do
|
|
177
|
+
caldav.stub(:request, response) do
|
|
178
|
+
_(caldav.calendars('/dav/calendars/user/abc/')).must_equal ['/dav/calendars/user/abc/work/']
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# test/Net/HTTP/Mkcalendar_test.rb
|
|
2
|
+
|
|
3
|
+
require_relative '../../helper'
|
|
4
|
+
|
|
5
|
+
describe Net::HTTP::Mkcalendar do
|
|
6
|
+
it "is a subclass of Net::HTTPRequest" do
|
|
7
|
+
_(Net::HTTP::Mkcalendar < Net::HTTPRequest).must_equal(true)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it "has the correct METHOD" do
|
|
11
|
+
_(Net::HTTP::Mkcalendar::METHOD).must_equal('MKCALENDAR')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it "accepts a body" do
|
|
15
|
+
_(Net::HTTP::Mkcalendar::REQUEST_HAS_BODY).must_equal(true)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "expects a response body" do
|
|
19
|
+
_(Net::HTTP::Mkcalendar::RESPONSE_HAS_BODY).must_equal(true)
|
|
20
|
+
end
|
|
21
|
+
end
|
data/test/helper.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# test/helper.rb
|
|
2
|
+
|
|
3
|
+
require 'minitest/autorun'
|
|
4
|
+
require 'minitest/mock'
|
|
5
|
+
require 'minitest/spec'
|
|
6
|
+
|
|
7
|
+
require_relative '../lib/caldav'
|
|
8
|
+
|
|
9
|
+
MockResponse = Struct.new(:code, :message, :body, :headers_hash, keyword_init: true) do
|
|
10
|
+
def [](key)
|
|
11
|
+
headers_hash&.dig(key)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# test/integration_test.rb
|
|
2
|
+
|
|
3
|
+
require_relative './helper'
|
|
4
|
+
require_relative './vcr_helper'
|
|
5
|
+
|
|
6
|
+
describe 'CalDAV integration' do
|
|
7
|
+
let(:caldav){caldav_client}
|
|
8
|
+
|
|
9
|
+
let(:events_query_body) do
|
|
10
|
+
<<~XML
|
|
11
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
12
|
+
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
13
|
+
<d:prop>
|
|
14
|
+
<d:getetag/>
|
|
15
|
+
<c:calendar-data/>
|
|
16
|
+
</d:prop>
|
|
17
|
+
<c:filter>
|
|
18
|
+
<c:comp-filter name="VCALENDAR">
|
|
19
|
+
<c:comp-filter name="VEVENT"/>
|
|
20
|
+
</c:comp-filter>
|
|
21
|
+
</c:filter>
|
|
22
|
+
</c:calendar-query>
|
|
23
|
+
XML
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "discovers principal, home-set and calendars as bare URL strings" do
|
|
27
|
+
with_caldav_cassette('discovery') do
|
|
28
|
+
principal = caldav.current_user_principal
|
|
29
|
+
_(principal).must_be_kind_of String
|
|
30
|
+
_(principal).wont_include '<'
|
|
31
|
+
home = caldav.calendar_home_set(principal)
|
|
32
|
+
_(home).must_be_kind_of String
|
|
33
|
+
_(home).wont_include '<'
|
|
34
|
+
calendars = caldav.calendars(home)
|
|
35
|
+
_(calendars).must_be_kind_of Array
|
|
36
|
+
calendars.each do |href|
|
|
37
|
+
_(href).must_be_kind_of String
|
|
38
|
+
_(href).wont_include '<'
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "queries the first calendar and returns CalDAV resources" do
|
|
44
|
+
with_caldav_cassette('calendar_query') do
|
|
45
|
+
calendar = caldav.calendars.first
|
|
46
|
+
skip 'no calendars on this account' unless calendar
|
|
47
|
+
result = caldav.calendar_query(calendar, body: events_query_body)
|
|
48
|
+
_(result).must_be_kind_of CalDAV::MultiStatus
|
|
49
|
+
result.resources.each{|resource| _(resource).must_be_kind_of CalDAV::Resource}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/test/vcr_helper.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# test/vcr_helper.rb
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'vcr'
|
|
5
|
+
|
|
6
|
+
EXAMPLE_CALDAV_URL = 'https://caldav.example.com/dav/'
|
|
7
|
+
CALDAV_URL = ENV.fetch('CALDAV_URL', EXAMPLE_CALDAV_URL)
|
|
8
|
+
|
|
9
|
+
# Replaces the account-identifier segment in CalDAV collection paths (e.g.
|
|
10
|
+
# /principals/user/<id>/ or /calendars/user/<id>/) with a fixed placeholder, so a
|
|
11
|
+
# committed cassette does not expose an account-specific id. The collection name
|
|
12
|
+
# after the id (a calendar name, say) is preserved. Covers the common
|
|
13
|
+
# collection/realm/id layout; the credential filters still scrub email-style ids.
|
|
14
|
+
ACCOUNT_PATH = %r{(/(?:principals|calendars)/[^/]+/)[^/]+/}
|
|
15
|
+
|
|
16
|
+
def anonymise_account_path(string)
|
|
17
|
+
string&.gsub(ACCOUNT_PATH, '\1anon/')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
VCR.configure do |config|
|
|
21
|
+
config.cassette_library_dir = File.expand_path('cassettes', __dir__)
|
|
22
|
+
config.hook_into :webmock
|
|
23
|
+
config.filter_sensitive_data(URI(EXAMPLE_CALDAV_URL).host){URI(ENV['CALDAV_URL']).host if ENV['CALDAV_URL']}
|
|
24
|
+
# Basic auth is base64(user:pass), so the credential filters below would not catch it — filter the Authorization header itself too.
|
|
25
|
+
config.filter_sensitive_data('<BASIC_AUTH>'){|interaction| interaction.request.headers['Authorization']&.first}
|
|
26
|
+
config.filter_sensitive_data('<CALDAV_USERNAME>'){ENV['CALDAV_USERNAME']}
|
|
27
|
+
config.filter_sensitive_data('<CALDAV_PASSWORD>'){ENV['CALDAV_PASSWORD']}
|
|
28
|
+
# Anonymise account ids in request URIs and response bodies before writing. The
|
|
29
|
+
# rewrite is self-consistent (the same paths drive the next request), so the
|
|
30
|
+
# anonymised cassette still replays.
|
|
31
|
+
config.before_record do |interaction|
|
|
32
|
+
interaction.request.uri = anonymise_account_path(interaction.request.uri)
|
|
33
|
+
interaction.response.body = anonymise_account_path(interaction.response.body)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def caldav_client
|
|
38
|
+
CalDAV.new(
|
|
39
|
+
CALDAV_URL,
|
|
40
|
+
username: ENV.fetch('CALDAV_USERNAME', 'recorded'),
|
|
41
|
+
password: ENV.fetch('CALDAV_PASSWORD', 'recorded')
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Records against the real server when credentials are present; replays from the
|
|
46
|
+
# committed cassette otherwise. Skips when a real request would be needed but no
|
|
47
|
+
# credentials are set, so the suite stays green without an account. To re-record,
|
|
48
|
+
# delete the cassette and re-run with credentials.
|
|
49
|
+
def with_caldav_cassette(name)
|
|
50
|
+
cassette = File.join(VCR.configuration.cassette_library_dir, "#{name}.yml")
|
|
51
|
+
unless File.exist?(cassette) || ENV['CALDAV_USERNAME']
|
|
52
|
+
raise Minitest::Skip, "no cassette '#{name}.yml' and CALDAV_USERNAME unset; export CALDAV_URL/CALDAV_USERNAME/CALDAV_PASSWORD to record"
|
|
53
|
+
end
|
|
54
|
+
VCR.use_cassette(name){yield}
|
|
55
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# test/vcr_helper_test.rb
|
|
2
|
+
|
|
3
|
+
require_relative './helper'
|
|
4
|
+
require_relative './vcr_helper'
|
|
5
|
+
|
|
6
|
+
describe 'anonymise_account_path' do
|
|
7
|
+
it "replaces the id under a principals collection" do
|
|
8
|
+
_(anonymise_account_path('/dav/principals/user/john@example.net/')).must_equal '/dav/principals/user/anon/'
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it "replaces the id under a calendars collection, keeping the calendar name" do
|
|
12
|
+
_(anonymise_account_path('/dav/calendars/user/abc123/work/')).must_equal '/dav/calendars/user/anon/work/'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it "replaces an opaque (non-email) id" do
|
|
16
|
+
_(anonymise_account_path('/dav/principals/user/u9f8e7d6/')).must_equal '/dav/principals/user/anon/'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "anonymises ids inside a serialised response body" do
|
|
20
|
+
body = '<d:href>/dav/calendars/user/abc123/</d:href>'
|
|
21
|
+
_(anonymise_account_path(body)).must_equal '<d:href>/dav/calendars/user/anon/</d:href>'
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "leaves unrelated paths untouched" do
|
|
25
|
+
_(anonymise_account_path('/dav/')).must_equal '/dav/'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "handles nil" do
|
|
29
|
+
_(anonymise_account_path(nil)).must_be_nil
|
|
30
|
+
end
|
|
31
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: caldav.rb
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- thoran
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: webdav
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '6.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '6.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: minitest-mock
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rake
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: vcr
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '6.0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '6.0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: webmock
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '3.0'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '3.0'
|
|
96
|
+
description: A Ruby CalDAV client library, built on webdav (RFC 4791).
|
|
97
|
+
email: code@thoran.com
|
|
98
|
+
executables: []
|
|
99
|
+
extensions: []
|
|
100
|
+
extra_rdoc_files: []
|
|
101
|
+
files:
|
|
102
|
+
- CHANGELOG
|
|
103
|
+
- Gemfile
|
|
104
|
+
- LICENSE
|
|
105
|
+
- README.md
|
|
106
|
+
- Rakefile
|
|
107
|
+
- caldav.rb.gemspec
|
|
108
|
+
- lib/CalDAV.rb
|
|
109
|
+
- lib/CalDAV/MultiStatus.rb
|
|
110
|
+
- lib/CalDAV/Resource.rb
|
|
111
|
+
- lib/CalDAV/VERSION.rb
|
|
112
|
+
- lib/Net/HTTP/Mkcalendar.rb
|
|
113
|
+
- test/CalDAV/Resource_test.rb
|
|
114
|
+
- test/CalDAV_test.rb
|
|
115
|
+
- test/Net/HTTP/Mkcalendar_test.rb
|
|
116
|
+
- test/helper.rb
|
|
117
|
+
- test/integration_test.rb
|
|
118
|
+
- test/vcr_helper.rb
|
|
119
|
+
- test/vcr_helper_test.rb
|
|
120
|
+
homepage: https://github.com/thoran/caldav
|
|
121
|
+
licenses:
|
|
122
|
+
- MIT
|
|
123
|
+
metadata: {}
|
|
124
|
+
rdoc_options: []
|
|
125
|
+
require_paths:
|
|
126
|
+
- lib
|
|
127
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
128
|
+
requirements:
|
|
129
|
+
- - ">="
|
|
130
|
+
- !ruby/object:Gem::Version
|
|
131
|
+
version: '3.2'
|
|
132
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
133
|
+
requirements:
|
|
134
|
+
- - ">="
|
|
135
|
+
- !ruby/object:Gem::Version
|
|
136
|
+
version: '0'
|
|
137
|
+
requirements: []
|
|
138
|
+
rubygems_version: 4.0.14
|
|
139
|
+
specification_version: 4
|
|
140
|
+
summary: A Ruby CalDAV client library.
|
|
141
|
+
test_files: []
|