alma 0.2.4 → 0.3.2

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 (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