collectionspace-client 0.3.0 → 0.15.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.git-blame-ignore-revs +8 -0
  3. data/.github/workflows/ci.yml +30 -0
  4. data/.github/workflows/publish.yml +42 -0
  5. data/.gitignore +6 -0
  6. data/.rubocop.yml +4 -8
  7. data/.ruby-version +1 -0
  8. data/Gemfile +3 -1
  9. data/README.md +11 -14
  10. data/Rakefile +44 -2
  11. data/bin/console +26 -0
  12. data/bin/rspec +29 -0
  13. data/collectionspace-client.gemspec +26 -23
  14. data/examples/batches.rb +50 -0
  15. data/examples/demo.rb +10 -8
  16. data/examples/media_with_external_file.rb +11 -9
  17. data/examples/purge_empty_vocabs.rb +10 -8
  18. data/examples/reports.rb +45 -0
  19. data/examples/reset_media_blob.rb +35 -0
  20. data/examples/search.rb +25 -12
  21. data/examples/update_password.rb +1 -31
  22. data/lib/collectionspace/client/batch.rb +55 -0
  23. data/lib/collectionspace/client/client.rb +19 -6
  24. data/lib/collectionspace/client/configuration.rb +16 -14
  25. data/lib/collectionspace/client/helpers.rb +197 -15
  26. data/lib/collectionspace/client/refname.rb +114 -0
  27. data/lib/collectionspace/client/report.rb +180 -0
  28. data/lib/collectionspace/client/request.rb +12 -9
  29. data/lib/collectionspace/client/response.rb +14 -3
  30. data/lib/collectionspace/client/search.rb +9 -5
  31. data/lib/collectionspace/client/service.rb +204 -0
  32. data/lib/collectionspace/client/template.rb +26 -0
  33. data/lib/collectionspace/client/templates/batch.xml.erb +18 -0
  34. data/lib/collectionspace/client/templates/reindex_by_csids.xml.erb +10 -0
  35. data/lib/collectionspace/client/templates/reindex_by_doctype.xml.erb +5 -0
  36. data/lib/collectionspace/client/templates/reindex_full_text.xml.erb +51 -0
  37. data/lib/collectionspace/client/templates/report.xml.erb +16 -0
  38. data/lib/collectionspace/client/templates/reset_media_blob.xml.erb +6 -0
  39. data/lib/collectionspace/client/version.rb +3 -1
  40. data/lib/collectionspace/client.rb +25 -12
  41. metadata +68 -6
