collectionspace-client 0.3.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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