webdav 0.1.2 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 282fc0b7d1998d1002ce33a6a590d1b42b352d7918ed41c001a75fac0f473bfa
4
- data.tar.gz: 3c61685c5a13c143db955a376985b175fa7799efe13e986e8a7044bed8c27f2a
3
+ metadata.gz: 112d0cec672b4fba93b1b1b356ba14d148982ae94b9063c7df8ff0116ab2f775
4
+ data.tar.gz: 0e94db83b43f2dd4efed30a214931086d29278fb5eab5dc3cc0b4cecf1fdb051
5
5
  SHA512:
6
- metadata.gz: ec83adb732f914c8786003133ecdc4e9ebbc71a42e3d2e0b59d1b76c1b21e9ab48ffc5af3cea08a193676e71591e5c850da2af57977d3a12c354a7c32cc0b04d
7
- data.tar.gz: 761abe1872f20ca61c80ba3468709da9dea781116866c7ece0954cd67ddfdb65011981c859ffd15ec7bb79c7a9849be2db36c19e6b8a6ea56c55d7f4cef33993
6
+ metadata.gz: 1684e452204e2e57250698d6acd09d7603bd51fcaeea1ddb679edf7bb2d80ae7310ad122ffb3917b5d9fa1bfe0e6e4952e4c79c5db74068a554a73c4d793a165
7
+ data.tar.gz: 686bf1a9d4729235642d694379663ee5e9711291d5d524013a22ce05c10aac66b4235477cb8085e5208316830793f4cc58ef2ae6c26308ec7bb13ceacad70fc9
data/CHANGELOG CHANGED
@@ -1,5 +1,17 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 20260522
4
+
5
+ 0.2.0: Rewrite MultiStatus#parse; align dependencies with http.rb 1.0.0; document WebDAV concepts.
6
+
7
+ 1. ~ WebDAV::MultiStatus#parse: Walk propstat blocks individually; preserve per-propstat status; detect response-level status (for COPY/MOVE/DELETE); preserve namespace URIs on properties. Now private; resources remains the public accessor.
8
+ 2. ~ WebDAV::MultiStatus resource shape: Properties move from flat hash keyed by local name to nested hash keyed by namespace URI then local name. Resources now have :propstats (array of {properties:, status:}) and :status (response-level, may be nil) instead of a single :properties and :status pair.
9
+ 3. ~ test/WebDAV/MultiStatus_test.rb: Update existing tests for new shape; + tests for per-propstat status, response-level status, namespace preservation, and same-local-name collision across namespaces.
10
+ 4. ~ README.md: Document new MultiStatus#resources shape, with paired wire-XML and parsed-Ruby examples for both PROPFIND and collection-cascade Multi-Status; + WebDAV::Response wire example; + Concepts section (properties vs content, collections and the trailing slash, why 207 Multi-Status exists, namespaces).
11
+ 5. ~ webdav.gemspec: required_ruby_version />= 2.7/>= 3.2/; http.rb dependency /[]/['~> 1.0']/; minitest pin /'~> 5.27'/'~> 6.0'/; + minitest-mock (minitest 6 split it out).
12
+ 6. ~ WebDAV::VERSION: /0.1.2/0.2.0/
13
+ 7. ~ CHANGELOG: + 0.2.0 entry
14
+
3
15
  ## 20260520
4
16
 
5
17
  0.1.2: Fix Response#success? to return true only for 2xx responses.