@@ -1,33 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
4
- require 'awesome_print'
5
- require 'base64'
6
- require 'collectionspace/client'
7
-
8
- CS_CFG_URL = ENV.fetch('CS_CFG_URL', 'https://core.dev.collectionspace.org/cspace-services')
9
- CS_CFG_USER = ENV.fetch('CS_CFG_USER', 'admin@core.collectionspace.org')
10
- CS_CFG_PASS = ENV.fetch('CS_CFG_PASS', 'Administrator')
11
- CS_UPD_USER = ENV.fetch('CS_UPD_USER', 'admin@core.collectionspace.org')
12
- CS_UPD_PASS = Base64.encode64(ENV.fetch('CS_UPD_PASS', 'Administrator')).chomp
13
-
14
- client = CollectionSpace::Client.new(
15
- CollectionSpace::Configuration.new(
16
- base_uri: CS_CFG_URL,
17
- username: CS_CFG_USER,
18
- password: CS_CFG_PASS
19
- )
20
- )
21
-
22
- PAYLOAD = <<~XML
23
- <ns2:accounts_common xmlns:ns2="http://collectionspace.org/services/account">
24
- <userId>#{CS_UPD_USER}</userId>
25
- <password>#{CS_UPD_PASS}</password>
26
- </ns2:accounts_common>
27
- XML
28
-
29
- client.all('accounts').each do |item|
30
- next unless item['email'] == CS_UPD_USER
31
-
32
- ap client.put(item['uri'], PAYLOAD).parsed
33
- end
3
+ puts "Update password has been moved to Rake task: `cli:update_password[args...]`"
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollectionSpace
4
+ # CollectionSpace batch
5
+ class Batch
6
+ def self.all
7
+ [
8
+ {
9
+ name: "Update Current Location",
10
+ notes: "Recompute the current location of Object records, based on the " \
11
+ "related Location/Movement/Inventory records. Runs on a single record " \
12
+ "or all records.",
13
+ doctype: %w[CollectionObject],
14
+ supports_single_doc: "true",
15
+ supports_doc_list: "false",
16
+ supports_group: "false",
17
+ supports_no_context: "true",
18
+ creates_new_focus: "false",
19
+ classname:
20
+ "org.collectionspace.services.batch.nuxeo.UpdateObjectLocationBatchJob"
21
+ },
22
+ {
23
+ name: "Update Inventory Status",
24
+ notes: "Set the inventory status of selected Object records. Runs on a " \
25
+ "record list only.",
26
+ doctype: %w[CollectionObject],
27
+ supports_single_doc: "false",
28
+ supports_doc_list: "true",
29
+ supports_group: "false",
30
+ supports_no_context: "false",
31
+ creates_new_focus: "false",
32
+ classname:
33
+ "org.collectionspace.services.batch.nuxeo.UpdateInventoryStatusBatchJob"
34
+ },
35
+ {
36
+ name: "Merge Authority Items",
37
+ notes: "Merge an authority item into a target, and update all " \
38
+ "referencing records. Runs on a single record only.",
39
+ doctype: %w[],
40
+ supports_single_doc: "true",
41
+ supports_doc_list: "false",
42
+ supports_group: "false",
43
+ supports_no_context: "false",
44
+ creates_new_focus: "false",
45
+ classname:
46
+ "org.collectionspace.services.batch.nuxeo.MergeAuthorityItemsBatchJob"
47
+ }
48
+ ]
49
+ end
50
+
51
+ def self.find(key, value)
52
+ all.find { |batch| batch[key] == value }
53
+ end
54
+ end
55
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CollectionSpace
2
4
  # CollectionSpace client
3
5
  class Client
@@ -6,28 +8,39 @@ module CollectionSpace
6
8
 
7
9
  def initialize(config = Configuration.new)
8
10
  unless config.is_a? CollectionSpace::Configuration
9
- raise CollectionSpace::ArgumentError, 'Invalid configuration object'
11
+ raise CollectionSpace::ArgumentError, "Invalid configuration object"
10
12
  end
11
13
 
12
14
  @config = config
13
15
  end
14
16
 
15
17
  def get(path, options = {})
16
- request 'GET', path, options
18
+ request "GET", path, options
17
19
  end
18
20
 
19
21
  def post(path, payload, options = {})
20
22
  check_payload(payload)
21
- request 'POST', path, { body: payload }.merge(options)
23
+ request "POST", path, {body: payload}.merge(options)
24
+ end
25
+
26
+ def post_file(file, options = {})
27
+ file = File.expand_path(file)
28
+ raise ArgumentError, "cannot find file #{file}" unless File.exist? file
29
+
30
+ request "POST", "blobs", {
31
+ body: {
32
+ file: File.open(file)
33
+ }
34
+ }.merge(options)
22
35
  end
23
36
 
24
- def put(path, payload)
37
+ def put(path, payload, options = {})
25
38
  check_payload(payload)
26
- request 'PUT', path, body: payload
39
+ request "PUT", path, {body: payload}.merge(options)
27
40
  end
28
41
 
29
42
  def delete(path)
30
- request 'DELETE', path
43
+ request "DELETE", path
31
44
  end
32
45
 
33
46
  private
@@ -1,25 +1,27 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CollectionSpace
2
4
  # CollectionSpace configuration
