calendav 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../errors"
3
4
  require_relative "../event"
4
5
  require_relative "../requests/list_events"
5
6
 
@@ -13,23 +14,45 @@ module Calendav
13
14
  end
14
15
 
15
16
  def create(calendar_url, event_identifier, ics)
16
- url = merged_url(calendar_url, event_identifier)
17
- result = endpoint.put(ics, url: url, content_type: :ics)
17
+ event_url = merged_url(calendar_url, event_identifier)
18
+ result = endpoint.put(ics, url: event_url, content_type: :ics)
18
19
 
19
- result.headers["Location"]
20
+ result.headers["Location"] || event_url
20
21
  end
21
22
 
22
- def delete(event_url)
23
- endpoint.delete(url: event_url)
23
+ def delete(event_url, etag: nil)
24
+ endpoint.delete(url: event_url, etag: etag)
25
+ rescue PreconditionError
26
+ false
24
27
  end
25
28
 
26
- def list(calendar_url, from:, to:)
29
+ def find(event_url)
30
+ response = endpoint.get(url: event_url)
31
+
32
+ Event.new(
33
+ event_url,
34
+ calendar_data: response.body.to_s,
35
+ etag: response.headers["ETag"]
36
+ )
37
+ end
38
+
39
+ def list(calendar_url, from: nil, to: nil)
27
40
  request = Requests::ListEvents.call(from: from, to: to)
28
41
 
29
42
  endpoint
30
43
  .report(request.to_xml, url: calendar_url, depth: 1)
31
- .xpath(".//dav:response")
32
- .collect { |node| Event.from_xml(node) }
44
+ .reject { |node| node.xpath(".//caldav:calendar-data").text.empty? }
45
+ .collect { |node| Event.from_xml(calendar_url, node) }
46
+ end
47
+
48
+ def update(event_url, ics, etag: nil)
49
+ endpoint.put(
50
+ ics, url: event_url, content_type: :ics, etag: etag
51
+ )
52
+
53
+ true
54
+ rescue PreconditionError
55
+ false
33
56
  end
34
57
 
35
58
  private
@@ -37,11 +60,7 @@ module Calendav
37
60
  attr_reader :client, :endpoint, :credentials
38
61
 
39
62
  def merged_url(calendar_url, event_identifier)
40
- if calendar_url.end_with?("/")
41
- "#{calendar_url}#{event_identifier}"
42
- else
43
- "#{calendar_url}/#{event_identifier}"
44
- end
63
+ "#{calendar_url.delete_suffix('/')}/#{event_identifier}"
45
64
  end
46
65
  end
47
66
  end
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "uri"
4
+
3
5
  module Calendav
4
6
  class ContextualURL
5
- def self.call(credentials, url_or_path)
6
- return credentials.host.to_s if url_or_path.nil? || url_or_path.empty?
7
+ def self.call(host, url_or_path)
8
+ host = URI(host)
9
+ return host.to_s if url_or_path.nil? || url_or_path.empty?
7
10
 
8
11
  if url_or_path.start_with?("/")
9
- credentials.host.dup.tap { |new_url| new_url.path = url_or_path }.to_s
12
+ host.dup.tap { |new_url| new_url.path = url_or_path }.to_s
10
13
  else
11
14
  URI(url_or_path).to_s
12
15
  end
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "./contextual_url"
4
- require_relative "./xml_processor"
4
+ require_relative "./errors"
5
+ require_relative "./parsers/response_xml"
5
6
 
6
7
  module Calendav
7
8
  class Endpoint
@@ -10,14 +11,18 @@ module Calendav
10
11
  xml: "application/xml; charset=utf-8"
11
12
  }.freeze
12
13
 
13
- RequestFailedError = Class.new(StandardError)
14
-
15
14
  def initialize(credentials)
16
15
  @credentials = credentials
17
16
  end
18
17
 
19
- def delete(url:)
20
- request(:delete, url: url).status.success?
18
+ def delete(url:, etag: nil)
19
+ request(:delete, url: url, http: with_headers(etag: etag))
20
+ .status
21
+ .success?
22
+ end
23
+
24
+ def get(url:)
25
+ request(:get, url: url)
21
26
  end
22
27
 
23
28
  def mkcalendar(body, url:)
@@ -29,6 +34,10 @@ module Calendav
29
34
  )
30
35
  end
31
36
 
37
+ def options(url:)
38
+ request(:options, url: url)
39
+ end
40
+
32
41
  def propfind(body, url: nil, depth: 0)
