alma 0.2.4 → 0.3.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +5 -5
  2. data/.circleci/config.yml +54 -0
  3. data/.circleci/setup-rubygems.sh +3 -0
  4. data/.github/dependabot.yml +7 -0
  5. data/.gitignore +3 -0
  6. data/.rubocop.yml +134 -0
  7. data/.ruby-version +1 -1
  8. data/Gemfile +5 -1
  9. data/Guardfile +75 -0
  10. data/README.md +146 -57
  11. data/Rakefile +3 -1
  12. data/alma.gemspec +21 -12
  13. data/lib/alma.rb +34 -54
  14. data/lib/alma/alma_record.rb +3 -3
  15. data/lib/alma/api_defaults.rb +39 -0
  16. data/lib/alma/availability_response.rb +69 -31
  17. data/lib/alma/bib.rb +54 -29
  18. data/lib/alma/bib_holding.rb +25 -0
  19. data/lib/alma/bib_item.rb +164 -0
  20. data/lib/alma/bib_item_set.rb +93 -0
  21. data/lib/alma/bib_set.rb +5 -10
  22. data/lib/alma/config.rb +10 -4
  23. data/lib/alma/course.rb +47 -0
  24. data/lib/alma/course_set.rb +17 -0
  25. data/lib/alma/electronic.rb +167 -0
  26. data/lib/alma/electronic/README.md +20 -0
  27. data/lib/alma/electronic/batch_utils.rb +224 -0
  28. data/lib/alma/electronic/business.rb +29 -0
  29. data/lib/alma/error.rb +16 -4
  30. data/lib/alma/fine.rb +16 -0
  31. data/lib/alma/fine_set.rb +41 -8
  32. data/lib/alma/item_request_options.rb +23 -0
  33. data/lib/alma/library.rb +29 -0
  34. data/lib/alma/library_set.rb +21 -0
  35. data/lib/alma/loan.rb +31 -2
  36. data/lib/alma/loan_set.rb +62 -4
  37. data/lib/alma/location.rb +29 -0
  38. data/lib/alma/location_set.rb +21 -0
  39. data/lib/alma/renewal_response.rb +25 -14
  40. data/lib/alma/request.rb +167 -0
  41. data/lib/alma/request_options.rb +66 -0
  42. data/lib/alma/request_set.rb +69 -5
  43. data/lib/alma/response.rb +45 -0
  44. data/lib/alma/result_set.rb +27 -35
  45. data/lib/alma/user.rb +142 -86
  46. data/lib/alma/user_request.rb +19 -0
  47. data/lib/alma/user_set.rb +5 -6
  48. data/lib/alma/version.rb +3 -1
  49. data/log/.gitignore +4 -0
  50. metadata +149 -10
  51. data/.travis.yml +0 -5
  52. data/lib/alma/api.rb +0 -33
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "alma/bib_item_set"
4
+ module Alma
5
+ class BibItem
6
+ extend Alma::ApiDefaults
7
+ extend Forwardable
8
+
9
+ attr_reader :item
10
+ def_delegators :item, :[], :has_key?, :keys, :to_json
11
+
12
+ PERMITTED_ARGS = [
13
+ :limit, :offset, :expand, :user_id, :current_library,
14
+ :current_location, :q, :order_by, :direction
15
+ ]
16
+
17
+ def self.find(mms_id, options = {})
18
+ holding_id = options.delete(:holding_id) || "ALL"
19
+ options.select! { |k, _| PERMITTED_ARGS.include? k }
20
+ url = "#{bibs_base_path}/#{mms_id}/holdings/#{holding_id}/items"
21
+ response = HTTParty.get(url, headers: headers, query: options, timeout: timeout)
22
+ BibItemSet.new(response, options.merge({ mms_id: mms_id, holding_id: holding_id }))
23
+ end
24
+
25
+ def self.find_by_barcode(barcode)
26
+ response = HTTParty.get(items_base_path, headers: headers, query: { item_barcode: barcode }, timeout: timeout, follow_redirects: true)
27
+ new(response)
28
+ end
29
+
30
+ def self.scan(mms_id:, holding_id:, item_pid:, options: {})
31
+ url = "#{bibs_base_path}/#{mms_id}/holdings/#{holding_id}/items/#{item_pid}"
32
+ response = HTTParty.post(url, headers: headers, query: options)
33
+ new(response)
34
+ end
35
+
36
+ def initialize(item)
37
+ @item = item
38
+ end
39
+
40
+ def holding_data
41
+ @item.fetch("holding_data", {})
42
+ end
43
+
44
+ def item_data
45
+ @item.fetch("item_data", {})
46
+ end
47
+
48
+ def in_temp_location?
49
+ holding_data.fetch("in_temp_location", false)
50
+ end
51
+
52
+ def library
53
+ in_temp_location? ? temp_library : holding_library
54
+ end
55
+
56
+ def library_name
57
+ in_temp_location? ? temp_library_name : holding_library_name
58
+ end
59
+
60
+ def location
61
+ in_temp_location? ? temp_location : holding_location
62
+ end
63
+
64
+ def location_name
65
+ in_temp_location? ? temp_location_name : holding_location_name
66
+ end
67
+
68
+ def holding_library
69
+ item_data.dig("library", "value")
70
+ end
71
+
72
+ def holding_library_name
73
+ item_data.dig("library", "desc")
74
+ end
75
+
76
+ def holding_location
77
+ item_data.dig("location", "value")
78
+ end
79
+
80
+ def holding_location_name
81
+ item_data.dig("location", "desc")
82
+ end
83
+
84
+ def temp_library
85
+ holding_data.dig("temp_library", "value")
86
+ end
87
+
88
+ def temp_library_name
89
+ holding_data.dig("temp_library", "desc")
90
+ end
91
+
92
+ def temp_location
93
+ holding_data.dig("temp_location", "value")
94
+ end
95
+
96
+ def temp_location_name
97
+ holding_data.dig("temp_location", "desc")
98
+ end
99
+
100
+ def temp_call_number
101
+ holding_data.fetch("temp_call_number", "")
102
+ end
103
+
104
+ def has_temp_call_number?
105
+ !temp_call_number.empty?
106
+ end
107
+
108
+ def call_number
109
+ if has_temp_call_number?
110
+ holding_data.fetch("temp_call_number")
111
+ else
112
+ holding_data.fetch("call_number", "")
113
+ end
114
+ end
115
+
116
+ def has_alt_call_number?
117
+ !alt_call_number.empty?
118
+ end
119
+
120
+ def alt_call_number
121
+ item_data.fetch("alternative_call_number", "")
122
+ end
123
+
124
+ def has_process_type?
125
+ !process_type.empty?
126
+ end
127
+
128
+ def process_type
129
+ item_data.dig("process_type", "value") || ""
130
+ end
131
+
132
+ def missing_or_lost?
133
+ !!process_type.match(/MISSING|LOST_LOAN/)
134
+ end
135
+
136
+ def base_status
137
+ item_data.dig("base_status", "value") || ""
138
+ end
139
+
140
+ def in_place?
141
+ base_status == "1"
142
+ end
143
+
144
+ def circulation_policy
145
+ item_data.dig("policy", "desc") || ""
146
+ end
147
+
148
+ def non_circulating?
149
+ circulation_policy.include?("Non-circulating")
150
+ end
151
+
152
+ def description
153
+ item_data.fetch("description", "")
154
+ end
155
+
156
+ def physical_material_type
157
+ item_data.fetch("physical_material_type", "")
158
+ end
159
+
160
+ def public_note
161
+ item_data.fetch("public_note", "")
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alma
4
+ class BibItemSet < ResultSet
5
+ ITEMS_PER_PAGE = 100
6
+
7
+ class ResponseError < ::Alma::StandardError
8
+ end
9
+
10
+ attr_accessor :items
11
+ attr_reader :raw_response, :total_record_count
12
+
13
+ def_delegators :items, :[], :[]=, :empty?, :size, :each
14
+ def_delegators :raw_response, :response, :request
15
+
16
+ def initialize(response, options = {})
17
+ @raw_response = response
18
+ parsed = response.parsed_response
19
+ @total_record_count = parsed["total_record_count"]
20
+ @options = options
21
+ @mms_id = @options.delete(:mms_id)
22
+
23
+ validate(response)
24
+ @items = parsed.fetch(key, []).map { |item| single_record_class.new(item) }
25
+ end
26
+
27
+ def loggable
28
+ { total_record_count: @total_record_count.to_s,
29
+ mms_id: @mms_id,
30
+ uri: @raw_response&.request&.uri.to_s
31
+ }.select { |k, v| !(v.nil? || v.empty?) }
32
+ end
33
+
34
+ def validate(response)
35
+ if response.code != 200
36
+ log = loggable.merge(response.parsed_response)
37
+ raise ResponseError.new("Could not get bib items.", log)
38
+ end
39
+ end
40
+
41
+ def grouped_by_library
42
+ group_by(&:library)
43
+ end
44
+
45
+ def filter_missing_and_lost
46
+ clone = dup
47
+ clone.items = reject(&:missing_or_lost?)
48
+ clone
49
+ end
50
+
51
+ def all
52
+ @last_page_index ||= false
53
+ Enumerator.new do |yielder|
54
+ offset = 0
55
+ while (!@last_page_index || @last_page_index >= offset / items_per_page) do
56
+ r = (offset == 0) ? self : single_record_class.find(@mms_id, options = @options.merge({ limit: items_per_page, offset: offset }))
57
+ unless r.empty?
58
+ r.map { |item| yielder << item }
59
+ @last_page_index = (offset / items_per_page)
60
+ else
61
+ @last_page_index = @last_page_index ? @last_page_index - 1 : -1
62
+ end
63
+
64
+ if r.size == items_per_page
65
+ @last_page_index += 1
66
+ end
67
+
68
+ offset += items_per_page
69
+ end
70
+ end
71
+ end
72
+
73
+ def each(&block)
74
+ @items.each(&block)
75
+ end
76
+
77
+ def success?
78
+ raw_response.response.code.to_s == "200"
79
+ end
80
+
81
+ def key
82
+ "item"
83
+ end
84
+
85
+ def single_record_class
86
+ Alma::BibItem
87
+ end
88
+
89
+ def items_per_page
90
+ ITEMS_PER_PAGE
91
+ end
92
+ end
93
+ end
data/lib/alma/bib_set.rb CHANGED
@@ -1,22 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Alma
2
4
  class BibSet < ResultSet