3
5
  class Configuration
4
- def defaults
5
- {
6
- base_uri: nil,
7
- username: nil,
8
- password: nil,
9
- page_size: 25,
10
- include_deleted: false,
11
- throttle: 0,
12
- verify_ssl: true
13
- }
14
- end
6
+ DEFAULTS = {
7
+ base_uri: nil,
8
+ username: nil,
9
+ password: nil,
10
+ page_size: 25,
11
+ include_deleted: false,
12
+ throttle: 0,
13
+ verbose: false,
14
+ verify_ssl: true
15
+ }.freeze
16
+
17
+ attr_accessor :base_uri, :username, :password, :page_size, :include_deleted, :throttle, :verbose, :verify_ssl
15
18
 
16
19
  def initialize(settings = {})
17
- settings = defaults.merge(settings)
20
+ settings = DEFAULTS.merge(settings)
18
21
  settings.each do |property, value|
19
- next unless defaults.key? property
22
+ next unless DEFAULTS.key?(property)
20
23
 
21
24
  instance_variable_set("@#{property}", value)
22
- self.class.send(:attr_accessor, property)
23
25
  end
24
26
  end
25
27
  end
@@ -1,6 +1,40 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CollectionSpace
2
4
  # Helper methods for client requests
3
5
  module Helpers
6
+ # add / update batch job
7
+ def add_batch_job(name, template, data = {}, params = {pgSz: 100})
8
+ payload = Template.process(template, data)
9
+ response = get("batch", {query: params})
10
+ create_or_update(response, "batch", "name", name, payload)
11
+ end
12
+
13
+ # add / update batches and data updates
14
+ def add_batch(data = {}, params = {pgSz: 100})
15
+ payload = Template.process("batch", data)
16
+ response = get("batch", {query: params})
17
+ create_or_update(response, "batch", "name", data[:name], payload)
18
+ end
19
+
20
+ # add / update reports
21
+ def add_report(data = {}, params = {pgSz: 100})
22
+ payload = Template.process("report", data)
23
+ response = get("reports", {query: params})
24
+ create_or_update(response, "reports", "name", data[:name], payload)
25
+ end
26
+
27
+ # returns Array of authority doctypes for use in setting up batches
28
+ def authority_doctypes
29
+ response = get("/servicegroups/authority")
30
+ unless response.result.success?
31
+ raise CollectionSpace::RequestError, response.result.body
32
+ end
33
+
34
+ result = response.result.parsed_response
35
+ result.dig("document", "servicegroups_common", "hasDocTypes", "hasDocType")
36
+ end
37
+
4
38
  # get ALL records at path by paging through record set
5
39
  def all(path, options = {})
6
40
  list_type, list_item = get_list_types(path)
@@ -8,12 +42,10 @@ module CollectionSpace
8
42
  return [] unless iterations.positive?
9
43
 
10
44
  Enumerator::Lazy.new(0...iterations) do |yielder, i|
11
- response = request('GET', path, options.merge(query: { pgNum: i }))
12
- unless response.result.success?
13
- raise CollectionSpace::RequestError, response.result.body
14
- end
45
+ response = request("GET", path, options.merge(query: {pgNum: i}))
46
+ raise CollectionSpace::RequestError, response.result.body unless response.result.success?
15
47
 
16
- items_in_page = response.parsed[list_type].fetch('itemsInPage', 0).to_i
48
+ items_in_page = response.parsed[list_type].fetch("itemsInPage", 0).to_i
17
49
  list_items = items_in_page.positive? ? response.parsed[list_type][list_item] : []
18
50
  list_items = [list_items] if items_in_page == 1
19
51
 
@@ -23,25 +55,175 @@ module CollectionSpace
23
55
 
24
56
  def count(path)
25
57
  list_type, = get_list_types(path)
