cmis_active 0.3.7
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/AUTHORS +9 -0
- data/LICENSE +26 -0
- data/README.md +55 -0
- data/Rakefile +34 -0
- data/TODO +7 -0
- data/VERSION.yml +5 -0
- data/active_cmis.gemspec +79 -0
- data/lib/active_cmis.rb +30 -0
- data/lib/active_cmis/acl.rb +181 -0
- data/lib/active_cmis/acl_entry.rb +26 -0
- data/lib/active_cmis/active_cmis.rb +87 -0
- data/lib/active_cmis/atomic_types.rb +245 -0
- data/lib/active_cmis/attribute_prefix.rb +35 -0
- data/lib/active_cmis/collection.rb +206 -0
- data/lib/active_cmis/document.rb +356 -0
- data/lib/active_cmis/exceptions.rb +82 -0
- data/lib/active_cmis/folder.rb +36 -0
- data/lib/active_cmis/internal/caching.rb +86 -0
- data/lib/active_cmis/internal/connection.rb +241 -0
- data/lib/active_cmis/internal/utils.rb +82 -0
- data/lib/active_cmis/ns.rb +18 -0
- data/lib/active_cmis/object.rb +563 -0
- data/lib/active_cmis/policy.rb +13 -0
- data/lib/active_cmis/property_definition.rb +179 -0
- data/lib/active_cmis/query_result.rb +40 -0
- data/lib/active_cmis/rel.rb +17 -0
- data/lib/active_cmis/relationship.rb +49 -0
- data/lib/active_cmis/rendition.rb +86 -0
- data/lib/active_cmis/repository.rb +327 -0
- data/lib/active_cmis/server.rb +121 -0
- data/lib/active_cmis/type.rb +200 -0
- data/lib/active_cmis/version.rb +10 -0
- metadata +132 -0
@@ -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,241 @@
|
|
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
|
+
|
48
|
+
# Ensure the parsed URL is an HTTP one
|
49
|
+
raise HTTPError::ClientError.new("Invalid URL #{url}") unless uri.is_a?(URI::HTTP)
|
50
|
+
|
51
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
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
|
+
# Force to use SSL
|
162
|
+
http.use_ssl = (uri.scheme == 'https')
|
163
|
+
|
164
|
+
if options[:ssl_verfiy] == false
|
165
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
166
|
+
end
|
167
|
+
|
168
|
+
# Set the timeout
|
169
|
+
if options[:timeout]
|
170
|
+
http.open_timeout = options[:timeout]
|
171
|
+
http.read_timeout = options[:timeout]
|
172
|
+
end
|
173
|
+
if auth = @authentication
|
174
|
+
req.send(auth[:method], *auth[:params])
|
175
|
+
end
|
176
|
+
http
|
177
|
+
end
|
178
|
+
|
179
|
+
def assign_body(req, body)
|
180
|
+
if body.respond_to? :length
|
181
|
+
req.body = body
|
182
|
+
else
|
183
|
+
req.body_stream = body
|
184
|
+
if body.respond_to? :stat
|
185
|
+
req["Content-Length"] = body.stat.size.to_s
|
186
|
+
elsif req["Content-Size"].nil?
|
187
|
+
req["Transfer-Encoding"] = 'chunked'
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def handle_request(uri, req, retry_count = 0)
|
193
|
+
logger.debug "#{req.method} #{uri}"
|
194
|
+
http = authenticate_request(uri, req)
|
195
|
+
|
196
|
+
status, body, headers = nil
|
197
|
+
http.request(req) { |resp|
|
198
|
+
status = resp.code.to_i
|
199
|
+
body = resp.body
|
200
|
+
headers = resp
|
201
|
+
}
|
202
|
+
|
203
|
+
logger.debug "RECEIVED #{status}"
|
204
|
+
|
205
|
+
if 200 <= status && status < 300
|
206
|
+
return body
|
207
|
+
elsif 300 <= status && status < 400
|
208
|
+
# follow the redirected a limited number of times
|
209
|
+
location = headers["location"]
|
210
|
+
logger.debug "REDIRECTING: #{location.inspect}"
|
211
|
+
if retry_count <= 3
|
212
|
+
new_uri = URI.parse(location)
|
213
|
+
if new_uri.relative?
|
214
|
+
new_uri = uri + location
|
215
|
+
end
|
216
|
+
new_req = req.class.new(uri.request_uri)
|
217
|
+
handle_request(new_uri, new_req, retry_count + 1)
|
218
|
+
else
|
219
|
+
raise HTTPError.new("Too many redirects")
|
220
|
+
end
|
221
|
+
elsif 400 <= status && status < 500
|
222
|
+
# Problem: some codes 400, 405, 403, 409, 500 have multiple meanings
|
223
|
+
logger.error "Error occurred when handling request:\n#{body}"
|
224
|
+
case status
|
225
|
+
when 400; raise Error::InvalidArgument.new(body)
|
226
|
+
# FIXME: can also be filterNotValid
|
227
|
+
when 401; raise HTTPError::AuthenticationError.new(body)
|
228
|
+
when 404; raise Error::ObjectNotFound.new(body)
|
229
|
+
when 403; raise Error::PermissionDenied.new(body)
|
230
|
+
# FIXME: can also be streamNotSupported (?? shouldn't that be 405??)
|
231
|
+
when 405; raise Error::NotSupported.new(body)
|
232
|
+
else
|
233
|
+
raise HTTPError::ClientError.new("A HTTP #{status} error occured, for more precision update the code:\n" + body)
|
234
|
+
end
|
235
|
+
elsif 500 <= status
|
236
|
+
raise HTTPError::ServerError.new("The server encountered an internal error #{status} (this could be a client error though):\n" + body)
|
237
|
+
end
|
238
|
+
end
|
239
|
+
end
|
240
|
+
end
|
241
|
+
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
|