33
42
  request(
34
43
  :propfind,
@@ -38,12 +47,21 @@ module Calendav
38
47
  )
39
48
  end
40
49
 
41
- def put(body, url:, content_type: nil)
50
+ def proppatch(body, url: nil)
51
+ request(
52
+ :proppatch,
53
+ body,
54
+ url: url,
55
+ http: with_headers(content_type: :xml)
56
+ )
57
+ end
58
+
59
+ def put(body, url:, content_type: nil, etag: nil)
42
60
  request(
43
61
  :put,
44
62
  body,
45
63
  url: url,
46
- http: with_headers(content_type: content_type)
64
+ http: with_headers(content_type: content_type, etag: etag)
47
65
  )
48
66
  end
49
67
 
@@ -72,10 +90,11 @@ module Calendav
72
90
  end
73
91
  end
74
92
 
75
- def with_headers(content_type: nil, depth: nil)
93
+ def with_headers(content_type: nil, depth: nil, etag: nil)
76
94
  http = authenticated
77
95
 
78
96
  http = http.headers(depth: depth) if depth
97
+ http = http.headers("If-Match" => etag) if etag
79
98
  if content_type
80
99
  http = http.headers("Content-Type" => CONTENT_TYPES[content_type])
81
100
  end
@@ -85,15 +104,26 @@ module Calendav
85
104
 
86
105
  def request(verb, body = nil, url:, http: with_headers)
87
106
  response = http.request(
88
- verb, ContextualURL.call(credentials, url), body: body
107
+ verb, ContextualURL.call(credentials.host, url), body: body
89
108
  )
90
- unless response.status.success?
91
- raise RequestFailedError, response.status.to_s
109
+
110
+ if response.status.success?
111
+ parse(response)
112
+ elsif response.status.code == 412
113
+ raise PreconditionError, response
114
+ else
115
+ raise RequestError, response
92
116
  end
117
+ end
93
118
 
94
- return response if response.content_length&.zero?
119
+ def parse(response)
120
+ return response if response.content_length&.zero? || response.body.empty?
95
121
 
96
- XMLProcessor.call(response.body)
122
+ if response.content_type.mime_type == "text/calendar"
123
+ response
124
+ else
125
+ Parsers::ResponseXML.call(response.body)
126
+ end
97
127
  end
98
128
  end
99
129
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calendav
4
+ Error = Class.new(StandardError)
5
+
6
+ class ParsingXMLError < Error
7
+ attr_reader :xml, :original
8
+
9
+ def initialize(xml, original)
10
+ super original.message
11
+
12
+ @xml = xml
13
+ @original = original
14
+ end
15
+ end
16
+
17
+ class RequestError < Error
18
+ attr_reader :response
19
+
20
+ def initialize(response)
21
+ super response.status.to_s
22
+
23
+ @response = response
24
+ end
25
+ end
26
+
27
+ PreconditionError = Class.new(RequestError)
28
+ end
@@ -1,56 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "./xml_processor"
3
+ require_relative "./contextual_url"
4
+ require_relative "./parsers/event_xml"
4
5
 
5
6
  module Calendav
6
7
  class Event
7
- attr_reader :path
8
+ attr_reader :url, :calendar_data, :etag
8
9
 
9
- def self.from_xml(node)
10
+ def self.from_xml(host, node)
10
11
  new(
11
- node.xpath("./dav:href").text,
12
- node.xpath(".//dav:prop/*").to_xml,
13
- node.namespaces
12
+ ContextualURL.call(host, node.xpath("./dav:href").text),
13
+ Parsers::EventXML.call(node)
14
14
  )
15
15
  end
16
16
 
17
- def initialize(path, attribute_nodes, namespaces = {})
18
- @path = path
19
- @attribute_nodes = attribute_nodes
20
- @namespaces = namespaces
21
- end
22
-
23
- def calendar_data
24
- attribute_value fragment.xpath("//caldav:calendar-data")
25
- end
26
-
27
- def etag
28
- attribute_value fragment.xpath("//dav:getetag")
17
+ def initialize(url, attributes = {})
18
+ @url = url
19
+ @calendar_data = attributes[:calendar_data]
20
+ @etag = attributes[:etag]
29
21
  end
30
22
 
31
23
  def summary
32
24
  inner_event.summary
33
25
  end
34
26
 
35
- private
36
-
37
- attr_reader :attribute_nodes, :namespaces
38
-
39
- def fragment
40
- @fragment ||= XMLProcessor.call(
41
- "<nodes>#{attribute_nodes}</nodes>", namespaces
42
- )
27
+ def unloaded?
28
+ calendar_data.nil?
43
29
  end
