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 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
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
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
@@ -0,0 +1,9 @@
1
+ # Rakefile
2
+
3
+ require 'rake/testtask'
4
+
5
+ Rake::TestTask.new do |t|
6
+ t.test_files = FileList['test/**/*_test.rb']
7
+ end
8
+
9
+ task default: :test
@@ -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
@@ -0,0 +1,8 @@
1
+ # CardDAV/VERSION.rb
2
+ # CardDAV::VERSION
3
+
4
+ class WebDAV; end
5
+
6
+ class CardDAV < WebDAV
7
+ VERSION = '0.0.0'
8
+ 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
@@ -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: []