webdav.rb 0.2.1

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.
data/lib/webdav.rb ADDED
@@ -0,0 +1,157 @@
1
+ # WebDAV.rb
2
+ # WebDAV
3
+
4
+ gem 'http.rb'; require 'http.rb'
5
+ require 'net/http'
6
+ require 'uri'
7
+
8
+ require_relative './Net/HTTP/Report'
9
+ require_relative './String/to_const'
10
+ require_relative './WebDAV/Error'
11
+ require_relative './WebDAV/MultiStatus'
12
+ require_relative './WebDAV/Response'
13
+
14
+ class WebDAV
15
+
16
+ # Properties
17
+
18
+ def propfind(path = '/', body: nil, depth: '1')
19
+ response = request(:propfind, path, body: body, headers: {'Depth' => depth})
20
+ handle_response(response)
21
+ end
22
+
23
+ def proppatch(path, body:)
24
+ response = request(:proppatch, path, body: body)
25
+ handle_response(response)
26
+ end
27
+
28
+ # Versioning
29
+
30
+ def report(path, body:, depth: '1')
31
+ response = request(:report, path, body: body, headers: {'Depth' => depth})
32
+ handle_response(response)
33
+ end
34
+
35
+ # Collections
36
+
37
+ def mkcol(path)
38
+ response = request(:mkcol, path)
39
+ handle_response(response)
40
+ end
41
+
42
+ # Namespace
43
+
44
+ def copy(path, to:, depth: 'infinity', overwrite: true)
45
+ response = request(:copy, path, headers: {
46
+ 'Destination' => resolve_uri(to).to_s,
47
+ 'Depth' => depth,
48
+ 'Overwrite' => overwrite ? 'T' : 'F'
49
+ })
50
+ handle_response(response)
51
+ end
52
+
53
+ def move(path, to:, overwrite: true)
54
+ response = request(:move, path, headers: {
55
+ 'Destination' => resolve_uri(to).to_s,
56
+ 'Overwrite' => overwrite ? 'T' : 'F'
57
+ })
58
+ handle_response(response)
59
+ end
60
+
61
+ # Locking
62
+
63
+ def lock(path, body:)
64
+ response = request(:lock, path, body: body)
65
+ handle_response(response)
66
+ end
67
+
68
+ def unlock(path, token:)
69
+ response = request(:unlock, path, headers: {'Lock-Token' => "<#{token}>"})
70
+ handle_response(response)
71
+ end
72
+
73
+ # Standard HTTP
74
+
75
+ def get(path)
76
+ response = request(:get, path)
77
+ handle_response(response)
78
+ end
79
+
80
+ def head(path)
81
+ response = request(:head, path)
82
+ handle_response(response)
83
+ end
84
+
85
+ def post(path, body:, content_type: 'application/xml')
86
+ response = request(:post, path, body: body, headers: {'Content-Type' => content_type})
87
+ handle_response(response)
88
+ end
89
+
90
+ def put(path, body:, content_type: 'application/octet-stream')
91
+ response = request(:put, path, body: body, headers: {'Content-Type' => content_type})
92
+ handle_response(response)
93
+ end
94
+
95
+ def patch(path, body:, content_type: 'application/xml')
96
+ response = request(:patch, path, body: body, headers: {'Content-Type' => content_type})
97
+ handle_response(response)
98
+ end
99
+
100
+ def delete(path)
101
+ response = request(:delete, path)
102
+ handle_response(response)
103
+ end
104
+
105
+ def options(path = '/')
106
+ response = request(:options, path)
107
+ handle_response(response)
108
+ end
109
+
110
+ def trace(path)
111
+ response = request(:trace, path)
112
+ handle_response(response)
113
+ end
114
+
115
+ private
116
+
117
+ def initialize(uri, username:, password:)
118
+ @uri = uri.is_a?(URI) ? uri : URI.parse(uri)
119
+ @username = username
120
+ @password = password
121
+ end
122
+
123
+ def resolve_uri(path)
124
+ URI.join(@uri, path)
125
+ end
126
+
127
+ def request_uri(path)
128
+ resolve_uri(path).request_uri
129
+ end
130
+
131
+ def default_headers
132
+ {'Content-Type' => 'application/xml; charset=utf-8'}
133
+ end
134
+
135
+ def request_class(verb)
136
+ "Net::HTTP::#{verb.to_s.capitalize}".to_const
137
+ end
138
+
139
+ def request(verb, path, body: nil, headers: {})
140
+ request_object = request_class(verb).new(request_uri(path))
141
+ request_object.basic_auth(@username, @password)
142
+ request_object.body = body if body
143
+ merged_headers = default_headers.merge(headers)
144
+ merged_headers.each{|k, v| request_object[k] = v}
145
+ HTTP.request(resolve_uri(path), request_object)
146
+ end
147
+
148
+ def handle_response(response)
149
+ raise WebDAV::Error.new(response) if response.code.to_i >= 400
150
+ case response.code.to_i
151
+ when 207
152
+ MultiStatus.new(response)
153
+ else
154
+ Response.new(response)
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,21 @@
1
+ # test/Net/HTTP/Report_test.rb
2
+
3
+ require_relative '../../helper'
4
+
5
+ describe Net::HTTP::Report do
6
+ it "is a subclass of Net::HTTPRequest" do
7
+ _(Net::HTTP::Report < Net::HTTPRequest).must_equal(true)
8
+ end
9
+
10
+ it "has the correct METHOD" do
11
+ _(Net::HTTP::Report::METHOD).must_equal('REPORT')
12
+ end
13
+
14
+ it "accepts a body" do
15
+ _(Net::HTTP::Report::REQUEST_HAS_BODY).must_equal(true)
16
+ end
17
+
18
+ it "expects a response body" do
19
+ _(Net::HTTP::Report::RESPONSE_HAS_BODY).must_equal(true)
20
+ end
21
+ end
@@ -0,0 +1,85 @@
1
+ # test/WebDAV/Error_test.rb
2
+
3
+ require_relative '../helper'
4
+
5
+ describe WebDAV::Error do
6
+ let(:mock_response) do
7
+ MockResponse.new(
8
+ code: '404',
9
+ message: 'Not Found',
10
+ body: '<!DOCTYPE html><html><body>Not Found</body></html>'
11
+ )
12
+ end
13
+
14
+ let(:error){WebDAV::Error.new(mock_response)}
15
+
16
+ describe "#code" do
17
+ it "returns the status code as an integer" do
18
+ _(error.code).must_equal 404
19
+ end
20
+ end
21
+
22
+ describe "#message" do
23
+ it "returns the status message" do
24
+ _(error.message).must_equal 'Not Found'
25
+ end
26
+ end
27
+
28
+ describe "#body" do
29
+ it "returns the response body" do
30
+ _(error.body).must_include 'Not Found'
31
+ end
32
+ end
33
+
34
+ describe "#to_s" do
35
+ it "includes the status code and message" do
36
+ _(error.to_s).must_equal 'HTTP 404: Not Found'
37
+ end
38
+ end
39
+
40
+ describe "inheritance" do
41
+ it "is a StandardError" do
42
+ _(error).must_be_kind_of StandardError
43
+ end
44
+
45
+ it "can be raised and rescued" do
46
+ assert_raises(WebDAV::Error) do
47
+ raise WebDAV::Error.new(mock_response)
48
+ end
49
+ end
50
+ end
51
+
52
+ describe "with a 401 status code" do
53
+ let(:mock_response) do
54
+ MockResponse.new(
55
+ code: '401',
56
+ message: 'Unauthorized',
57
+ body: ''
58
+ )
59
+ end
60
+
61
+ let(:error){WebDAV::Error.new(mock_response)}
62
+
63
+ it "handles a 401 error" do
64
+ _(error.code).must_equal 401
65
+ _(error.to_s).must_equal 'HTTP 401: Unauthorized'
66
+ end
67
+ end
68
+
69
+ describe "with a 500 status code" do
70
+ let(:mock_response) do
71
+ MockResponse.new(
72
+ code: '500',
73
+ message: 'Internal Server Error',
74
+ body: ''
75
+ )
76
+ end
77
+
78
+ let(:error){WebDAV::Error.new(mock_response)}
79
+
80
+ it "handles a 500 error" do
81
+ _(error.code).must_equal 500
82
+ _(error.to_s).must_equal 'HTTP 500: Internal Server Error'
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,231 @@
1
+ # test/WebDAV/MultiStatus_test.rb
2
+
3
+ require_relative '../helper'
4
+
5
+ describe WebDAV::MultiStatus do
6
+ let(:multistatus_xml) do
7
+ <<~XML
8
+ <?xml version="1.0" encoding="UTF-8"?>
9
+ <d:multistatus xmlns:d="DAV:">
10
+ <d:response>
11
+ <d:href>/dav/calendars/user/test/calendar1/</d:href>
12
+ <d:propstat>
13
+ <d:prop>
14
+ <d:displayname>Work</d:displayname>
15
+ <d:resourcetype><d:collection/></d:resourcetype>
16
+ </d:prop>
17
+ <d:status>HTTP/1.1 200 OK</d:status>
18
+ </d:propstat>
19
+ </d:response>
20
+ <d:response>
21
+ <d:href>/dav/calendars/user/test/calendar2/</d:href>
22
+ <d:propstat>
23
+ <d:prop>
24
+ <d:displayname>Personal</d:displayname>
25
+ <d:resourcetype><d:collection/></d:resourcetype>
26
+ </d:prop>
27
+ <d:status>HTTP/1.1 200 OK</d:status>
28
+ </d:propstat>
29
+ </d:response>
30
+ </d:multistatus>
31
+ XML
32
+ end
33
+
34
+ let(:mock_response) do
35
+ MockResponse.new(
36
+ code: '207',
37
+ message: 'Multi-Status',
38
+ body: multistatus_xml
39
+ )
40
+ end
41
+
42
+ let(:response){WebDAV::MultiStatus.new(mock_response)}
43
+
44
+ describe "#code" do
45
+ it "returns 207" do
46
+ _(response.code).must_equal 207
47
+ end
48
+ end
49
+
50
+ describe "#success?" do
51
+ it "returns true" do
52
+ _(response.success?).must_equal true
53
+ end
54
+ end
55
+
56
+ describe "#resources" do
57
+ it "returns an array" do
58
+ _(response.resources).must_be_kind_of Array
59
+ end
60
+
61
+ it "parses the correct number of resources" do
62
+ _(response.resources.length).must_equal 2
63
+ end
64
+
65
+ it "extracts href from each resource" do
66
+ _(response.resources[0][:href]).must_equal '/dav/calendars/user/test/calendar1/'
67
+ _(response.resources[1][:href]).must_equal '/dav/calendars/user/test/calendar2/'
68
+ end
69
+
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
+ end
74
+
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'
206
+ end
207
+ end
208
+
209
+ describe "with empty multistatus" do
210
+ let(:empty_xml) do
211
+ <<~XML
212
+ <?xml version="1.0" encoding="UTF-8"?>
213
+ <d:multistatus xmlns:d="DAV:">
214
+ </d:multistatus>
215
+ XML
216
+ end
217
+
218
+ let(:empty_response) do
219
+ MockResponse.new(
220
+ code: '207',
221
+ message: 'Multi-Status',
222
+ body: empty_xml
223
+ )
224
+ end
225
+
226
+ it "returns an empty array" do
227
+ r = WebDAV::MultiStatus.new(empty_response)
228
+ _(r.resources).must_equal []
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,72 @@
1
+ # test/WebDAV/Response_test.rb
2
+
3
+ require_relative '../helper'
4
+
5
+ describe WebDAV::Response do
6
+ let(:mock_response) do
7
+ headers = {'ETag' => '"abc123"', 'Content-Type' => 'text/html'}
8
+ response = MockResponse.new(
9
+ code: '200',
10
+ message: 'OK',
11
+ body: '<html>Hello</html>',
12
+ headers_hash: headers
13
+ )
14
+ response
15
+ end
16
+
17
+ let(:response){WebDAV::Response.new(mock_response)}
18
+
19
+ describe "#code" do
20
+ it "returns the status code as an integer" do
21
+ _(response.code).must_equal 200
22
+ end
23
+ end
24
+
25
+ describe "#message" do
26
+ it "returns the status message" do
27
+ _(response.message).must_equal 'OK'
28
+ end
29
+ end
30
+
31
+ describe "#body" do
32
+ it "returns the response body" do
33
+ _(response.body).must_equal '<html>Hello</html>'
34
+ end
35
+ end
36
+
37
+ describe "#success?" do
38
+ it "returns true for 2xx responses" do
39
+ _(response.success?).must_equal true
40
+ end
41
+
42
+ it "returns false for 3xx responses" do
43
+ redirect_response = MockResponse.new(code: '302', message: 'Found', body: '')
44
+ r = WebDAV::Response.new(redirect_response)
45
+ _(r.success?).must_equal false
46
+ end
47
+
48
+ it "returns false for 4xx responses" do
49
+ error_response = MockResponse.new(code: '404', message: 'Not Found', body: '')
50
+ r = WebDAV::Response.new(error_response)
51
+ _(r.success?).must_equal false
52
+ end
53
+
54
+ it "returns false for 5xx responses" do
55
+ error_response = MockResponse.new(code: '500', message: 'Internal Server Error', body: '')
56
+ r = WebDAV::Response.new(error_response)
57
+ _(r.success?).must_equal false
58
+ end
59
+ end
60
+
61
+ describe "#etag" do
62
+ it "returns the ETag header" do
63
+ _(response.etag).must_equal '"abc123"'
64
+ end
65
+ end
66
+
67
+ describe "#content_type" do
68
+ it "returns the Content-Type header" do
69
+ _(response.content_type).must_equal 'text/html'
70
+ end
71
+ end
72
+ 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/webdav'
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