carddav.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 +17 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +155 -0
- data/Rakefile +9 -0
- data/carddav.rb.gemspec +50 -0
- data/lib/CardDAV/MultiStatus.rb +33 -0
- data/lib/CardDAV/Resource.rb +69 -0
- data/lib/CardDAV/VERSION.rb +8 -0
- data/lib/carddav.rb +138 -0
- data/test/CardDAV/MultiStatus_test.rb +61 -0
- data/test/CardDAV/Resource_test.rb +108 -0
- data/test/carddav_test.rb +167 -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 +140 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9430d7a069267b260526ee0cfc83d48015b5ed3246d9cbb665929915d1146e8a
|
|
4
|
+
data.tar.gz: 312d5939b30b496bcf96c747ed1e3aea39cafd53225dde43668c4bbe865e71b9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ab268a645a1fb8769ff36517f908469655d2407c553271892c220b90f303a61120824542a2505da6b6b51043b5c8e873a282da6541547bfee97baf0fdac41b33
|
|
7
|
+
data.tar.gz: 131b86e9cdfea2c4bf6892effb27d1f8d5386a50721a812d2c65891b35ddb10f8f9c710afc2c075836261b86f94ac874ff33cf9d614429968b1812786547423e
|
data/CHANGELOG
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
## 20260623
|
|
4
|
+
|
|
5
|
+
0.0.0: Initial release.
|
|
6
|
+
|
|
7
|
+
1. + CardDAV protocol verbs: mkcol (extended MKCOL), addressbook_query, and addressbook_multiget.
|
|
8
|
+
2. + CardDAV discovery helpers: #current_user_principal, #addressbook_home_set, #addressbooks.
|
|
9
|
+
3. + CardDAV::MultiStatus: type-preserving subclass that wraps each response resource as a CardDAV::Resource.
|
|
10
|
+
3. + CardDAV::Resource: wraps a webdav resource hash and adds CardDAV-namespace navigation accessors: address_data, addressbook_description, supported_address_data, max_resource_size, is_addressbook?.
|
|
11
|
+
4. + CardDAV::VERSION
|
|
12
|
+
5. + carddav.rb.gemspec
|
|
13
|
+
6. + CHANGELOG
|
|
14
|
+
7. + Gemfile
|
|
15
|
+
8. + LICENSE
|
|
16
|
+
9. + Rakefile
|
|
17
|
+
10. + README.md
|
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,155 @@
|
|
|
1
|
+
# carddav.rb
|
|
2
|
+
|
|
3
|
+
A CardDAV client library for Ruby, built on [webdav](https://github.com/thoran/webdav) and implementing RFC 6352. This release ships **Layer 1 (Protocol)** of a planned three-layer ecosystem.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
gem install carddav.rb
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or in your Gemfile:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
gem 'carddav.rb'
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## Concepts
|
|
20
|
+
|
|
21
|
+
carddav.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 CardDAV verbs, multistatus responses, and namespace-aware navigation accessors. The equivalent of a raw protocol library: it returns CardDAV-typed responses but does not parse vCard 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.address_data` and parsing it with a vCard gem such as `vpim`.
|
|
24
|
+
- **Layer 2 — Objects** (future, `require 'carddav.rb/objects'`). `CardDAV::Card`, `CardDAV::Addressbook`, and `CardDAV::Principal` value objects with vCard parsing and convenience predicates. The principal object will expose `CARDDAV:principal-address` (RFC 6352 §7.1.2) — the vCard that identifies the principal — which Layer 1 cannot surface, since discovery returns bare strings rather than principal objects.
|
|
25
|
+
- **Layer 3 — Queryable** (future, `require 'carddav.rb/queryable'`). A [Namo](https://github.com/thoran/namo)-backed `CardDAV::Query::Addressbook` exposing contacts as queryable rows with derived columns. Its fluent `addressbook-query` filter builder will negotiate `CARDDAV:supported-collation-set` (RFC 6352 §8.3.1) for text-match collations — also reserved until then.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require 'carddav.rb'
|
|
32
|
+
|
|
33
|
+
carddav = CardDAV.new('https://carddav.example.com/dav/', username: 'user', password: 'pass')
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Discovery
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
principal = carddav.current_user_principal
|
|
40
|
+
home = carddav.addressbook_home_set(principal)
|
|
41
|
+
carddav.addressbooks(home).each{|href| puts href}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Each discovery helper defaults its argument to the result of the previous step, so `carddav.addressbooks` alone walks principal → home-set → addressbooks.
|
|
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 CardDAV context — e.g. `https://carddav.example.com/dav/` rather than `https://carddav.example.com/`. (RFC 6764 `.well-known/carddav` redirects are not auto-followed for PROPFIND in this release.) You can also override the starting point per call: `carddav.current_user_principal('/dav/')`.
|
|
47
|
+
|
|
48
|
+
### Querying an addressbook
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
result = carddav.addressbook_query('/addressbooks/user/contacts/', body: query_xml)
|
|
52
|
+
result.resources.each do |resource|
|
|
53
|
+
puts resource.href
|
|
54
|
+
puts resource.address_data # the raw vCard string
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Fetching specific cards
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
result = carddav.addressbook_multiget('/addressbooks/user/contacts/', body: multiget_xml)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Creating an addressbook
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
carddav.mkcol('/addressbooks/user/new/', body: mkcol_xml)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
CardDAV has no `MKCARDDAV` method; an addressbook is created with an **extended MKCOL** (RFC 6352 §6.3.1) — a standard MKCOL carrying a body that sets `resourcetype` to include `<c:addressbook/>`. `CardDAV#mkcol` overrides webdav's zero-body `#mkcol` to accept that optional body.
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
## Methods
|
|
74
|
+
|
|
75
|
+
### Protocol verbs (RFC 6352)
|
|
76
|
+
|
|
77
|
+
- `mkcol(path, body:)` — §6.3.1. Create an addressbook collection via extended MKCOL. Returns a `WebDAV::Response`.
|
|
78
|
+
- `addressbook_query(path, body:, depth:)` — §8.6. REPORT with a `<c:addressbook-query>` body. Returns a `CardDAV::MultiStatus`.
|
|
79
|
+
- `addressbook_multiget(path, body:, depth:)` — §8.7. REPORT with a `<c:addressbook-multiget>` body. Returns a `CardDAV::MultiStatus`.
|
|
80
|
+
|
|
81
|
+
### Discovery
|
|
82
|
+
|
|
83
|
+
- `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.
|
|
84
|
+
- `addressbook_home_set(principal = current_user_principal)` — returns the addressbook-home-set URL string.
|
|
85
|
+
- `addressbooks(home = addressbook_home_set)` — returns an array of addressbook collection URL strings.
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
## Responses
|
|
89
|
+
|
|
90
|
+
The REPORT verbs return `CardDAV::MultiStatus`, a type-preserving subclass of `WebDAV::MultiStatus` whose `resources` are `CardDAV::Resource` objects. Each `CardDAV::Resource` adds CardDAV-namespace navigation accessors over the underlying webdav resource:
|
|
91
|
+
|
|
92
|
+
- `href` — the resource URL
|
|
93
|
+
- `address_data` — the vCard string from `<c:address-data>`
|
|
94
|
+
- `addressbook_description` — `<c:addressbook-description>`
|
|
95
|
+
- `supported_address_data` — `<c:supported-address-data>`
|
|
96
|
+
- `max_resource_size` — `<c:max-resource-size>`
|
|
97
|
+
- `is_addressbook?` — true when `<d:resourcetype>` includes `<c:addressbook/>`
|
|
98
|
+
|
|
99
|
+
These are strictly navigation: they return raw strings and values, never parsed vCard objects. Parsing is Layer 2's job.
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
## Limitations
|
|
103
|
+
|
|
104
|
+
This is a Layer 1 protocol release. Known boundaries:
|
|
105
|
+
|
|
106
|
+
- **Read-only.** Conditional writes need `If-Match`, which webdav's `put` does not yet expose; writes arrive with Layer 2.
|
|
107
|
+
- **No vCard parsing.** Use a vCard gem (e.g. `vpim`) on `resource.address_data` if you need parsed contacts now.
|
|
108
|
+
- **Basic auth only.** No OAuth.
|
|
109
|
+
- **Not yet verified against a live server.** The integration suite is in place but ships without a committed cassette; supply credentials to record one and exercise it (see below).
|
|
110
|
+
- **No sync-collection.** Incremental sync is deferred.
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
## Dependencies
|
|
114
|
+
|
|
115
|
+
- [webdav](https://github.com/thoran/webdav)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
## Testing
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
rake
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Unit tests stub at the request boundary and need no network. A separate set of
|
|
125
|
+
integration tests (`test/integration_test.rb`) run against a real CardDAV server
|
|
126
|
+
via [VCR](https://github.com/vcr/vcr): they record real interactions into
|
|
127
|
+
host- and credential-scrubbed cassettes under `test/cassettes/` on first run, then
|
|
128
|
+
replay offline. Without a cassette and without credentials they skip, so the
|
|
129
|
+
default suite stays green.
|
|
130
|
+
|
|
131
|
+
To record against a live account, supply the server and credentials through the
|
|
132
|
+
environment and run `rake`:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
CARDDAV_URL='https://your-carddav-host/' \
|
|
136
|
+
CARDDAV_USERNAME='you@example.com' \
|
|
137
|
+
CARDDAV_PASSWORD='app-password' \
|
|
138
|
+
rake
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
See [test/cassettes/README.md](test/cassettes/README.md) for details.
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
## Contributing
|
|
145
|
+
|
|
146
|
+
1. Fork it [https://github.com/thoran/carddav.rb/fork](https://github.com/thoran/carddav.rb/fork)
|
|
147
|
+
2. Create your feature branch (git checkout -b my-new-feature)
|
|
148
|
+
3. Commit your changes (git commit -am 'Add some feature')
|
|
149
|
+
4. Push to the branch (git push origin my-new-feature)
|
|
150
|
+
5. Create a new pull request
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
## Licence
|
|
154
|
+
|
|
155
|
+
MIT
|
data/Rakefile
ADDED
data/carddav.rb.gemspec
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require_relative './lib/CardDAV/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 = 'carddav.rb'
|
|
15
|
+
spec.version = CardDAV::VERSION
|
|
16
|
+
|
|
17
|
+
spec.summary = "A Ruby CardDAV client library."
|
|
18
|
+
spec.description = "A Ruby CardDAV client library (RFC 6352)."
|
|
19
|
+
|
|
20
|
+
spec.author = 'thoran'
|
|
21
|
+
spec.email = 'code@thoran.com'
|
|
22
|
+
spec.homepage = 'https://github.com/thoran/carddav'
|
|
23
|
+
spec.license = 'MIT'
|
|
24
|
+
|
|
25
|
+
spec.required_ruby_version = '>= 3.2'
|
|
26
|
+
spec.require_paths = ['lib']
|
|
27
|
+
|
|
28
|
+
spec.files = [
|
|
29
|
+
'carddav.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
|
+
# CardDAV/MultiStatus.rb
|
|
2
|
+
# CardDAV::MultiStatus
|
|
3
|
+
|
|
4
|
+
require 'webdav'
|
|
5
|
+
|
|
6
|
+
require_relative './Resource'
|
|
7
|
+
|
|
8
|
+
class CardDAV < WebDAV
|
|
9
|
+
class MultiStatus < WebDAV::MultiStatus
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def parse_response(response_element)
|
|
14
|
+
CardDAV::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, addressbook-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,69 @@
|
|
|
1
|
+
# CardDAV/Resource.rb
|
|
2
|
+
# CardDAV::Resource
|
|
3
|
+
|
|
4
|
+
require 'webdav'
|
|
5
|
+
|
|
6
|
+
class CardDAV < 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 vCard string or value.
|
|
24
|
+
|
|
25
|
+
def address_data
|
|
26
|
+
property(NAMESPACE, 'address-data')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def addressbook_description
|
|
30
|
+
property(NAMESPACE, 'addressbook-description')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def supported_address_data
|
|
34
|
+
property(NAMESPACE, 'supported-address-data')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def max_resource_size
|
|
38
|
+
property(NAMESPACE, 'max-resource-size')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Matches an <addressbook> element (under any namespace prefix) in the
|
|
42
|
+
# serialised resourcetype, rather than a bare 'addressbook' substring (RFC 6352
|
|
43
|
+
# §5.2). There is no addressbook-proxy collision as there is for calendars, but
|
|
44
|
+
# the namespaced-element match is kept for symmetry and safety.
|
|
45
|
+
def is_addressbook?
|
|
46
|
+
resource_type.match?(%r{<(?:\w+:)?addressbook(?:\s[^>]*)?/?>})
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def initialize(hash)
|
|
52
|
+
@hash = hash
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def properties
|
|
56
|
+
@properties ||= propstats.each_with_object({}) do |propstat, result|
|
|
57
|
+
result.merge!(propstat[:properties])
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def property(namespace, name)
|
|
62
|
+
properties.dig(namespace, name)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def resource_type
|
|
66
|
+
property(DAV_NAMESPACE, 'resourcetype').to_s
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/carddav.rb
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# CardDAV.rb
|
|
2
|
+
# CardDAV
|
|
3
|
+
|
|
4
|
+
require 'webdav'
|
|
5
|
+
|
|
6
|
+
require_relative './CardDAV/MultiStatus'
|
|
7
|
+
require_relative './CardDAV/Resource'
|
|
8
|
+
require_relative './CardDAV/VERSION'
|
|
9
|
+
|
|
10
|
+
class CardDAV < WebDAV
|
|
11
|
+
NAMESPACE = 'urn:ietf:params:xml:ns:carddav'
|
|
12
|
+
DAV_NAMESPACE = 'DAV:'
|
|
13
|
+
|
|
14
|
+
# Protocol verbs (RFC 6352)
|
|
15
|
+
|
|
16
|
+
# RFC 6352 §6.3.1. Extended MKCOL — creates an addressbook collection. CardDAV
|
|
17
|
+
# has no MKCARDDAV: an addressbook is made with a standard MKCOL carrying a body
|
|
18
|
+
# that sets resourcetype to include <c:addressbook/>. Overrides the inherited
|
|
19
|
+
# zero-body WebDAV#mkcol to add the optional body (a deliberate widening, not a
|
|
20
|
+
# shadow). Returns a WebDAV::Response.
|
|
21
|
+
def mkcol(path, body: nil)
|
|
22
|
+
response = request(:mkcol, path, body: body)
|
|
23
|
+
handle_response(response)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# RFC 6352 §8.6. REPORT with a <c:addressbook-query> body. The filter grammar
|
|
27
|
+
# (prop-filter/text-match) is the caller's XML at Layer 1; a fluent filter
|
|
28
|
+
# builder — which would negotiate CARDDAV:supported-collation-set (§8.3.1) — is
|
|
29
|
+
# reserved for Layer 3.
|
|
30
|
+
def addressbook_query(path, body:, depth: '1')
|
|
31
|
+
report_as_carddav(path, body: body, depth: depth)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# RFC 6352 §8.7. REPORT with a <c:addressbook-multiget> body.
|
|
35
|
+
def addressbook_multiget(path, body:, depth: '0')
|
|
36
|
+
report_as_carddav(path, body: body, depth: depth)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Discovery helpers. Return paths/strings. An object-oriented discovery API
|
|
40
|
+
# (Principal/Addressbook objects) is planned. The CARDDAV:principal-address
|
|
41
|
+
# property (§7.1.2), which identifies a principal's own vCard, is a principal
|
|
42
|
+
# property — reserved for Layer 2 (Objects) alongside CardDAV::Principal, since
|
|
43
|
+
# Layer 1 has no discovery path returning a principal Resource to read it from.
|
|
44
|
+
|
|
45
|
+
def current_user_principal(path = discovery_path)
|
|
46
|
+
resource = propfind_as_carddav(path, body: current_user_principal_body, depth: '0').resources.first
|
|
47
|
+
resource&.then{|r| href_property(r, DAV_NAMESPACE, 'current-user-principal')}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def addressbook_home_set(principal = current_user_principal)
|
|
51
|
+
resource = propfind_as_carddav(principal, body: addressbook_home_set_body, depth: '0').resources.first
|
|
52
|
+
resource&.then{|r| href_property(r, NAMESPACE, 'addressbook-home-set')}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def addressbooks(home = addressbook_home_set)
|
|
56
|
+
resources = propfind_as_carddav(home, body: addressbooks_body, depth: '1').resources
|
|
57
|
+
resources.select{|resource| resource.is_addressbook?}.collect{|resource| resource.href}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Send a REPORT and return the CardDAV-typed multistatus rather than the plain
|
|
63
|
+
# WebDAV one. Constructed from the raw response so the CardDAV resource wrappers
|
|
64
|
+
# are produced by CardDAV::MultiStatus's overridden parse_response.
|
|
65
|
+
def report_as_carddav(path, body:, depth:)
|
|
66
|
+
response = request(:report, path, body: body, headers: {'Depth' => depth})
|
|
67
|
+
raise WebDAV::Error.new(response) if response.code.to_i >= 400
|
|
68
|
+
CardDAV::MultiStatus.new(response)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# As report_as_carddav, but for PROPFIND. Needed because the inherited #propfind
|
|
72
|
+
# returns a plain WebDAV::MultiStatus whose resources are bare hashes, not
|
|
73
|
+
# CardDAV::Resource wrappers. Discovery relies on the wrappers (#is_addressbook?,
|
|
74
|
+
# #href), so it routes through here.
|
|
75
|
+
def propfind_as_carddav(path, body:, depth:)
|
|
76
|
+
response = request(:propfind, path, body: body, headers: {'Depth' => depth})
|
|
77
|
+
raise WebDAV::Error.new(response) if response.code.to_i >= 400
|
|
78
|
+
CardDAV::MultiStatus.new(response)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The discovery properties (current-user-principal, addressbook-home-set) wrap
|
|
82
|
+
# their value in a nested <d:href>. webdav 0.2.0 renders a nested-element
|
|
83
|
+
# property as serialised markup, so the bare URL is lifted back out here.
|
|
84
|
+
# Caveat: when a server pretty-prints, webdav's `text || to_s` returns the
|
|
85
|
+
# leading whitespace text node and the href is lost upstream; recovering that
|
|
86
|
+
# waits for the webdav-objectifies-resources rework.
|
|
87
|
+
def href_property(resource, namespace, name)
|
|
88
|
+
resource.propstats.each do |propstat|
|
|
89
|
+
href = extract_href(propstat[:properties].dig(namespace, name))
|
|
90
|
+
return href if href
|
|
91
|
+
end
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def extract_href(value)
|
|
96
|
+
return nil unless value
|
|
97
|
+
match = value.match(%r{<(?:\w+:)?href[^>]*>(.*?)</(?:\w+:)?href>}m)
|
|
98
|
+
href = (match ? match[1] : value).strip
|
|
99
|
+
href.empty? ? nil : href
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Discovery starts from the path of the configured base URL, so a server that
|
|
103
|
+
# does not expose the current-user-principal at '/' is reached by giving its
|
|
104
|
+
# CardDAV context path in the base URL (e.g. https://host/dav/). Falls back to
|
|
105
|
+
# '/' when the base URL has no path.
|
|
106
|
+
def discovery_path
|
|
107
|
+
@uri.path.empty? ? '/' : @uri.path
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def current_user_principal_body
|
|
111
|
+
<<~XML
|
|
112
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
113
|
+
<d:propfind xmlns:d="DAV:">
|
|
114
|
+
<d:prop><d:current-user-principal/></d:prop>
|
|
115
|
+
</d:propfind>
|
|
116
|
+
XML
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def addressbook_home_set_body
|
|
120
|
+
<<~XML
|
|
121
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
122
|
+
<d:propfind xmlns:d="DAV:" xmlns:c="#{NAMESPACE}">
|
|
123
|
+
<d:prop><c:addressbook-home-set/></d:prop>
|
|
124
|
+
</d:propfind>
|
|
125
|
+
XML
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def addressbooks_body
|
|
129
|
+
<<~XML
|
|
130
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
131
|
+
<d:propfind xmlns:d="DAV:">
|
|
132
|
+
<d:prop>
|
|
133
|
+
<d:resourcetype/>
|
|
134
|
+
</d:prop>
|
|
135
|
+
</d:propfind>
|
|
136
|
+
XML
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# test/CardDAV/MultiStatus_test.rb
|
|
2
|
+
|
|
3
|
+
require_relative '../helper'
|
|
4
|
+
|
|
5
|
+
describe CardDAV::MultiStatus do
|
|
6
|
+
let(:body) do
|
|
7
|
+
<<~XML
|
|
8
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
9
|
+
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
|
|
10
|
+
<d:response>
|
|
11
|
+
<d:href>/addressbooks/user/contacts/</d:href>
|
|
12
|
+
<d:propstat>
|
|
13
|
+
<d:prop>
|
|
14
|
+
<d:resourcetype>
|
|
15
|
+
<d:collection/>
|
|
16
|
+
<c:addressbook/>
|
|
17
|
+
</d:resourcetype>
|
|
18
|
+
<c:addressbook-description>Work contacts</c:addressbook-description>
|
|
19
|
+
</d:prop>
|
|
20
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
21
|
+
</d:propstat>
|
|
22
|
+
</d:response>
|
|
23
|
+
<d:response>
|
|
24
|
+
<d:href>/addressbooks/user/personal/</d:href>
|
|
25
|
+
<d:propstat>
|
|
26
|
+
<d:prop>
|
|
27
|
+
<d:resourcetype>
|
|
28
|
+
<d:collection/>
|
|
29
|
+
<c:addressbook/>
|
|
30
|
+
</d:resourcetype>
|
|
31
|
+
</d:prop>
|
|
32
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
33
|
+
</d:propstat>
|
|
34
|
+
</d:response>
|
|
35
|
+
</d:multistatus>
|
|
36
|
+
XML
|
|
37
|
+
end
|
|
38
|
+
let(:multistatus){CardDAV::MultiStatus.new(MockResponse.new(code: '207', message: 'Multi-Status', body: body))}
|
|
39
|
+
let(:contacts){multistatus.resources[0]}
|
|
40
|
+
let(:contacts_properties){contacts.propstats.first[:properties]}
|
|
41
|
+
|
|
42
|
+
it "parses one resource per response element" do
|
|
43
|
+
_(multistatus.resources.size).must_equal 2
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "wraps every response as a CardDAV::Resource" do
|
|
47
|
+
_(multistatus.resources.map(&:class).uniq).must_equal [CardDAV::Resource]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "preserves response order" do
|
|
51
|
+
_(multistatus.resources.collect(&:href)).must_equal ['/addressbooks/user/contacts/', '/addressbooks/user/personal/']
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "keeps a pretty-printed nested property as serialised markup, not whitespace" do
|
|
55
|
+
_(contacts_properties[CardDAV::DAV_NAMESPACE]['resourcetype']).must_match(%r{<(?:\w+:)?addressbook})
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "keeps a flat property's text" do
|
|
59
|
+
_(contacts_properties[CardDAV::NAMESPACE]['addressbook-description']).must_equal 'Work contacts'
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# test/CardDAV/Resource_test.rb
|
|
2
|
+
|
|
3
|
+
require_relative '../helper'
|
|
4
|
+
|
|
5
|
+
describe CardDAV::Resource do
|
|
6
|
+
let(:addressbook_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:carddav">
|
|
10
|
+
<d:response>
|
|
11
|
+
<d:href>/addressbooks/user/contacts/</d:href>
|
|
12
|
+
<d:propstat>
|
|
13
|
+
<d:prop>
|
|
14
|
+
<d:resourcetype><d:collection/><c:addressbook/></d:resourcetype>
|
|
15
|
+
<c:addressbook-description>Work contacts</c:addressbook-description>
|
|
16
|
+
<c:supported-address-data>
|
|
17
|
+
<c:address-data-type content-type="text/vcard" version="3.0"/>
|
|
18
|
+
</c:supported-address-data>
|
|
19
|
+
<c:max-resource-size>10485760</c:max-resource-size>
|
|
20
|
+
</d:prop>
|
|
21
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
22
|
+
</d:propstat>
|
|
23
|
+
</d:response>
|
|
24
|
+
<d:response>
|
|
25
|
+
<d:href>/addressbooks/user/contacts/card.vcf</d:href>
|
|
26
|
+
<d:propstat>
|
|
27
|
+
<d:prop>
|
|
28
|
+
<d:getetag>"abc123"</d:getetag>
|
|
29
|
+
<c:address-data>BEGIN:VCARD</c:address-data>
|
|
30
|
+
</d:prop>
|
|
31
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
32
|
+
</d:propstat>
|
|
33
|
+
</d:response>
|
|
34
|
+
<d:response>
|
|
35
|
+
<d:href>/addressbooks/user/addressbook-proxy-read/</d:href>
|
|
36
|
+
<d:propstat>
|
|
37
|
+
<d:prop>
|
|
38
|
+
<d:resourcetype><d:collection/><cs:addressbook-proxy-read xmlns:cs="http://calendarserver.org/ns/"/></d:resourcetype>
|
|
39
|
+
</d:prop>
|
|
40
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
41
|
+
</d:propstat>
|
|
42
|
+
</d:response>
|
|
43
|
+
</d:multistatus>
|
|
44
|
+
XML
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
let(:multistatus){CardDAV::MultiStatus.new(MockResponse.new(code: '207', message: 'Multi-Status', body: addressbook_xml))}
|
|
48
|
+
let(:addressbook_resource){multistatus.resources[0]}
|
|
49
|
+
let(:card_resource){multistatus.resources[1]}
|
|
50
|
+
let(:proxy_resource){multistatus.resources[2]}
|
|
51
|
+
|
|
52
|
+
it "wraps resources as CardDAV::Resource" do
|
|
53
|
+
_(addressbook_resource).must_be_kind_of CardDAV::Resource
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "exposes href" do
|
|
57
|
+
_(addressbook_resource.href).must_equal '/addressbooks/user/contacts/'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "reads addressbook-description" do
|
|
61
|
+
_(addressbook_resource.addressbook_description).must_equal 'Work contacts'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "reads address-data" do
|
|
65
|
+
_(card_resource.address_data).must_equal 'BEGIN:VCARD'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "reads supported-address-data as serialised markup" do
|
|
69
|
+
_(addressbook_resource.supported_address_data).must_match %r{<(?:\w+:)?address-data-type\b[^>]*content-type=['"]text/vcard['"][^>]*version=['"]3\.0['"]}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "reads max-resource-size" do
|
|
73
|
+
_(addressbook_resource.max_resource_size).must_equal '10485760'
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "identifies an addressbook collection" do
|
|
77
|
+
_(addressbook_resource.is_addressbook?).must_equal true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "identifies a non-addressbook resource" do
|
|
81
|
+
_(card_resource.is_addressbook?).must_equal false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "does not mistake an addressbook-proxy collection for an addressbook" do
|
|
85
|
+
_(proxy_resource.is_addressbook?).must_equal false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "identifies an addressbook from a pretty-printed (indented) resourcetype" do
|
|
89
|
+
pretty = CardDAV::MultiStatus.new(MockResponse.new(code: '207', message: 'Multi-Status', body: <<~XML))
|
|
90
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
91
|
+
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
|
|
92
|
+
<d:response>
|
|
93
|
+
<d:href>/dav/addressbooks/user/x/contacts/</d:href>
|
|
94
|
+
<d:propstat>
|
|
95
|
+
<d:prop>
|
|
96
|
+
<d:resourcetype>
|
|
97
|
+
<d:collection/>
|
|
98
|
+
<c:addressbook/>
|
|
99
|
+
</d:resourcetype>
|
|
100
|
+
</d:prop>
|
|
101
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
102
|
+
</d:propstat>
|
|
103
|
+
</d:response>
|
|
104
|
+
</d:multistatus>
|
|
105
|
+
XML
|
|
106
|
+
_(pretty.resources.first.is_addressbook?).must_equal true
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# test/carddav_test.rb
|
|
2
|
+
|
|
3
|
+
require_relative './helper'
|
|
4
|
+
|
|
5
|
+
describe CardDAV do
|
|
6
|
+
let(:base_uri){'https://carddav.example.com/'}
|
|
7
|
+
let(:carddav){CardDAV.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:carddav">
|
|
16
|
+
<d:response>
|
|
17
|
+
<d:href>/addressbooks/user/contacts/card.vcf</d:href>
|
|
18
|
+
<d:propstat>
|
|
19
|
+
<d:prop>
|
|
20
|
+
<d:getetag>"abc123"</d:getetag>
|
|
21
|
+
<c:address-data>BEGIN:VCARD</c:address-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 "#mkcol" do
|
|
34
|
+
it "returns a Response" do
|
|
35
|
+
carddav.stub(:request, created_response) do
|
|
36
|
+
result = carddav.mkcol('/addressbooks/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 "#addressbook_query" do
|
|
44
|
+
it "returns a CardDAV::MultiStatus" do
|
|
45
|
+
carddav.stub(:request, multistatus_response) do
|
|
46
|
+
result = carddav.addressbook_query('/addressbooks/user/contacts/', body: '<c:addressbook-query/>')
|
|
47
|
+
_(result).must_be_kind_of CardDAV::MultiStatus
|
|
48
|
+
_(result.resources.first).must_be_kind_of CardDAV::Resource
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
describe "#addressbook_multiget" do
|
|
54
|
+
it "returns a CardDAV::MultiStatus" do
|
|
55
|
+
carddav.stub(:request, multistatus_response) do
|
|
56
|
+
result = carddav.addressbook_multiget('/addressbooks/user/contacts/', body: '<c:addressbook-multiget/>')
|
|
57
|
+
_(result).must_be_kind_of CardDAV::MultiStatus
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
describe "#current_user_principal" do
|
|
63
|
+
let(:response) do
|
|
64
|
+
MockResponse.new(code: '207', message: 'Multi-Status', body: <<~XML)
|
|
65
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
66
|
+
<d:multistatus xmlns:d="DAV:">
|
|
67
|
+
<d:response>
|
|
68
|
+
<d:href>/</d:href>
|
|
69
|
+
<d:propstat>
|
|
70
|
+
<d:prop><d:current-user-principal><d:href>/dav/principals/user/abc/</d:href></d:current-user-principal></d:prop>
|
|
71
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
72
|
+
</d:propstat>
|
|
73
|
+
</d:response>
|
|
74
|
+
</d:multistatus>
|
|
75
|
+
XML
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "returns the principal href as a bare URL string" do
|
|
79
|
+
carddav.stub(:request, response) do
|
|
80
|
+
_(carddav.current_user_principal).must_equal '/dav/principals/user/abc/'
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "PROPFINDs the base URL's path by default, not '/'" do
|
|
85
|
+
dav = CardDAV.new('https://carddav.example.com/dav/', username: 'u', password: 'p')
|
|
86
|
+
captured = nil
|
|
87
|
+
responder = ->(verb, path, **kw){captured = path; response}
|
|
88
|
+
dav.stub(:request, responder) do
|
|
89
|
+
dav.current_user_principal
|
|
90
|
+
end
|
|
91
|
+
_(captured).must_equal '/dav/'
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "extracts the principal from a pretty-printed (indented) response" do
|
|
95
|
+
pretty = MockResponse.new(code: '207', message: 'Multi-Status', body: <<~XML)
|
|
96
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
97
|
+
<d:multistatus xmlns:d="DAV:">
|
|
98
|
+
<d:response>
|
|
99
|
+
<d:href>/dav/</d:href>
|
|
100
|
+
<d:propstat>
|
|
101
|
+
<d:prop>
|
|
102
|
+
<d:current-user-principal>
|
|
103
|
+
<d:href>/dav/principals/user/abc/</d:href>
|
|
104
|
+
</d:current-user-principal>
|
|
105
|
+
</d:prop>
|
|
106
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
107
|
+
</d:propstat>
|
|
108
|
+
</d:response>
|
|
109
|
+
</d:multistatus>
|
|
110
|
+
XML
|
|
111
|
+
carddav.stub(:request, pretty) do
|
|
112
|
+
_(carddav.current_user_principal).must_equal '/dav/principals/user/abc/'
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
describe "#addressbook_home_set" do
|
|
118
|
+
let(:response) do
|
|
119
|
+
MockResponse.new(code: '207', message: 'Multi-Status', body: <<~XML)
|
|
120
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
121
|
+
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
|
|
122
|
+
<d:response>
|
|
123
|
+
<d:href>/dav/principals/user/abc/</d:href>
|
|
124
|
+
<d:propstat>
|
|
125
|
+
<d:prop><c:addressbook-home-set><d:href>/dav/addressbooks/user/abc/</d:href></c:addressbook-home-set></d:prop>
|
|
126
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
127
|
+
</d:propstat>
|
|
128
|
+
</d:response>
|
|
129
|
+
</d:multistatus>
|
|
130
|
+
XML
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "returns the home-set href as a bare URL string" do
|
|
134
|
+
carddav.stub(:request, response) do
|
|
135
|
+
_(carddav.addressbook_home_set('/dav/principals/user/abc/')).must_equal '/dav/addressbooks/user/abc/'
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
describe "#addressbooks" do
|
|
141
|
+
let(:response) do
|
|
142
|
+
MockResponse.new(code: '207', message: 'Multi-Status', body: <<~XML)
|
|
143
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
144
|
+
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/">
|
|
145
|
+
<d:response>
|
|
146
|
+
<d:href>/dav/addressbooks/user/abc/</d:href>
|
|
147
|
+
<d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
|
148
|
+
</d:response>
|
|
149
|
+
<d:response>
|
|
150
|
+
<d:href>/dav/addressbooks/user/abc/contacts/</d:href>
|
|
151
|
+
<d:propstat><d:prop><d:resourcetype><d:collection/><c:addressbook/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
|
152
|
+
</d:response>
|
|
153
|
+
<d:response>
|
|
154
|
+
<d:href>/dav/addressbooks/user/abc/proxy/</d:href>
|
|
155
|
+
<d:propstat><d:prop><d:resourcetype><d:collection/><cs:addressbook-proxy-read/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
|
|
156
|
+
</d:response>
|
|
157
|
+
</d:multistatus>
|
|
158
|
+
XML
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "returns only the addressbook collection hrefs as bare URL strings" do
|
|
162
|
+
carddav.stub(:request, response) do
|
|
163
|
+
_(carddav.addressbooks('/dav/addressbooks/user/abc/')).must_equal ['/dav/addressbooks/user/abc/contacts/']
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
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/carddav'
|
|
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 'CardDAV integration' do
|
|
7
|
+
let(:carddav){carddav_client}
|
|
8
|
+
|
|
9
|
+
let(:contacts_query_body) do
|
|
10
|
+
<<~XML
|
|
11
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
12
|
+
<c:addressbook-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
|
|
13
|
+
<d:prop>
|
|
14
|
+
<d:getetag/>
|
|
15
|
+
<c:address-data/>
|
|
16
|
+
</d:prop>
|
|
17
|
+
<c:filter>
|
|
18
|
+
<c:prop-filter name="FN">
|
|
19
|
+
<c:text-match collation="i;unicode-casemap" match-type="contains">a</c:text-match>
|
|
20
|
+
</c:prop-filter>
|
|
21
|
+
</c:filter>
|
|
22
|
+
</c:addressbook-query>
|
|
23
|
+
XML
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "discovers principal, home-set and addressbooks as bare URL strings" do
|
|
27
|
+
with_carddav_cassette('discovery') do
|
|
28
|
+
principal = carddav.current_user_principal
|
|
29
|
+
_(principal).must_be_kind_of String
|
|
30
|
+
_(principal).wont_include '<'
|
|
31
|
+
home = carddav.addressbook_home_set(principal)
|
|
32
|
+
_(home).must_be_kind_of String
|
|
33
|
+
_(home).wont_include '<'
|
|
34
|
+
addressbooks = carddav.addressbooks(home)
|
|
35
|
+
_(addressbooks).must_be_kind_of Array
|
|
36
|
+
addressbooks.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 addressbook and returns CardDAV resources" do
|
|
44
|
+
with_carddav_cassette('addressbook_query') do
|
|
45
|
+
addressbook = carddav.addressbooks.first
|
|
46
|
+
skip 'no addressbooks on this account' unless addressbook
|
|
47
|
+
result = carddav.addressbook_query(addressbook, body: contacts_query_body)
|
|
48
|
+
_(result).must_be_kind_of CardDAV::MultiStatus
|
|
49
|
+
result.resources.each{|resource| _(resource).must_be_kind_of CardDAV::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_CARDDAV_URL = 'https://carddav.example.com/dav/'
|
|
7
|
+
CARDDAV_URL = ENV.fetch('CARDDAV_URL', EXAMPLE_CARDDAV_URL)
|
|
8
|
+
|
|
9
|
+
# Replaces the account-identifier segment in CardDAV collection paths (e.g.
|
|
10
|
+
# /principals/user/<id>/ or /addressbooks/user/<id>/) with a fixed placeholder, so
|
|
11
|
+
# a committed cassette does not expose an account-specific id. The collection name
|
|
12
|
+
# after the id (an addressbook 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|addressbooks)/[^/]+/)[^/]+/}
|
|
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_CARDDAV_URL).host){URI(ENV['CARDDAV_URL']).host if ENV['CARDDAV_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('<CARDDAV_USERNAME>'){ENV['CARDDAV_USERNAME']}
|
|
27
|
+
config.filter_sensitive_data('<CARDDAV_PASSWORD>'){ENV['CARDDAV_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 carddav_client
|
|
38
|
+
CardDAV.new(
|
|
39
|
+
CARDDAV_URL,
|
|
40
|
+
username: ENV.fetch('CARDDAV_USERNAME', 'recorded'),
|
|
41
|
+
password: ENV.fetch('CARDDAV_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_carddav_cassette(name)
|
|
50
|
+
cassette = File.join(VCR.configuration.cassette_library_dir, "#{name}.yml")
|
|
51
|
+
unless File.exist?(cassette) || ENV['CARDDAV_USERNAME']
|
|
52
|
+
raise Minitest::Skip, "no cassette '#{name}.yml' and CARDDAV_USERNAME unset; export CARDDAV_URL/CARDDAV_USERNAME/CARDDAV_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 an addressbooks collection, keeping the addressbook name" do
|
|
12
|
+
_(anonymise_account_path('/dav/addressbooks/user/abc123/contacts/')).must_equal '/dav/addressbooks/user/anon/contacts/'
|
|
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/addressbooks/user/abc123/</d:href>'
|
|
21
|
+
_(anonymise_account_path(body)).must_equal '<d:href>/dav/addressbooks/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,140 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: carddav.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 CardDAV client library (RFC 6352).
|
|
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
|
+
- carddav.rb.gemspec
|
|
108
|
+
- lib/CardDAV/MultiStatus.rb
|
|
109
|
+
- lib/CardDAV/Resource.rb
|
|
110
|
+
- lib/CardDAV/VERSION.rb
|
|
111
|
+
- lib/carddav.rb
|
|
112
|
+
- test/CardDAV/MultiStatus_test.rb
|
|
113
|
+
- test/CardDAV/Resource_test.rb
|
|
114
|
+
- test/carddav_test.rb
|
|
115
|
+
- test/helper.rb
|
|
116
|
+
- test/integration_test.rb
|
|
117
|
+
- test/vcr_helper.rb
|
|
118
|
+
- test/vcr_helper_test.rb
|
|
119
|
+
homepage: https://github.com/thoran/carddav
|
|
120
|
+
licenses:
|
|
121
|
+
- MIT
|
|
122
|
+
metadata: {}
|
|
123
|
+
rdoc_options: []
|
|
124
|
+
require_paths:
|
|
125
|
+
- lib
|
|
126
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '3.2'
|
|
131
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
132
|
+
requirements:
|
|
133
|
+
- - ">="
|
|
134
|
+
- !ruby/object:Gem::Version
|
|
135
|
+
version: '0'
|
|
136
|
+
requirements: []
|
|
137
|
+
rubygems_version: 4.0.10
|
|
138
|
+
specification_version: 4
|
|
139
|
+
summary: A Ruby CardDAV client library.
|
|
140
|
+
test_files: []
|