26
- response = request('GET', path, query: { pgNum: 0, pgSz: 1 })
27
- response.parsed[list_type]['totalItems'].to_i if response.result.success?
58
+ response = request("GET", path, query: {pgNum: 0, pgSz: 1})
59
+ raise CollectionSpace::RequestError, response.result.body unless response.result.success?
60
+
61
+ response.parsed[list_type]["totalItems"].to_i
62
+ end
63
+
64
+ # get the tenant domain from a system required top level authority (person)
65
+ def domain
66
+ path = "personauthorities"
67
+ response = request("GET", path, query: {pgNum: 0, pgSz: 1})
68
+ raise CollectionSpace::RequestError, response.result.body unless response.result.success?
69
+
70
+ refname = response.parsed.dig(*get_list_types(path), "refName")
71
+ CollectionSpace::RefName.parse(refname)[:domain]
72
+ end
73
+
74
+ # find procedure or object by type and id
75
+ # find authority/vocab term by type, subtype, and refname
76
+ def find(type:, value:, subtype: nil, field: nil, schema: "common", sort: nil, operator: "=")
77
+ service = CollectionSpace::Service.get(type: type, subtype: subtype)
78
+ field ||= service[:term] # this will be set if it is an authority or vocabulary, otherwise nil
79
+ field ||= service[:identifier]
80
+ search_args = CollectionSpace::Search.new.from_hash(
81
+ path: service[:path],
82
+ namespace: "#{service[:ns_prefix]}_#{schema}",
83
+ field: field,
84
+ expression: "#{operator} '#{value.gsub(/'/, "\\\\'")}'"
85
+ )
86
+ search(search_args, sortBy: CollectionSpace::Search::DEFAULT_SORT)
87
+ end
88
+ # rubocop:enable Metrics/ParameterLists
89
+
90
+ # @param subject_csid [String] to be searched as `sbj` value
91
+ # @param object_csid [String] to be searched as `obj` value
92
+ # @param rel_type [String<'affects', 'hasBroader'>, nil] to be searched as `prd` value
93
+ def find_relation(subject_csid:, object_csid:, rel_type: nil)
94
+ if rel_type
95
+ get("relations", query: {"sbj" => subject_csid, "obj" => object_csid, "prd" => rel_type})
96
+ else
97
+ warn(
98
+ "No rel_type specified, so multiple types of relations between #{subject_csid} and #{object_csid} may be returned",
99
+ uplevel: 1
100
+ )
101
+ get("relations", query: {"sbj" => subject_csid, "obj" => object_csid})
102
+ end
28
103
  end
29
104
 
30
105
  def get_list_types(path)
31
106
  {
32
- 'accounts' => %w[accounts_common_list account_list_item],
33
- 'relations' => %w[relations_common_list relation_list_item],
107
+ "accounts" => %w[accounts_common_list account_list_item],
108
+ "relations" => %w[relations_common_list relation_list_item]
34
109
  }.fetch(path, %w[abstract_common_list list_item])
35
110
  end
36
111
 