3
-
4
- def top_level_key
5
- 'bibs'
6
- end
7
-
8
- def response_records_key
9
- 'bib'
5
+ def key
6
+ "bib"
10
7
  end
11
8
 
12
9
  def single_record_class
13
10
  Alma::Bib
14
11
  end
15
12
 
16
- # Doesn't seem to actually return a total record count as documented.
17
13
  def total_record_count
18
- (response_records.is_a? Array) ? size : 1
14
+ size
19
15
  end
20
-
21
16
  end
22
17
  end
data/lib/alma/config.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Alma
2
4
  class << self
3
5
  attr_accessor :configuration
@@ -9,12 +11,16 @@ module Alma
9
11
  end
10
12
 
11
13
  class Configuration
12
- attr_accessor :apikey, :region
14
+ attr_accessor :apikey, :region, :enable_loggable
15
+ attr_accessor :timeout, :http_retries, :logger
13
16
 
14
17
  def initialize
15
18
  @apikey = "TEST_API_KEY"
16
- @region = 'https://api-na.hosted.exlibrisgroup.com'
19
+ @region = "https://api-na.hosted.exlibrisgroup.com"
20
+ @enable_loggable = false
21
+ @timeout = 5
22
+ @http_retries = 3
23
+ @logger = Logger.new(STDOUT)
17
24
  end