data/README.md CHANGED
@@ -30,7 +30,14 @@ dav = WebDAV.new('https://dav.example.com/files/', username: 'user', password: '
30
30
  response = dav.propfind('/', depth: '1')
31
31
  response.resources.each do |resource|
32
32
  puts resource[:href]
33
- puts resource[:properties]
33
+ resource[:propstats].each do |propstat|
34
+ puts propstat[:status]
35
+ propstat[:properties].each do |namespace, properties|
36
+ properties.each do |name, value|
37
+ puts " #{namespace} #{name} = #{value}"
38
+ end
39
+ end
40
+ end
34
41
  end
35
42
  ```
36
43
 
@@ -92,6 +99,27 @@ end
92
99
  ```
93
100
 
94
101
 
102
+ ## Concepts
103
+
104
+ WebDAV extends HTTP with a few ideas that don't have direct REST analogues. The ones below explain why the API is shaped the way it is.
105
+
106
+ ### Properties vs content
107
+
108
+ A WebDAV resource has two faces. **Content** is what GET returns — the bytes of the file. **Properties** are metadata associated with the resource: display name, creation date, lock state, content type, and any custom properties the server defines. The same URL identifies both, but different verbs reach them — GET/PUT for content, PROPFIND/PROPPATCH for properties.
109
+
110
+ ### Collections and the trailing slash
111
+
112
+ A **collection** is WebDAV's directory: a resource that contains other resources. MKCOL creates one. By convention, collection URLs end in `/` and ordinary resources don't; servers that care about the distinction will redirect or 404 if you get it wrong. The distinction matters because COPY, MOVE, and DELETE on a collection cascade to its children — which is also why those verbs can return 207 Multi-Status when children succeed and fail independently.
113
+
114
+ ### Why 207 Multi-Status exists
115
+
116
+ HTTP assumes one request maps to one status. WebDAV breaks that assumption: PROPFIND on a folder asks about many resources at once; COPY of a tree may succeed on some children and fail on others. The 207 Multi-Status response code says "the request touched many things; here are per-thing outcomes." The XML body carries one `<d:response>` per affected resource. This gem returns those as `WebDAV::MultiStatus`; the `resources` accessor exposes the per-resource detail (see [Responses](#responses)).
117
+
118
+ ### Namespaces
119
+
120
+ WebDAV properties are XML elements, and XML elements belong to namespaces. The core RFC 4918 properties live in the `DAV:` namespace. Extensions — CalDAV (`urn:ietf:params:xml:ns:caldav`), CardDAV, Exchange, ownCloud, custom server vocabularies — each define their own. Properties from different namespaces can share local names (`<d:displayname>` and `<x:displayname>` are different properties), so the parser preserves namespace URIs as the outer key in the properties hash.
121
+
122
+
95
123
  ## Methods
96
124
 
97
125
  WebDAV extends HTTP with additional methods for distributed authoring. This gem provides all the methods defined in RFC 4918 ("HTTP Extensions for Web Distributed Authoring and Versioning") and the REPORT method from RFC 3253 ("Versioning Extensions to WebDAV"), which is essential for CalDAV and CardDAV queries.
@@ -149,12 +177,108 @@ All methods return either a `WebDAV::Response` or a `WebDAV::MultiStatus`.
149
177
  - `content_type`
150
178
  - `success?`
151
179
 
180
+ A `GET` response on the wire:
181
+
182
+ ```
183
+ HTTP/1.1 200 OK
184
+ Content-Type: text/plain
185
+ Content-Length: 13
186
+ ETag: "5d41402a"
187
+
188
+ Hello, world.
189
+ ```
190
+
191
+ Parses to:
192
+
193
+ ```ruby
194
+ response.code # => 200
195
+ response.message # => "OK"
196
+ response.body # => "Hello, world."
197
+ response.etag # => "\"5d41402a\""
198
+ response.content_type # => "text/plain"
199
+ response.success? # => true
200
+ ```
201
+
152
202
  `WebDAV::MultiStatus` additionally provides:
153
203
 
154
204
  - `resources` — an array of hashes, each with:
155
- - `href`
156
- - `properties`
157
- - `status`
205
+ - `href` — the resource URL
206
+ - `propstats` — an array of `{properties:, status:}` hashes (PROPFIND / PROPPATCH / REPORT). May be empty when the response carries a response-level status instead.
207
+ - `status` — the response-level status string (COPY / MOVE / DELETE). `nil` when the response has propstats instead.
208
+
209
+ Within a propstat, `properties` is a nested hash keyed first by XML namespace URI, then by local name. For example, a CalDAV calendar property appears as `propstat[:properties]['urn:ietf:params:xml:ns:caldav']['calendar-data']` and a DAV property as `propstat[:properties]['DAV:']['getetag']`. Keeping the namespace explicit prevents collisions between properties from different namespaces that share a local name.
210
+
211
+ A PROPFIND response — properties grouped by namespace, status per propstat, response-level status `nil`. The wire XML:
212
+
213
+ ```xml
214
+ <?xml version="1.0" encoding="UTF-8"?>
215
+ <d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
216
+ <d:response>
217
+ <d:href>/calendar/event.ics</d:href>
218
+ <d:propstat>
219
+ <d:prop>
220
+ <d:getetag>"abc123"</d:getetag>
221
+ <c:calendar-data>BEGIN:VCALENDAR...</c:calendar-data>
222
+ </d:prop>
223
+ <d:status>HTTP/1.1 200 OK</d:status>
224
+ </d:propstat>
225
+ <d:propstat>
226
+ <d:prop>
227
+ <d:getctag/>
228
+ </d:prop>
229
+ <d:status>HTTP/1.1 404 Not Found</d:status>
230
+ </d:propstat>
231
+ </d:response>
232
+ </d:multistatus>
233
+ ```
234
+
235
+ Parses to:
236
+
237
+ ```ruby
238
+ [
239
+ {
240
+ href: '/calendar/event.ics',
241
+ propstats: [
242
+ {
243
+ properties: {
244
+ 'DAV:' => {'getetag' => '"abc123"'},
245
+ 'urn:ietf:params:xml:ns:caldav' => {'calendar-data' => 'BEGIN:VCALENDAR...'}
246
+ },
247
+ status: 'HTTP/1.1 200 OK'
248
+ },
249
+ {
250
+ properties: {'DAV:' => {'getctag' => ''}},
251
+ status: 'HTTP/1.1 404 Not Found'
252
+ }
253
+ ],
254
+ status: nil
255
+ }
256
+ ]
257
+ ```
258
+
259
+ A COPY / MOVE / DELETE on a collection where a child resource failed — the server returns 207 Multi-Status with one `<d:response>` per affected child, each carrying a response-level status rather than propstats. Single-resource lifecycle operations don't go through this path; they return a plain `WebDAV::Response` with the status code as the whole story. The wire XML:
260
+
261
+ ```xml
262
+ <?xml version="1.0" encoding="UTF-8"?>
263
+ <d:multistatus xmlns:d="DAV:">
264
+ <d:response>
265
+ <d:href>/dir/file.txt</d:href>
266
+ <d:status>HTTP/1.1 403 Forbidden</d:status>
267
+ </d:response>
268
+ </d:multistatus>
269
+ ```
270
+
271
+ Parses to:
272
+
273
+ ```ruby
274
+ [
275
+ {
276
+ href: '/dir/file.txt',
277
+ propstats: [],
278
+ status: 'HTTP/1.1 403 Forbidden'
279
+ }
280
+ ]
281
+ ```
158
282
 
159
283
 
160
284
  ## Errors
@@ -9,26 +9,48 @@ class WebDAV
9
9
  class MultiStatus < Response
10
10
  attr_reader :resources
11
11
 
12
- def parse
13
- doc = REXML::Document.new(body)
14
- resources = []
15
- doc.elements.each('//d:response') do |resp|
16
- href = resp.elements['.//d:href']&.text
17
- properties = {}
18
- resp.elements.each('.//d:prop/*') do |prop|
19
- properties[prop.name] = prop.text || prop.to_s
20
- end
21
- status = resp.elements['.//d:status']&.text
22
- resources << {href: href, properties: properties, status: status}
23
- end
24
- resources
25
- end
26
-
27
12
  private
28
13
 
29
14
  def initialize(response)
30
15
  super
31
16
  @resources = parse
32
17
  end
18
+
19
+ def parse
20
+ doc = REXML::Document.new(body)
21
+ doc.elements.collect('//d:response'){|response_element| parse_response(response_element)}
22
+ end
23
+
24
+ def parse_response(response_element)
25
+ {
26
+ href: response_element.elements['d:href']&.text,
27
+ propstats: parse_propstats(response_element),
28
+ status: parse_response_status(response_element)
29
+ }
30
+ end
31
+
32
+ def parse_propstats(response_element)
33
+ response_element.elements.collect('d:propstat'){|propstat_element| parse_propstat(propstat_element)}
34
+ end
35
+
36
+ def parse_propstat(propstat_element)
37
+ {
38
+ properties: parse_properties(propstat_element.elements['d:prop']),
39
+ status: propstat_element.elements['d:status']&.text
40
+ }
41
+ end
42
+
43
+ def parse_properties(prop_element)
44
+ return {} unless prop_element
45
+ prop_element.elements.to_a.each_with_object({}) do |property_element, result|
46
+ result[property_element.namespace] ||= {}
47
+ result[property_element.namespace][property_element.name] = property_element.text || property_element.to_s
48
+ end
49
+ end
50
+
51
+ def parse_response_status(response_element)
52
+ return nil if response_element.elements['d:propstat']
53
+ response_element.elements['d:status']&.text
54
+ end
33
55
  end
34
56
  end
@@ -2,5 +2,5 @@
2
2
  # WebDAV::VERSION
3
3
 
4
4
  class WebDAV
5
- VERSION = '0.1.2'
5
+ VERSION = '0.2.0'
6
6
  end
@@ -67,13 +67,142 @@ describe WebDAV::MultiStatus do
67
67
  _(response.resources[1][:href]).must_equal '/dav/calendars/user/test/calendar2/'
68
68
  end
69
69
 
70
- it "extracts properties from each resource" do
71
- _(response.resources[0][:properties]['displayname']).must_equal 'Work'
72
- _(response.resources[1][:properties]['displayname']).must_equal 'Personal'
70
+ it "extracts properties from each propstat, keyed by namespace then local name" do
71
+ _(response.resources[0][:propstats][0][:properties]['DAV:']['displayname']).must_equal 'Work'
72
+ _(response.resources[1][:propstats][0][:properties]['DAV:']['displayname']).must_equal 'Personal'
73
73
  end
74
74
 
75
- it "extracts status from each resource" do
76
- _(response.resources[0][:status]).must_equal 'HTTP/1.1 200 OK'
75
+ it "extracts per-propstat status" do
76
+ _(response.resources[0][:propstats][0][:status]).must_equal 'HTTP/1.1 200 OK'
77
+ end
78
+
79
+ it "leaves response-level status nil when propstats are present" do
80
+ _(response.resources[0][:status]).must_be_nil
81
+ end
82
+ end
83
+
84
+ describe "with multiple propstats per response" do
85
+ let(:mixed_status_xml) do
86
+ <<~XML
87
+ <?xml version="1.0" encoding="UTF-8"?>
88
+ <d:multistatus xmlns:d="DAV:">
89
+ <d:response>
90
+ <d:href>/calendar/</d:href>
91
+ <d:propstat>
92
+ <d:prop>
93
+ <d:displayname>Work</d:displayname>
94
+ </d:prop>
95
+ <d:status>HTTP/1.1 200 OK</d:status>
96
+ </d:propstat>
97
+ <d:propstat>
98
+ <d:prop>
99
+ <d:getctag/>
100
+ </d:prop>
101
+ <d:status>HTTP/1.1 404 Not Found</d:status>
102
+ </d:propstat>
103
+ </d:response>
104
+ </d:multistatus>
105
+ XML
106
+ end
107
+
108
+ let(:mixed_response){WebDAV::MultiStatus.new(MockResponse.new(code: '207', message: 'Multi-Status', body: mixed_status_xml))}
109
+
110
+ it "preserves each propstat as a separate entry" do
111
+ _(mixed_response.resources[0][:propstats].length).must_equal 2
112
+ end
113
+
114
+ it "keeps the 200 propstat's properties under its own status" do
115
+ successful_propstat = mixed_response.resources[0][:propstats][0]
116
+ _(successful_propstat[:status]).must_equal 'HTTP/1.1 200 OK'
117
+ _(successful_propstat[:properties]['DAV:']['displayname']).must_equal 'Work'
118
+ end
119
+
120
+ it "keeps the 404 propstat's properties under its own status" do
121
+ missing_propstat = mixed_response.resources[0][:propstats][1]
122
+ _(missing_propstat[:status]).must_equal 'HTTP/1.1 404 Not Found'
123
+ _(missing_propstat[:properties]['DAV:']).must_include 'getctag'
124
+ end
125
+ end
126
+
127
+ describe "with response-level status (COPY/MOVE/DELETE)" do
128
+ let(:response_level_xml) do
129
+ <<~XML
130
+ <?xml version="1.0" encoding="UTF-8"?>
131
+ <d:multistatus xmlns:d="DAV:">
132
+ <d:response>
133
+ <d:href>/dir/file.txt</d:href>
134
+ <d:status>HTTP/1.1 403 Forbidden</d:status>
135
+ </d:response>
136
+ </d:multistatus>
137
+ XML
138
+ end
139
+
140
+ let(:response_level_response){WebDAV::MultiStatus.new(MockResponse.new(code: '207', message: 'Multi-Status', body: response_level_xml))}
141
+
142
+ it "populates the response-level status" do
143
+ _(response_level_response.resources[0][:status]).must_equal 'HTTP/1.1 403 Forbidden'
144
+ end
145
+
146
+ it "leaves propstats empty" do
147
+ _(response_level_response.resources[0][:propstats]).must_equal []
148
+ end
149
+ end
150
+
151
+ describe "with multiple namespaces" do
152
+ let(:multi_namespace_xml) do
153
+ <<~XML
154
+ <?xml version="1.0" encoding="UTF-8"?>
155
+ <d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
156
+ <d:response>
157
+ <d:href>/calendar/event.ics</d:href>
158
+ <d:propstat>
159
+ <d:prop>
160
+ <d:getetag>"abc123"</d:getetag>
161
+ <c:calendar-data>BEGIN:VCALENDAR</c:calendar-data>
162
+ </d:prop>
163
+ <d:status>HTTP/1.1 200 OK</d:status>
164
+ </d:propstat>
165
+ </d:response>
166
+ </d:multistatus>
167
+ XML
168
+ end
169
+
170
+ let(:multi_namespace_response){WebDAV::MultiStatus.new(MockResponse.new(code: '207', message: 'Multi-Status', body: multi_namespace_xml))}
171
+
172
+ it "groups DAV properties under the DAV: namespace" do
173
+ _(multi_namespace_response.resources[0][:propstats][0][:properties]['DAV:']['getetag']).must_equal '"abc123"'
174
+ end
175
+
176
+ it "groups CalDAV properties under the CalDAV namespace" do
177
+ _(multi_namespace_response.resources[0][:propstats][0][:properties]['urn:ietf:params:xml:ns:caldav']['calendar-data']).must_equal 'BEGIN:VCALENDAR'
178
+ end
179
+ end
180
+
181
+ describe "with the same local name in different namespaces" do
182
+ let(:collision_xml) do
183
+ <<~XML
184
+ <?xml version="1.0" encoding="UTF-8"?>
185
+ <d:multistatus xmlns:d="DAV:" xmlns:x="http://example.com/custom">
186
+ <d:response>
187
+ <d:href>/file.txt</d:href>
188
+ <d:propstat>
189
+ <d:prop>
190
+ <d:displayname>From DAV</d:displayname>
191
+ <x:displayname>From custom</x:displayname>
192
+ </d:prop>
193
+ <d:status>HTTP/1.1 200 OK</d:status>
194
+ </d:propstat>
195
+ </d:response>
196
+ </d:multistatus>
197
+ XML
198
+ end
199
+
200
+ let(:collision_response){WebDAV::MultiStatus.new(MockResponse.new(code: '207', message: 'Multi-Status', body: collision_xml))}
201
+
202
+ it "keeps both without collision" do
203
+ properties = collision_response.resources[0][:propstats][0][:properties]
204
+ _(properties['DAV:']['displayname']).must_equal 'From DAV'
205
+ _(properties['http://example.com/custom']['displayname']).must_equal 'From custom'
77
206
  end
78
207
  end
79
208
 
data/webdav.gemspec CHANGED
@@ -22,7 +22,7 @@ Gem::Specification.new do |spec|
22
22
  spec.homepage = 'https://github.com/thoran/webdav'
23
23
  spec.license = 'MIT'
24
24
 
25
- spec.required_ruby_version = '>= 2.7'
25
+ spec.required_ruby_version = '>= 3.2'
26
26
  spec.require_paths = ['lib']
27
27
 
28
28
  spec.files = [
@@ -37,12 +37,13 @@ Gem::Specification.new do |spec|
37
37
  ].flatten
38
38
 
39
39
  spec.dependencies = [
40
- ['http.rb'],
40
+ ['http.rb', '~> 1.0'],
41
41
  ['rexml']
42
42
  ]
43
43
 
44
44
  spec.development_dependencies = [
45
- ['minitest', '~> 5.27'],
45
+ ['minitest', '~> 6.0'],
46
+ ['minitest-mock'],
46
47
  ['rake']
47
48
  ]
48
49
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: webdav
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran
@@ -13,16 +13,16 @@ dependencies:
13
13
  name: http.rb
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - ">="
16
+ - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0'
18
+ version: '1.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - ">="
23
+ - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0'
25
+ version: '1.0'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rexml
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -43,14 +43,28 @@ dependencies:
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '5.27'
46
+ version: '6.0'
47
47
  type: :development
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '5.27'
53
+ version: '6.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: minitest-mock
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'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: rake
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -104,7 +118,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
104
118
  requirements:
105
119
  - - ">="
106
120
  - !ruby/object:Gem::Version
107
- version: '2.7'
121
+ version: '3.2'
108
122
  required_rubygems_version: !ruby/object:Gem::Requirement
109
123
  requirements:
110
124
  - - ">="