37
- def prepare_query(query, options = {})
38
- query_string = "#{query.type}:#{query.field} #{query.expression}"
39
- options.merge(query: { as: query_string })
112
+ def reindex_full_text(doctype, csids = [])
113
+ if csids.any?
114
+ run_job(
115
+ "Reindex Full Text", :reindex_full_text, :reindex_by_csids, {doctype: doctype, csids: csids}
116
+ )
117
+ else
118
+ run_job(
119
+ "Reindex Full Text", :reindex_full_text, :reindex_by_doctype, {doctype: doctype}
120
+ )
121
+ end
122
+ end
123
+
124
+ # @param id [String] media record's identificationNumber value
125
+ # @param url [String] blobUri value
126
+ # @param verbose [Boolean] whether to put brief report of outcome to STDOUT
127
+ # @param ensure_safe_url [Boolean] set to false if using FILE URIs or
128
+ # other non-HTTPS URIs
129
+ # @param delete_existing_blob [Boolean] set to false if you have already
130
+ # manually deleted blobs
131
+ def reset_media_blob(id:, url:, verbose: false,
132
+ ensure_safe_url: true,
133
+ delete_existing_blob: true)
134
+ if ensure_safe_url
135
+ unless URI.parse(url).instance_of? URI::HTTPS
136
+ raise CollectionSpace::ArgumentError, "Not a valid url #{url}"
137
+ end
138
+ end
139
+
140
+ response = find(type: "media", value: id, field: "identificationNumber")
141
+ unless response.result.success?
142
+ if verbose
143
+ puts "#{id}\tfailure\tAPI request error: #{response.result.body}"
144
+ else
145
+ raise CollectionSpace::RequestError, response.result.body
146
+ end
147
+ end
148
+
149
+ found = response.parsed
150
+ total = found["abstract_common_list"]["totalItems"].to_i
151
+
152
+ if total.zero?
153
+ msg = "Media #{id} not found"
154
+ if verbose
155
+ puts "#{id}\tfailure\t#{msg}"
156
+ else
157
+ raise CollectionSpace::NotFoundError, msg
158
+ end
159
+ elsif total > 1
160
+ msg = "Found multiple media records for #{id}"
161
+ if verbose
162
+ puts "#{id}\tfailure\t#{msg}"
163
+ else
164
+ raise CollectionSpace::DuplicateIdFound, msg
165
+ end
166
+ end
167
+
168
+ media_uri = found["abstract_common_list"]["list_item"]["uri"]
169
+
170
+ if delete_existing_blob
171
+ blob_csid = found["abstract_common_list"]["list_item"]["blobCsid"]
172
+ delete("/blobs/#{blob_csid}") if blob_csid
173
+ end
174
+
175
+ payload = Template.process(:reset_media_blob, {id: id})
176
+ response = put(media_uri, payload, query: {"blobUri" => url})
177
+ if verbose
178
+ if response.result.success?
179
+ puts "#{id}\tsuccess\t"
180
+ else
181
+ puts "#{id}\tfailure\t#{response.parsed}"
182
+ end
183
+ else
184
+ response
185
+ end
186
+ end
187
+
188
+ def run_job(name, template, invoke_template, data = {})
189
+ payload = Template.process(invoke_template, data)
190
+ job = add_batch_job(name, template)
191
+ path = job.parsed["document"]["collectionspace_core"]["uri"]
192
+ post(path, payload)
193
+ end
194
+
195
+ def search(query, params = {})
196
+ options = prepare_query(query, params)
197
+ request "GET", query.path, options
198
+ end
199
+
200
+ def keyword_search(type:, value:, subtype: nil, sort: nil)
201
+ service = CollectionSpace::Service.get(type: type, subtype: subtype)
202
+ options = prepare_keyword_query(value, {sortBy: CollectionSpace::Search::DEFAULT_SORT})
203
+ request "GET", service[:path], options
204
+ end
205
+
206
+ def service(type:, subtype: "")
207
+ CollectionSpace::Service.get(type: type, subtype: subtype)
208
+ end
209
+
210
+ private
211
+
212
+ def create_or_update(response, path, property, value, payload)
213
+ list_type, item_type = get_list_types(path)
214
+ item = response.find(list_type, item_type, property, value)
215
+ path = item ? "#{path}/#{item["csid"]}" : path
216
+ item ? put(path, payload) : post(path, payload)
217
+ end
218
+
219
+ def prepare_query(query, params = {})
220
+ query_string = "#{query.namespace}:#{query.field} #{query.expression}"
221
+ {query: {as: query_string}.merge(params)}
40
222
  end
41
223
 
42
- def search(query, options = {})
43
- options = prepare_query(query, options)
44
- request 'GET', query.path, options
224
+ def prepare_keyword_query(query, sort = {})
225
+ query_string = query.downcase.tr(" ", "+")
226
+ {query: {kw: query_string}.merge(sort)}
45
227
  end
