active_cmis2 0.3.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,86 @@
1
+ module ActiveCMIS
2
+ module Internal
3
+ module Caching
4
+ def self.included(cl)
5
+ cl.extend ClassMethods
6
+ end
7
+
8
+ # A module for internal use only.
9
+ module ClassMethods
10
+
11
+ # Creates a proxy method for the given method names that caches the result.
12
+ #
13
+ # Parameters are passed and ignored, cached values will be returned regardless of the parameters.
14
+ # @param [Symbol, <Symbol>] names Names of methods that will be cached
15
+ # @return [void]
16
+ def cache(*names)
17
+ (@cached_methods ||= []).concat(names).uniq!
18
+ names.each do |name|
19
+ alias_method("#{name}__uncached", name)
20
+ class_eval <<-RUBY, __FILE__, __LINE__+1
21
+ if private_method_defined? :"#{name}"
22
+ private_method = true
23
+ end
24
+ def #{name}(*a, &b)
25
+ if defined? @#{name}
26
+ @#{name}
27
+ else
28
+ @#{name} = #{name}__uncached(*a, &b)
29
+ end
30
+ end
31
+ if private_method
32
+ private :"#{name}__uncached"
33
+ private :"#{name}"
34
+ end
35
+ RUBY
36
+ end
37
+ reloadable
38
+ end
39
+
40
+ # Creates methods to retrieve attributes with the given names.
41
+ #
42
+ # If the given attribute does not yet exist the method #load_from_data will be called
43
+ #
44
+ # @param [Symbol, <Symbol>] names Names of desired attributes
45
+ # @return [void]
46
+ def cached_reader(*names)
47
+ (@cached_methods ||= []).concat(names).uniq!
48
+ names.each do |name|
49
+ define_method "#{name}" do
50
+ if instance_variable_defined? "@#{name}"
51
+ instance_variable_get("@#{name}")
52
+ else
53
+ load_from_data # FIXME: make flexible?
54
+ instance_variable_get("@#{name}")
55
+ end
56
+ end
57
+ end
58
+ reloadable
59
+ end
60
+
61
+ private
62
+ def reloadable
63
+ class_eval <<-RUBY, __FILE__, __LINE__
64
+ def __reload
65
+ #{@cached_methods.inspect}.map do |method|
66
+ :"@\#{method}"
67
+ end.select do |iv|
68
+ instance_variable_defined? iv
69
+ end.each do |iv|
70
+ remove_instance_variable iv
71
+ end + (defined?(super) ? super : [])
72
+ end
73
+ private :__reload
74
+ RUBY
75
+ unless instance_methods.include? "reload"
76
+ class_eval <<-RUBY, __FILE__, __LINE__
77
+ def reload
78
+ __reload
79
+ end
80
+ RUBY
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,247 @@
1
+
2
+ module ActiveCMIS
3
+ module Internal
4
+ class Connection
5
+ # @return [String, nil] The user that is used with the authentication to the server
6
+ attr_reader :user
7
+ # @return [Logger] A logger used to send debug and info messages
8
+ attr_reader :logger
9
+ # @return [Hash] Options to be used by the HTTP objects
10
+ attr_reader :options
11
+
12
+ # @param [Logger] Initialize with a logger of your choice
13
+ def initialize(logger, options)
14
+ @logger = logger || ActiveCMIS.default_logger
15
+ @options = options || {}
16
+ end
17
+
18
+ # Use authentication to access the CMIS repository
19
+ #
20
+ # @param method [Symbol] Currently only :basic is supported
21
+ # @param params The parameters that need to be sent to the Net::HTTP authentication method used, username and password for basic authentication
22
+ # @return [void]
23
+ # @example Basic authentication
24
+ # repo.authenticate(:basic, "username", "password")
25
+ # @example NTLM authentication
26
+ # repo.authenticate(:ntlm, "username", "password")
27
+ def authenticate(method, *params)
28
+ case method
29
+ when :basic, "basic"
30
+ @authentication = {:method => :basic_auth, :params => params}
31
+ @user = params.first
32
+ when :ntlm, "ntlm"
33
+ require 'net/ntlm_http'
34
+ @authentication = {:method => :ntlm_auth, :params => params}
35
+ @user = params.first
36
+ else raise "Authentication method not supported"
37
+ end
38
+ end
39
+
40
+ # The return value is the unparsed body, unless an error occured
41
+ # If an error occurred, exceptions are thrown (see _ActiveCMIS::Exception
42
+ #
43
+ # @private
44
+ # @return [String] returns the body of the request, unless an error occurs
45
+ def get(url)
46
+ uri = normalize_url(url)
47
+ logger.debug "Method get only #{uri}"
48
+ # Ensure the parsed URL is an HTTP one
49
+ raise HTTPError::ClientError.new("Invalid URL #{url}") unless uri.is_a?(URI::HTTP)
50
+ req = Net::HTTP::Get.new(uri.request_uri)
51
+ req['Cookie'] = options[:cookies]
52
+ handle_request(uri, req)
53
+ end
54
+
55
+ # Does not throw errors, returns the full response (includes status code and headers)
56
+ # @private
57
+ # @return [Net::HTTP::Response]
58
+ def get_response(url)
59
+ logger.debug "GET (response) #{url}"
60
+ uri = normalize_url(url)
61
+
62
+ req = Net::HTTP::Get.new(uri.request_uri)
63
+ http = authenticate_request(uri, req)
64
+ response = nil
65
+ http.request(req) do |res|
66
+ logger.debug "GOT (#{res.code}) #{url}"
67
+ response = res
68
+ end
69
+ response
70
+ end
71
+
72
+ # Returns the parsed body of the result
73
+ # @private
74
+ # @return [Nokogiri::XML::Document]
75
+ def get_xml(url)
76
+ Nokogiri::XML.parse(get(url), nil, nil, Nokogiri::XML::ParseOptions::STRICT)
77
+ end
78
+
79
+ # @private
80
+ # @return [Nokogiri::XML::Node]
81
+ def get_atom_entry(url)
82
+ # FIXME: add validation that first child is really an entry
83
+ get_xml(url).child
84
+ end
85
+
86
+ # @private
87
+ def put(url, body, headers = {})
88
+ uri = normalize_url(url)
89
+
90
+ req = Net::HTTP::Put.new(uri.request_uri)
91
+ headers.each {|k,v| req.add_field k, v}
92
+ assign_body(req, body)
93
+ handle_request(uri, req)
94
+ end
95
+
96
+ # @private
97
+ def delete(url, headers = {})
98
+ uri = normalize_url(url)
99
+
100
+ req = Net::HTTP::Put.new(uri.request_uri)
101
+ headers.each {|k,v| req.add_field k, v}
102
+ handle_request(uri, req)
103
+ end
104
+
105
+ # @private
106
+ def post(url, body, headers = {})
107
+ uri = normalize_url(url)
108
+
109
+ req = Net::HTTP::Post.new(uri.request_uri)
110
+ headers.each {|k,v| req.add_field k, v}
111
+ assign_body(req, body)
112
+ handle_request(uri, req)
113
+ end
114
+
115
+ # Does not throw errors, returns the full response (includes status code and headers)
116
+ # @private
117
+ def post_response(url, body, headers = {})
118
+ logger.debug "POST (response) #{url}"
119
+ uri = normalize_url(url)
120
+
121
+ req = Net::HTTP::Post.new(uri.request_uri)
122
+ headers.each {|k,v| req.add_field k, v}
123
+ assign_body(req, body)
124
+
125
+ http = authenticate_request(uri, req)
126
+ response = http.request(req)
127
+ logger.debug "POSTED (#{response.code}) #{url}"
128
+ response
129
+ end
130
+
131
+ # @private
132
+ def delete(url)
133
+ uri = normalize_url(url)
134
+
135
+ req = Net::HTTP::Delete.new(uri.request_uri)
136
+ handle_request(uri, req)
137
+ end
138
+
139
+ private
140
+ def normalize_url(url)
141
+ case url
142
+ when URI; url
143
+ else URI.parse(url.to_s)
144
+ end
145
+ end
146
+
147
+ def http_class
148
+ @http_class ||= begin
149
+ if proxy = ENV['HTTP_PROXY'] || ENV['http_proxy'] then
150
+ p_uri = URI.parse(proxy)
151
+ p_user, p_pass = p_uri.user, p_uri.password if p_uri.user
152
+ Net::HTTP::Proxy(p_uri.host, p_uri.port, p_user, p_pass)
153
+ else
154
+ Net::HTTP
155
+ end
156
+ end
157
+ end
158
+
159
+ def authenticate_request(uri, req)
160
+ http = http_class.new(uri.host, uri.port)
161
+ # Set cookie value
162
+ req['Cookie'] = options[:cookies]
163
+ logger.debug "Method authenticate_request url #{uri}"
164
+ # Force to use SSL
165
+ http.use_ssl = (uri.scheme == 'https')
166
+ # Set cookie value
167
+ req['Cookie'] = options[:cookies]
168
+ logger.debug "Method authenticate_request Cookie value #{req['Cookie']}"
169
+ # Not verify SSL
170
+ if options[:ssl_verify] == false
171
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
172
+ end
173
+ # Set the timeout
174
+ if options[:timeout]
175
+ http.open_timeout = options[:timeout]
176
+ http.read_timeout = options[:timeout]
177
+ end
178
+ if auth = @authentication
179
+ req.send(auth[:method], *auth[:params])
180
+ end
181
+ http
182
+ end
183
+
184
+ def assign_body(req, body)
185
+ if body.respond_to? :length
186
+ req.body = body
187
+ else
188
+ req.body_stream = body
189
+ if body.respond_to? :stat
190
+ req["Content-Length"] = body.stat.size.to_s
191
+ elsif req["Content-Size"].nil?
192
+ req["Transfer-Encoding"] = 'chunked'
193
+ end
194
+ end
195
+ end
196
+
197
+ def handle_request(uri, req, retry_count = 0)
198
+ logger.debug "Method handle_request #{req.method} #{uri}"
199
+ http = authenticate_request(uri, req)
200
+ status, body, headers = nil
201
+ http.request(req) { |resp|
202
+ status = resp.code.to_i
203
+ body = resp.body
204
+ headers = resp
205
+ }
206
+
207
+ logger.debug "RECEIVED #{status}"
208
+
209
+ if 200 <= status && status < 300
210
+ logger.debug "Method handler request status #{status} and body #{body}"
211
+ logger.debug "End method handle_request #{status}"
212
+ return body
213
+ elsif 300 <= status && status < 400
214
+ # follow the redirected a limited number of times
215
+ location = headers["location"]
216
+ logger.debug "REDIRECTING: #{location.inspect}"
217
+ if retry_count <= 3
218
+ new_uri = URI.parse(location)
219
+ if new_uri.relative?
220
+ new_uri = uri + location
221
+ end
222
+ new_req = req.class.new(uri.request_uri)
223
+ handle_request(new_uri, new_req, retry_count + 1)
224
+ else
225
+ raise HTTPError.new("Too many redirects")
226
+ end
227
+ elsif 400 <= status && status < 500
228
+ # Problem: some codes 400, 405, 403, 409, 500 have multiple meanings
229
+ logger.error "Error occurred when handling request:\n#{body}"
230
+ case status
231
+ when 400; raise Error::InvalidArgument.new(body)
232
+ # FIXME: can also be filterNotValid
233
+ when 401; raise HTTPError::AuthenticationError.new(body)
234
+ when 404; raise Error::ObjectNotFound.new(body)
235
+ when 403; raise Error::PermissionDenied.new(body)
236
+ # FIXME: can also be streamNotSupported (?? shouldn't that be 405??)
237
+ when 405; raise Error::NotSupported.new(body)
238
+ else
239
+ raise HTTPError::ClientError.new("A HTTP #{status} error occured, for more precision update the code:\n" + body)
240
+ end
241
+ elsif 500 <= status
242
+ raise HTTPError::ServerError.new("The server encountered an internal error #{status} (this could be a client error though):\n" + body)
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,82 @@
1
+ module ActiveCMIS
2
+ module Internal
3
+ # @private
4
+ module Utils
5
+ # @private
6
+ def self.escape_url_parameter(parameter)
7
+ control = "\x00-\x1F\x7F"
8
+ space = " "
9
+ delims = "<>#%\""
10
+ unwise = '{}|\\\\^\[\]`'
11
+ query = ";/?:@&=+,$"
12
+ escape(parameter, /[#{control+space+delims+unwise+query}]/o)
13
+ end
14
+
15
+ # Given an url (string or URI) returns that url with the given parameters appended
16
+ #
17
+ # This method does not perform any encoding on the paramter or key values.
18
+ # This method does not check the existing parameters for duplication in keys
19
+ # @private
20
+ def self.append_parameters(uri, parameters)
21
+ uri = case uri
22
+ when String; string = true; URI.parse(uri)
23
+ when URI; uri.dup
24
+ end
25
+ uri.query = [uri.query, *parameters.map {|key, value| "#{key}=#{value}"} ].compact.join "&"
26
+ if string
27
+ uri.to_s
28
+ else
29
+ uri
30
+ end
31
+ end
32
+
33
+ # FIXME?? percent_encode and escape_url_parameter serve nearly the same purpose, replace one?
34
+ # @private
35
+ def self.percent_encode(string)
36
+ escape(string, /[^#{URI::PATTERN::UNRESERVED}]/o)
37
+ end
38
+
39
+ def self.escape(string, pattern)
40
+ if defined?(URI::Parser)
41
+ parser = URI::Parser.new
42
+ parser.escape(string, pattern)
43
+ else
44
+ URI.escape(string, pattern)
45
+ end
46
+ end
47
+
48
+ # Returns id if id is already an object, object_by_id if id is a string, nil otherwise
49
+ # @private
50
+ def self.string_or_id_to_object(repository, id)
51
+ # FIXME: only used in lib/activecmis/relationship.rb, the repository parameter
52
+ # would be unnecessary if included.
53
+ # Should this be a generic method, or should this be moved to the Relationship class?
54
+ # Or should I start including this module in every place that needs it?
55
+ case id
56
+ when String; repository.object_by_id(id)
57
+ when ::ActiveCMIS::Object; id
58
+ end
59
+ end
60
+
61
+ # @private
62
+ def self.extract_links(xml, rel, type_main = nil, type_params = {})
63
+ links = xml.xpath("at:link[@rel = '#{rel}']", NS::COMBINED)
64
+
65
+ if type_main
66
+ type_main = Regexp.escape(type_main)
67
+ if type_params.empty?
68
+ regex = /#{type_main}/
69
+ else
70
+ parameters = type_params.map {|k,v| "#{Regexp.escape(k)}=#{Regexp.escape(v)}" }.join(";\s*")
71
+ regex = /#{type_main};\s*#{parameters}/
72
+ end
73
+ links = links.select do |node|
74
+ regex === node.attribute("type").to_s
75
+ end
76
+ end
77
+
78
+ links.map {|l| l.attribute("href").to_s}
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,18 @@
1
+ module ActiveCMIS
2
+ # This module defines namespaces that often occur in the REST/Atompub API to CMIS
3
+ module NS
4
+ CMIS_CORE = "http://docs.oasis-open.org/ns/cmis/core/200908/"
5
+ CMIS_REST = "http://docs.oasis-open.org/ns/cmis/restatom/200908/"
6
+ CMIS_MESSAGING = "http://docs.oasis-open.org/ns/cmis/messaging/200908/"
7
+ APP = "http://www.w3.org/2007/app"
8
+ ATOM = "http://www.w3.org/2005/Atom"
9
+
10
+ COMBINED = {
11
+ "xmlns:c" => CMIS_CORE,
12
+ "xmlns:cra" => CMIS_REST,
13
+ "xmlns:cm" => CMIS_MESSAGING,
14
+ "xmlns:app" => APP,
15
+ "xmlns:at" => ATOM
16
+ }
17
+ end
18
+ end
@@ -0,0 +1,563 @@
1
+ module ActiveCMIS
2
+ class Object
3
+ include Internal::Caching
4
+
5
+ # The repository that contains this object
6
+ # @return [Repository]
7
+ attr_reader :repository
8
+
9
+ # The cmis:objectId of the object, or nil if the document does not yet exist in the repository
10
+ # @return [String,nil]
11
+ attr_reader :key
12
+ alias id key
13
+
14
+ # Creates a representation of an CMIS Object in the repository
15
+ #
16
+ # Not meant for direct use, use {Repository#object_by_id} instead. To create a new object use the new method on the type that you want the new object to have.
17
+ #
18
+ # @param [Repository] repository The repository this object belongs to
19
+ # @param [Nokogiri::XML::Node,nil] data The preparsed XML Atom Entry or nil if the object does not yet exist
20
+ # @param [Hash] parameters A list of parameters used to get the Atom Entry
21
+ def initialize(repository, data, parameters)
22
+ @repository = repository
23
+ @data = data
24
+
25
+ @updated_attributes = []
26
+
27
+ if @data.nil?
28
+ # Creating a new type from scratch
29
+ raise Error::Constraint.new("This type is not creatable") unless self.class.creatable
30
+ @key = parameters["id"]
31
+ @allowable_actions = {}
32
+ @parent_folders = [] # start unlinked
33
+ else
34
+ @key = parameters["id"] || attribute('cmis:objectId')
35
+ @self_link = data.xpath("at:link[@rel = 'self']/@href", NS::COMBINED).first
36
+ @self_link = @self_link.text
37
+ end
38
+ @used_parameters = parameters
39
+ # FIXME: decide? parameters to use?? always same ? or parameter with reload ?
40
+ end
41
+
42
+ # Via method missing attribute accessors and setters are provided for the CMIS attributes of an object.
43
+ # If attributes have a colon in their name you can access them by changing the colon in a dot
44
+ #
45
+ # @example Set an attribute named DateTimePropMV
46
+ # my_object.DateTimePropMV = Time.now #=> "Wed Apr 07 14:34:19 0200 2010"
47
+ # @example Read the attribute named DateTimePropMV
48
+ # my_object.DateTimePropMV #=> "Wed Apr 07 14:34:19 0200 2010"
49
+ # @example Get the cmis:name of an object
50
+ # my_object.cmis.name #=> "My object 25"
51
+ def method_missing(method, *parameters)
52
+ string = method.to_s
53
+ if string[-1] == ?=
54
+ assignment = true
55
+ string = string[0..-2]
56
+ end
57
+ if attributes.keys.include? string
58
+ if assignment
59
+ update(string => parameters.first)
60
+ else
61
+ attribute(string)
62
+ end
63
+ elsif self.class.attribute_prefixes.include? string
64
+ if assignment
65
+ raise NotImplementedError.new("Mass assignment not yet supported to prefix")
66
+ else
67
+ @attribute_prefix ||= {}
68
+ @attribute_prefix[method] ||= AttributePrefix.new(self, string)
69
+ end
70
+ else
71
+ super
72
+ end
73
+ end
74
+
75
+ # @return [String]
76
+ def inspect
77
+ "#<#{self.class.inspect} @key=#{key}>"
78
+ end
79
+
80
+ # Shorthand for the cmis:name of an object
81
+ # @return [String]
82
+ def name
83
+ attribute('cmis:name')
84
+ end
85
+ cache :name
86
+
87
+ # A list of all attributes that have changed locally
88
+ # @return [Array<String>]
89
+ attr_reader :updated_attributes
90
+
91
+ # Attribute getter for the CMIS attributes of an object
92
+ # @param [String] name The property id of the attribute
93
+ def attribute(name)
94
+ attributes[name]
95
+ end
96
+
97
+ # Attribute getter for the CMIS attributes of an object
98
+ # @return [Hash{String => ::Object}] All attributes, the keys are the property ids of the attributes
99
+ def attributes
100
+ self.class.attributes.inject({}) do |hash, (key, attr)|
101
+ if data.nil?
102
+ if key == "cmis:objectTypeId"
103
+ hash[key] = self.class.id
104
+ else
105
+ hash[key] = nil
106
+ end
107
+ else
108
+ properties = data.xpath("cra:object/c:properties", NS::COMBINED)
109
+ values = attr.extract_property(properties)
110
+ hash[key] = if values.nil? || values.empty?
111
+ if attr.repeating
112
+ []
113
+ else
114
+ nil
115
+ end
116
+ elsif attr.repeating
117
+ values.map do |value|
118
+ attr.property_type.cmis2rb(value)
119
+ end
120
+ else
121
+ attr.property_type.cmis2rb(values.first)
122
+ end
123
+ end
124
+ hash
125
+ end
126
+ end
127
+ cache :attributes
128
+
129
+ # Attribute setter for all CMIS attributes. This only updates this copy of the object.
130
+ # Use save to make these changes permanent and visible in the repositorhy.
131
+ # (use {#reload} after save on other instances of this document to reflect these changes)
132
+ #
133
+ # @param [{String => ::Object}] attributes A hash with new values for selected attributes
134
+ # @raise [Error::Constraint] if a readonly attribute is set
135
+ # @raise if a value can't be converted to the necessary type or falls outside the constraints
136
+ # @return [{String => ::Object}] The updated attributes hash
137
+ def update(attributes)
138
+ attributes.each do |key, value|
139
+ if (property = self.class.attributes[key.to_s]).nil?
140
+ raise Error::Constraint.new("You are trying to add an unknown attribute (#{key})")
141
+ else
142
+ property.validate_ruby_value(value)
143
+ end
144
+ end
145
+ self.updated_attributes.concat(attributes.keys).uniq!
146
+ self.attributes.merge!(attributes)
147
+ end
148
+
149
+ # Saves all changes to the object in the repository.
150
+ #
151
+ # *WARNING*: because of the way CMIS is constructed the save operation is not atomic if updates happen to different aspects of the object
152
+ # (parent folders, attributes, content stream, acl), we can't work around this because CMIS lacks transactions
153
+ # @return [Object]
154
+ def save
155
+ # FIXME: find a way to handle errors?
156
+ # FIXME: what if multiple objects are created in the course of a save operation?
157
+ result = self
158
+ updated_aspects.each do |hash|
159
+ result = result.send(hash[:message], *hash[:parameters])
160
+ end
161
+ result
162
+ end
163
+
164
+ # @return [Hash{String => Boolean,String}] A hash containing all actions allowed on this object for the current user
165
+ def allowable_actions
166
+ actions = {}
167
+ _allowable_actions.children.map do |node|
168
+ actions[node.name.sub("can", "")] = case t = node.text
169
+ when "true", "1"; true
170
+ when "false", "0"; false
171
+ else t
172
+ end
173
+ end
174
+ actions
175
+ end
176
+ cache :allowable_actions
177
+
178
+ # Returns all relationships where this object is the target
179
+ # @return [Collection]
180
+ def target_relations
181
+ query = "at:link[@rel = '#{Rel[repository.cmis_version][:relationships]}']/@href"
182
+ link = data.xpath(query, NS::COMBINED)
183
+ if link.length == 1
184
+ link = Internal::Utils.append_parameters(link.text, "relationshipDirection" => "target", "includeSubRelationshipTypes" => true)
185
+ Collection.new(repository, link)
186
+ else
187
+ raise "Expected exactly 1 relationships link for #{key}, got #{link.length}, are you sure this is a document/folder?"
188
+ end
189
+ end
190
+ cache :target_relations
191
+
192
+ # Returns all relationships where this object is the source
193
+ # @return [Collection]
194
+ def source_relations
195
+ query = "at:link[@rel = '#{Rel[repository.cmis_version][:relationships]}']/@href"
196
+ link = data.xpath(query, NS::COMBINED)
197
+ if link.length == 1
198
+ link = Internal::Utils.append_parameters(link.text, "relationshipDirection" => "source", "includeSubRelationshipTypes" => true)
199
+ Collection.new(repository, link)
200
+ else
201
+ raise "Expected exactly 1 relationships link for #{key}, got #{link.length}, are you sure this is a document/folder?"
202
+ end
203
+ end
204
+ cache :source_relations
205
+
206
+ # @return [Acl,nil] The ACL of the document, if there is any at all
207
+ def acl
208
+ if repository.acls_readable? && allowable_actions["GetACL"]
209
+ # FIXME: actual query should perhaps look at CMIS version before deciding which relation is applicable?
210
+ query = "at:link[@rel = '#{Rel[repository.cmis_version][:acl]}']/@href"
211
+ link = data.xpath(query, NS::COMBINED)
212
+ if link.length == 1
213
+ Acl.new(repository, self, link.first.text, data.xpath("cra:object/c:acl", NS::COMBINED))
214
+ else
215
+ raise "Expected exactly 1 acl for #{key}, got #{link.length}"
216
+ end
217
+ end
218
+ end
219
+
220
+ # Depending on the repository there can be more than 1 parent folder
221
+ # Always returns [] for relationships, policies may also return []
222
+ #
223
+ # @return [Array<Folder>,Collection] The parent folders in an array or a collection
224
+ def parent_folders
225
+ parent_feed = Internal::Utils.extract_links(data, 'up', 'application/atom+xml','type' => 'feed')
226
+ unless parent_feed.empty?
227
+ Collection.new(repository, parent_feed.first)
228
+ else
229
+ parent_entry = Internal::Utils.extract_links(data, 'up', 'application/atom+xml','type' => 'entry')
230
+ unless parent_entry.empty?
231
+ e = conn.get_atom_entry(parent_entry.first)
232
+ [ActiveCMIS::Object.from_atom_entry(repository, e)]
233
+ else
234
+ []
235
+ end
236
+ end
237
+ end
238
+ cache :parent_folders
239
+
240
+ # Files an object in a folder, if the repository supports multi-filing this will be an additional folder, else it will replace the previous folder
241
+ #
242
+ # @param [Folder] folder The (replacement) folder
243
+ # @return [void]
244
+ def file(folder)
245
+ raise Error::Constraint.new("Filing not supported for objects of type: #{self.class.id}") unless self.class.fileable
246
+ @original_parent_folders ||= parent_folders.dup
247
+ if repository.capabilities["MultiFiling"]
248
+ @parent_folders << folder unless @parent_folders.detect {|f| f.id == folder.id }
249
+ else
250
+ @parent_folders = [folder]
251
+ end
252
+ end
253
+
254
+ # Removes an object from a given folder or all folders. If the repository does not support unfiling this method throws an error if the document would have no folders left after unfiling.
255
+ #
256
+ # @param [Folder,nil] folder
257
+ # @return [void]
258
+ def unfile(folder = nil)
259
+ # Conundrum: should this throw exception if folder is not actually among parent_folders?
260
+ raise Error::Constraint.new("Filing not supported for objects of type: #{self.class.id}") unless self.class.fileable
261
+ @original_parent_folders ||= parent_folders.dup
262
+ if repository.capabilities["UnFiling"]
263
+ if folder.nil?
264
+ @parent_folders = []
265
+ else
266
+ @parent_folders.delete_if {|f| f.id == folder.id}
267
+ end
268
+ else
269
+ @parent_folders.delete_if {|f| f.id == folder.id}
270
+ if @parent_folders.empty?
271
+ @parent_folders = @original_parent_folders
272
+ @original_parent_folders = nil
273
+ raise Error::NotSupported.new("Unfiling not supported for this repository")
274
+ end
275
+ end
276
+ end
277
+
278
+ # Empties the locally cached and updated values, updated data is asked from the server the next time a value is requested.
279
+ # @raise [RuntimeError] if the object is not yet created on the server
280
+ # @return [void]
281
+ def reload
282
+ if @self_link.nil?
283
+ raise "Can't reload unsaved object"
284
+ else
285
+ __reload
286
+ @updated_attributes = []
287
+ @original_parent_folders = nil
288
+ end
289
+ end
290
+
291
+ # Tries to delete the object
292
+ # To delete all versions of a Document try #all_versions.delete
293
+ #
294
+ # For policies this may just remove the policy from the policy group
295
+ # of a document, this depends on how you retrieved the policy. Be careful
296
+ def destroy
297
+ conn.delete(self_link)
298
+ end
299
+
300
+ private
301
+ # Internal value, not meant for common-day use
302
+ # @private
303
+ # @return [Hash]
304
+ attr_reader :used_parameters
305
+
306
+ def self_link(options = {})
307
+ url = @self_link
308
+ if options.empty?
309
+ url
310
+ else
311
+ Internal::Utils.append_parameters(url, options)
312
+ end
313
+ #repository.object_by_id_url(options.merge("id" => id))
314
+ end
315
+
316
+ def data
317
+ parameters = {"includeAllowableActions" => true, "renditionFilter" => "*", "includeACL" => true}
318
+ data = conn.get_atom_entry(self_link(parameters))
319
+ @used_parameters = parameters
320
+ data
321
+ end
322
+ cache :data
323
+
324
+ def conn
325
+ @repository.conn
326
+ end
327
+
328
+ def _allowable_actions
329
+ if actions = data.xpath('cra:object/c:allowableActions', NS::COMBINED).first
330
+ actions
331
+ else
332
+ links = data.xpath("at:link[@rel = '#{Rel[repository.cmis_version][:allowableactions]}']/@href", NS::COMBINED)
333
+ if link = links.first
334
+ conn.get_xml(link.text)
335
+ else
336
+ nil
337
+ end
338
+ end
339
+ end
340
+
341
+ # @param properties a hash key/definition pairs of properties to be rendered (defaults to all attributes)
342
+ # @param attributes a hash key/value pairs used to determine the values rendered (defaults to self.attributes)
343
+ # @param options
344
+ # @yield [entry] Optional block to customize the rendered atom entry
345
+ # @yieldparam [Nokogiri::XML::Builder] entry The entry XML builder element on which you can add additional tags (uses the NS::COMBINED namespaces)
346
+ def render_atom_entry(properties = self.class.attributes, attributes = self.attributes, options = {})
347
+ builder = Nokogiri::XML::Builder.new do |xml|
348
+ xml.entry(NS::COMBINED) do
349
+ xml.parent.default_namespace = NS::COMBINED["xmlns:at"]
350
+ xml.author do
351
+ xml.name conn.user # FIXME: find reliable way to set author?
352
+ end
353
+ xml.title attributes["cmis:name"]
354
+ if attributes["cmis:objectId"]
355
+ xml.id_ attributes["cmis:objectId"]
356
+ else
357
+ xml.id_ "random-garbage"
358
+ end
359
+ xml["cra"].object do
360
+ xml["c"].properties do
361
+ properties.each do |key, definition|
362
+ definition.render_property(xml, attributes[key])
363
+ end
364
+ end
365
+ end
366
+ yield(xml) if block_given?
367
+ end
368
+ end
369
+ conn.logger.debug builder.to_xml
370
+ builder.to_xml
371
+ end
372
+
373
+ # @private
374
+ attr_writer :updated_attributes
375
+
376
+ def updated_aspects(checkin = nil)
377
+ result = []
378
+
379
+ if key.nil?
380
+ result << {:message => :save_new_object, :parameters => []}
381
+ if parent_folders.length > 1
382
+ # We started from 0 folders, we already added the first when creating the document
383
+
384
+ # Note: to keep a save operation at least somewhat atomic this might be better done in save_new_object
385
+ result << {:message => :save_folders, :parameters => [parent_folders]}
386
+ end
387
+ else
388
+ if !updated_attributes.empty?
389
+ result << {:message => :save_attributes, :parameters => [updated_attributes, attributes, checkin]}
390
+ end
391
+ if @original_parent_folders
392
+ result << {:message => :save_folders, :parameters => [parent_folders, checkin && !updated_attributes]}
393
+ end
394
+ end
395
+ if acl && acl.updated? # We need to be able to do this for newly created documents and merge the two
396
+ result << {:message => :save_acl, :parameters => [acl]}
397
+ end
398
+
399
+ if result.empty? && checkin
400
+ # NOTE: this needs some thinking through: in particular this may not work well if there would be an updated content stream
401
+ result << {:message => :save_attributes, :parameters => [{}, {}, checkin]}
402
+ end
403
+
404
+ result
405
+ end
406
+
407
+ def save_new_object
408
+ if self.class.required_attributes.any? {|a, _| attribute(a).nil? }
409
+ raise Error::InvalidArgument.new("Not all required attributes are filled in")
410
+ end
411
+
412
+ properties = self.class.attributes.reject do |key, definition|
413
+ # !updated_attributes.include?(key) && !definition.required
414
+ attributes[key].nil? or definition.updatability == "readonly"
415
+ end
416
+ body = render_atom_entry(properties, attributes, :create => true)
417
+
418
+ url = create_url
419
+ response = conn.post(create_url, body, "Content-Type" => "application/atom+xml;type=entry")
420
+ # XXX: Currently ignoring Location header in response
421
+
422
+ response_data = Nokogiri::XML::parse(response).xpath("at:entry", NS::COMBINED) # Assume that a response indicates success?
423
+
424
+ @self_link = response_data.xpath("at:link[@rel = 'self']/@href", NS::COMBINED).first
425
+ @self_link = @self_link.text
426
+ reload
427
+ @key = attribute("cmis:objectId")
428
+
429
+ self
430
+ end
431
+
432
+ def save_attributes(attributes, values, checkin = nil)
433
+ if attributes.empty? && checkin.nil?
434
+ raise "Error: saving attributes but nothing to do"
435
+ end
436
+ properties = self.class.attributes.select {|key,_| updated_attributes.include?(key)}
437
+ body = render_atom_entry(properties, values, :checkin => checkin)
438
+
439
+ if checkin.nil?
440
+ parameters = {}
441
+ else
442
+ checkin, major, comment = *checkin
443
+ parameters = {"checkin" => checkin}
444
+ if checkin
445
+ parameters.merge! "major" => !!major, "checkinComment" => Internal::Utils.escape_url_parameter(comment)
446
+
447
+ if properties.empty?
448
+ # The standard specifies that we can have an empty body here, that does not seem to be true for OpenCMIS
449
+ # body = ""
450
+ end
451
+ end
452
+ end
453
+
454
+ # NOTE: Spec says Entity Tag should be used for changeTokens, that does not seem to work
455
+ if ct = attribute("cmis:changeToken")
456
+ parameters.merge! "changeToken" => Internal::Utils.escape_url_parameter(ct)
457
+ end
458
+
459
+ uri = self_link(parameters)
460
+ response = conn.put(uri, body, "Content-Type" => "application/atom+xml;type=entry")
461
+
462
+ data = Nokogiri::XML.parse(response, nil, nil, Nokogiri::XML::ParseOptions::STRICT).xpath("at:entry", NS::COMBINED)
463
+ if data.xpath("cra:object/c:properties/c:propertyId[@propertyDefinitionId = 'cmis:objectId']/c:value", NS::COMBINED).text == id
464
+ reload
465
+ @data = data
466
+ self
467
+ else
468
+ reload # Updated attributes should be forgotten here
469
+ ActiveCMIS::Object.from_atom_entry(repository, data)
470
+ end
471
+ end
472
+
473
+ def save_folders(requested_parent_folders, checkin = nil)
474
+ current = parent_folders.to_a
475
+ future = requested_parent_folders.to_a
476
+
477
+ common_folders = future.map {|f| f.id}.select {|id| current.any? {|f| f.id == id } }
478
+
479
+ added = future.select {|f1| current.all? {|f2| f1.id != f2.id } }
480
+ removed = current.select {|f1| future.all? {|f2| f1.id != f2.id } }
481
+
482
+ # NOTE: an absent atom:content is important here according to the spec, for the moment I did not suffer from this
483
+ body = render_atom_entry("cmis:objectId" => self.class.attributes["cmis:objectId"])
484
+
485
+ # Note: change token does not seem to matter here
486
+ # FIXME: currently we assume the data returned by post is not important, I'm not sure that this is always true
487
+ if added.empty?
488
+ removed.each do |folder|
489
+ url = repository.unfiled.url
490
+ url = Internal::Utils.append_parameters(url, "removeFrom" => Internal::Utils.escape_url_parameter(removed.id))
491
+ conn.post(url, body, "Content-Type" => "application/atom+xml;type=entry")
492
+ end
493
+ elsif removed.empty?
494
+ added.each do |folder|
495
+ conn.post(folder.items.url, body, "Content-Type" => "application/atom+xml;type=entry")
496
+ end
497
+ else
498
+ removed.zip(added) do |r, a|
499
+ url = a.items.url
500
+ url = Internal::Utils.append_parameters(url, "sourceFolderId" => Internal::Utils.escape_url_parameter(r.id))
501
+ conn.post(url, body, "Content-Type" => "application/atom+xml;type=entry")
502
+ end
503
+ if extra = added[removed.length..-1]
504
+ extra.each do |folder|
505
+ conn.post(folder.items.url, body, "Content-Type" => "application/atom+xml;type=entry")
506
+ end
507
+ end
508
+ end
509
+
510
+ self
511
+ end
512
+
513
+ def save_acl(acl)
514
+ acl.save
515
+ reload
516
+ self
517
+ end
518
+
519
+ class << self
520
+ # The repository this type is defined in
521
+ # @return [Repository]
522
+ attr_reader :repository
523
+
524
+ # @private
525
+ def from_atom_entry(repository, data, parameters = {})
526
+ query = "cra:object/c:properties/c:propertyId[@propertyDefinitionId = '%s']/c:value"
527
+ type_id = data.xpath(query % "cmis:objectTypeId", NS::COMBINED).text
528
+ klass = repository.type_by_id(type_id)
529
+ if klass
530
+ if klass <= self
531
+ klass.new(repository, data, parameters)
532
+ else
533
+ raise "You tried to do from_atom_entry on a type which is not a supertype of the type of the document you identified"
534
+ end
535
+ else
536
+ raise "The object #{extract_property(data, "String", 'cmis:name')} has an unrecognized type #{type_id}"
537
+ end
538
+ end
539
+
540
+ # @private
541
+ def from_parameters(repository, parameters)
542
+ url = repository.object_by_id_url(parameters)
543
+ data = repository.conn.get_atom_entry(url)
544
+ from_atom_entry(repository, data, parameters)
545
+ end
546
+
547
+ # A list of all attributes defined on this object
548
+ # @param [Boolean] inherited Nonfunctional
549
+ # @return [Hash{String => PropertyDefinition}]
550
+ def attributes(inherited = false)
551
+ {}
552
+ end
553
+
554
+ # The key of the CMIS Type
555
+ # @return [String]
556
+ # @raise [NotImplementedError] for Object/Folder/Document/Policy/Relationship
557
+ def key
558
+ raise NotImplementedError
559
+ end
560
+
561
+ end
562
+ end
563
+ end