44
30
 
45
- def attribute_value(node)
46
- return nil if node.children.empty?
47
-
48
- if node.children.any?(&:element?)
49
- node.children.select(&:element?).collect(&:to_xml).join
50
- else
51
- node.children.text
52
- end
53
- end
31
+ private
54
32
 
55
33
  def inner_calendar
56
34
  Icalendar::Calendar.parse(calendar_data).first
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calendav
4
+ class MultiResponse
5
+ include Enumerable
6
+
7
+ def initialize(document)
8
+ @document = document
9
+ end
10
+
11
+ def each(...)
12
+ responses.each(...)
13
+ end
14
+
15
+ def xpath(...)
16
+ document.xpath(...)
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :document
22
+
23
+ def responses
24
+ document.xpath("/dav:multistatus/dav:response")
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calendav
4
+ NAMESPACES = {
5
+ "xmlns:dav" => "DAV:",
6
+ "xmlns:caldav" => "urn:ietf:params:xml:ns:caldav",
7
+ "xmlns:cs" => "http://calendarserver.org/ns/",
8
+ "xmlns:apple" => "http://apple.com/ns/ical/"
9
+ }.freeze
10
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calendav
4
+ module Parsers
5
+ class CalendarXML
6
+ XPATHS = {
7
+ display_name: ".//dav:displayname",
8
+ description: ".//caldav:calendar-description",
9
+ ctag: ".//cs:getctag",
10
+ etag: ".//dav:getetag",
11
+ time_zone: ".//caldav:calendar-timezone",
12
+ color: ".//apple:calendar-color",
13
+ sync_token: ".//dav:sync-token"
14
+ }.freeze
15
+
16
+ def self.call(...)
17
+ new(...).call
18
+ end
19
+
20
+ def initialize(element)
21
+ @element = element
22
+ end
23
+
24
+ def call
25
+ XPATHS
26
+ .transform_values { |xpath| value(xpath) }
27
+ .merge(components: components, reports: reports)
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :element
33
+
34
+ def components
35
+ element
36
+ .xpath(".//caldav:supported-calendar-component-set/caldav:comp")
37
+ .collect { |node| node["name"] }
38
+ end
39
+
40
+ def reports
41
+ element
42
+ .xpath("//dav:supported-report-set/dav:supported-report/dav:report/*")
43
+ .collect(&:name)
44
+ end
45
+
46
+ def value(xpath)
47
+ node = element.xpath(xpath)
48
+ return nil if node.children.empty?
49
+
50
+ if node.children.any?(&:element?)
51
+ node.children.select(&:element?).collect(&:to_xml).join
52
+ else
53
+ node.children.text
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calendav
4
+ module Parsers
5
+ class EventXML
6
+ def self.call(...)
7
+ new(...).call
8
+ end
9
+
10
+ def initialize(element)
11
+ @element = element
12
+ end
13
+
14
+ def call
15
+ {
16
+ calendar_data: value(".//caldav:calendar-data"),
17
+ etag: value(".//dav:getetag")
18
+ }
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :element
24
+
25
+ def value(xpath)
26
+ node = element.xpath(xpath)
27
+ return nil if node.children.empty?
28
+
29
+ if node.children.any?(&:element?)
30
+ node.children.select(&:element?).collect(&:to_xml).join
31
+ else
32
+ node.children.text
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../multi_response"
5
+ require_relative "../namespaces"
6
+
7
+ module Calendav
8
+ module Parsers
9
+ class ResponseXML
10
+ def self.call(...)
11
+ new(...).call
12
+ end
13
+
14
+ def initialize(raw, namespaces = NAMESPACES)
15
+ @raw = raw
16
+ @namespaces = namespaces
17
+ end
18
+
19
+ def call
20
+ return document if document.xpath("/dav:multistatus").empty?
21
+
22
+ MultiResponse.new(document)
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :raw, :namespaces
28
+
29
+ def document
30
+ @document ||= begin
31
+ initial = parse(raw)
32
+
33
+ namespaces.each do |key, value|
34
+ initial.root[key] = value unless initial.namespaces[key]
35
+ end
36
+
37
+ parse(initial.to_xml)
38
+ end
39
+ end
40
+
41
+ def parse(string)
42
+ Nokogiri::XML(string) { |config| config.strict.noblanks }
43
+ rescue Nokogiri::XML::SyntaxError => error
44
+ raise ParsingXMLError.new(string, error)
45
+ end
46
+ end
47
+ end
48
+ end