46
228
  end
47
229
  end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module CollectionSpace
6
+ # CollectionSpace RefName
7
+ #
8
+ # There are four patterns we need to handle:
9
+ #
10
+ # - urn:cspace:domain:type:name(subtype)'label' : Top level authority/vocabulary
11
+ # - urn:cspace:domain:type:name(subtype):item:name(identifier)'label' : Authority/vocabulary term
12
+ # - urn:cspace:domain:type:id(identifier)'label' : Collectionobject
13
+ # - urn:cspace:domain:type:id(identifier) : Procedures, relations, blobs
14
+ class RefName
15
+ attr_reader :domain, :type, :subtype, :identifier, :label
16
+
17
+ def initialize(refname)
18
+ @refname = refname
19
+ @domain = nil
20
+ @type = nil
21
+ @subtype = nil
22
+ @identifier = nil
23
+ @label = nil
24
+ parse
25
+ end
26
+
27
+ def parse
28
+ scanner = StringScanner.new(@refname)
29
+ scanner.skip("urn:cspace:")
30
+ @domain = to_next_colon(scanner)
31
+ @type = to_next_colon(scanner)
32
+
33
+ case next_segment(scanner)
34
+ when "name"
35
+ set_subtype(scanner)
36
+ when "id"
37
+ set_identifier(scanner)
38
+ end
39
+
40
+ self
41
+ end
42
+
43
+ # Convenience class method, so new instance of RefName does not have to be instantiated in order to parse
44
+ #
45
+ # As of v0.13.1, return_class is added and defaults to nil for backward compatibility
46
+ # Eventually this default will be deprecated, and a parsed RefName object will be returned as the default.
47
+ # Any new code written using this method should set the return_class parameter to :refname_obj
48
+ def self.parse(refname, return_class = nil)
49
+ (return_class == :refname_obj) ? new(refname) : new(refname).to_h
50
+ end
51
+
52
+ # Returns a parsed RefName object as a hash.
53
+ # As of v0.13.1, this is equivalent to calling RefName.parse('refnamevalue', :hash)
54
+ # This was added to simplify the process of updating existing code that expects a hash when calling RefName.parse
55
+ def to_h
56
+ {
57
+ domain: domain,
58
+ type: type,
59
+ subtype: subtype,
60
+ identifier: identifier,
61
+ label: label
62
+ }
63
+ end
64
+
65
+ private
66
+
67
+ def next_segment(scanner)
68
+ segment = scanner.check_until(/\(/)
69
+ return nil unless segment
70
+
71
+ segment.delete_suffix("(")
72
+ end
73
+
74
+ def set_identifier(scanner)
75
+ scanner.skip("id(")
76
+ @identifier = to_end_paren(scanner)
77
+ return if scanner.eos?
78
+
79
+ set_label(scanner)
80
+ end
81
+
82
+ def set_label(scanner)
83
+ scanner.skip("'")
84
+ @label = scanner.rest.delete_suffix("'")
85
+ end
86
+
87
+ def set_subtype(scanner)
88
+ scanner.skip("name(")
89
+ @subtype = to_end_paren(scanner)
90
+
91
+ case next_segment(scanner)
92
+ when nil
93
+ set_label(scanner)
94
+ when ":item:name"
95
+ set_term_identifier(scanner)
96
+ end
97
+ end
98
+
99
+ def set_term_identifier(scanner)
100
+ scanner.skip(":item:name(")
101
+ @identifier = to_end_paren(scanner)
102
+ scanner.skip("'")
103
+ set_label(scanner)
104
+ end
105
+
106
+ def to_end_paren(scanner)
107
+ scanner.scan_until(/\)/).delete_suffix(")")
108
+ end
109
+
110
+ def to_next_colon(scanner)
111
+ scanner.scan_until(/:/).delete_suffix(":")
112
+ end
113
+ end
114
+ end