18
-
19
25
  end
20
- end
26
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alma
4
+ class Course
5
+ extend Alma::ApiDefaults
6
+ extend Forwardable
7
+
8
+ def self.all_courses(args: {})
9
+ response = HTTParty.get("#{courses_base_path}/courses",
10
+ query: args,
11
+ headers: headers,
12
+ timeout: timeout)
13
+ if response.code == 200
14
+ Alma::CourseSet.new(get_body_from(response))
15
+ else
16
+ raise StandardError, get_body_from(response)
17
+ end
18
+ end
19
+
20
+ attr_accessor :response
21
+
22
+ # The Course object can respond directly to Hash like access of attributes
23
+ def_delegators :response, :[], :[]=, :has_key?, :keys, :to_json
24
+
25
+ def initialize(response_body)
26
+ @response = response_body
27
+ end
28
+
29
+ private
30
+
31
+ def self.get_body_from(response)
32
+ JSON.parse(response.body)
33
+ end
34
+
35
+ def self.courses_base_path
36
+ "https://api-na.hosted.exlibrisgroup.com/almaws/v1"
37
+ end
38
+
39
+ def courses_base_path
40
+ self.class.courses_base_path
41
+ end
42
+
43
+ def headers
44
+ self.class.headers
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alma
4
+ class CourseSet < ResultSet
5
+ def key
6
+ "course"
7
+ end
8
+
9
+ def single_record_class
10
+ Alma::Course
11
+ end
12
+
13
+ def total_record_count
14
+ size
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httparty"
4
+ require "active_support"
5
+ require "active_support/core_ext"
6
+ require "alma/config"
7
+
8
+ module Alma
9
+ # Alma::Electronic APIs wrapper.
10
+ class Electronic
11
+ class ElectronicError < ArgumentError
12
+ end
13
+
14
+ def self.get(params = {})
15
+ retries_count = 0
16
+ response = nil
17
+ while retries_count < http_retries do
18
+ begin
19
+ response = get_api(params)
20
+ break;
21
+
22
+ rescue Net::ReadTimeout
23
+ retries_count += 1
24
+ log.error("Retrying http after timeout with : #{params}")
25
+ no_more_retries_left = retries_count == http_retries
26
+
27
+ raise Net::ReadTimeout.new("Failed due to net timeout after #{http_retries}: #{params}") if no_more_retries_left
28
+ end
29
+ end
30
+
31
+ return response
32
+ end
33
+
34
+ def self.get_totals
35
+ @totals ||= get(limit: "0").data["total_record_count"]
36
+ end
37
+
38
+ def self.log
39
+ Alma.configuration.logger
40
+ end
41
+
42
+ def self.get_ids
43
+ total = get_totals()
44
+ limit = 100
45
+ offset = 0
46
+ log.info("Retrieving #{total} collection ids.")
47
+ groups = Array.new(total / limit + 1, limit)
48
+ @ids ||= groups.map { |limit|
49
+ prev_offset = offset
50
+ offset += limit
51
+ { offset: prev_offset, limit: limit }
52
+ }
53
+ .map { |params| Thread.new { self.get(params) } }
54
+ .map(&:value).map(&:data)
55
+ .map { |data| data["electronic_collection"].map { |coll| coll["id"] } }
56
+ .flatten.uniq
57
+ end
58
+
59
+ def self.http_retries
60
+ Alma.configuration.http_retries
61
+ end
62
+
63
+ private
64
+ class ElectronicAPI
65
+ include ::HTTParty
66
+ include ::Enumerable
67
+ extend ::Forwardable
68
+
69
+ REQUIRED_PARAMS = []
70
+ RESOURCE = "/almaws/v1/electronic"
71
+
72
+ attr_reader :params, :data
73
+ def_delegators :@data, :each, :each_pair, :fetch, :values, :keys, :dig,
74
+ :slice, :except, :to_h, :to_hash, :[], :with_indifferent_access
75
+
76
+ def initialize(params = {})
77
+ @params = params
78
+ headers = self.class::headers
79
+ log.info(url: url, query: params)
80
+ response = self.class::get(url, headers: headers, query: params, timeout: timeout)
81
+ @data = JSON.parse(response.body) rescue {}
82
+ end
83
+
84
+ def url
85
+ "#{Alma.configuration.region}#{resource}"
86
+ end
87
+
88
+ def timeout
89
+ Alma.configuration.timeout
90
+ end
91
+
92
+ def log
93
+ Alma::Electronic.log
94
+ end
95
+
96
+ def resource
97
+ @params.inject(self.class::RESOURCE) { |path, param|
98
+ key = param.first
99
+ value = param.last
100
+
101
+ if key && value
102
+ path.gsub(/:#{key}/, value.to_s)
103
+ else
104
+ path
105
+ end
106
+ }
107
+ end
108
+
109
+ def self.can_process?(params = {})
110
+ type = self.to_s.split("::").last.parameterize
111
+ self::REQUIRED_PARAMS.all? { |param| params.include? param } &&
112
+ params[:type].blank? || params[:type] == type
113
+ end
114
+
115
+ private
116
+ def self.headers
117
+ { "Authorization": "apikey #{apikey}",
118
+ "Accept": "application/json",
119
+ "Content-Type": "application/json" }
120
+ end
121
+
122
+ def self.apikey
123
+ Alma.configuration.apikey
124
+ end
125
+ end
126
+
127
+ class Portfolio < ElectronicAPI
128
+ REQUIRED_PARAMS = [ :collection_id, :service_id, :portfolio_id ]
129
+ RESOURCE = "/almaws/v1/electronic/e-collections/:collection_id/e-services/:service_id/portfolios/:portfolio_id"
130
+ end
131
+
132
+ class Service < ElectronicAPI
133
+ REQUIRED_PARAMS = [ :collection_id, :service_id ]
134
+ RESOURCE = "/almaws/v1/electronic/e-collections/:collection_id/e-services/:service_id"
135
+ end
136
+
137
+ class Services < ElectronicAPI
138
+ REQUIRED_PARAMS = [ :collection_id, :type ]
139
+ RESOURCE = "/almaws/v1/electronic/e-collections/:collection_id/e-services"
140
+ end
141
+
142
+ class Collection < ElectronicAPI
143
+ REQUIRED_PARAMS = [ :collection_id ]
144
+ RESOURCE = "/almaws/v1/electronic/e-collections/:collection_id"
145
+ end
146
+
147
+ # Catch all Electronic API.
148
+ # By default returns all collections
149
+ class Collections < ElectronicAPI
150
+ REQUIRED_PARAMS = []
151
+ RESOURCE = "/almaws/v1/electronic/e-collections"
152
+
153
+ def self.can_process?(params = {})
154
+ true
155
+ end
156
+ end
157
+
158
+ # Order matters because parameters can repeat.
159
+ REGISTERED_APIs = [Portfolio, Service, Services, Collection, Collections]
160
+
161
+ def self.get_api(params)
162
+ REGISTERED_APIs
163
+ .find { |m| m.can_process? params }
164
+ .new(params)
165
+ end
166
+ end
167
+ end