lusi_api 0.1.11

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.
@@ -0,0 +1,190 @@
1
+ require 'nokogiri'
2
+ require 'rest-client'
3
+
4
+ require 'lusi_api/core/exceptions'
5
+ require 'lusi_api/service_account'
6
+
7
+
8
+
9
+ module LUSI
10
+ module API
11
+ module Core
12
+
13
+
14
+ # Provides a wrapper for the LUSI web service API
15
+ class API
16
+
17
+ # Initializes a new API instance
18
+ # @param api_user [String] the service account username
19
+ # @param api_password [String] the service account password
20
+ # @param logger [Logger, nil] a Logger instance for optional logging
21
+ # @param timeout [Integer] the timeout in seconds for LUSI API calls
22
+ def initialize(api_user: nil, api_password: nil, api_root: nil, logger: nil, timeout: nil)
23
+ @api_password = api_password
24
+ @api_root = api_root || 'https://lusiservice.lancs.ac.uk'
25
+ @api_user = api_user
26
+ @logger = logger
27
+ @timeout = timeout.to_i
28
+ end
29
+
30
+ # Calls a LUSI API method and return the parsed XML response
31
+ # Any undocumented keyword parameters are passed as parameters to the LUSI API call. The LUSI 'Username' and
32
+ # 'Password' parameters are automatically added.
33
+ # @param path [String] the path of the API call (e.g. 'LUSIReference')
34
+ # @param endpoint [String] the endpoint of the API call (e.g. 'General.asmx')
35
+ # @param method [String] the method name of the API call (e.g. 'GetServiceAccountDetails')
36
+ # @param headers [Hash<String, String>] optional HTTP headers for the API call
37
+ # @param options [Hash<String, Object>] options for the REST client
38
+ # @return [Nokogiri::XML::Node] the parsed XML <body> content from the API response
39
+ # @raise [LUSIAPI::Core::APITimeoutError] if the LUSI API call times out
40
+ # @raise [LUSIAPI::Core::APICallError] if the LUSI API call fails for any other reason (e.g. network unavailable)
41
+ # @raise [LUSIAPI::Core::APIContentError] if the LUSI API call returns an XML document with an error response
42
+ def call(path, endpoint, method, headers: nil, options: nil, **params)
43
+
44
+ # Add the username and password to the params
45
+ params[:Username] ||= @api_user
46
+ params[:Password] ||= @api_password
47
+
48
+ # Set the REST client headers
49
+ rest_headers = {}.merge(headers || {})
50
+
51
+ # Set the REST client options
52
+ rest_options = {
53
+ method: :post,
54
+ headers: rest_headers,
55
+ payload: params,
56
+ url: url(path, endpoint, method),
57
+ }
58
+ rest_options[:timeout] = @timeout if @timeout > 0
59
+ rest_options.merge(options) if options
60
+
61
+ RestClient.log = @log if @log
62
+
63
+ # Make the API call
64
+ begin
65
+ response = RestClient::Request.execute(**rest_options)
66
+ rescue RestClient::Exceptions::Timeout => e
67
+ raise APITimeoutError.new(url: rest_options[:url])
68
+ rescue RestClient::Exception => e
69
+ raise APICallError.new(url: rest_options[:url])
70
+ rescue => e
71
+ raise APIError.new(url: rest_options[:url])
72
+ end
73
+
74
+ # Extract and return the XML content
75
+ xml(response)
76
+
77
+ end
78
+
79
+ # Return a ServiceAccount instance representing the API's service user
80
+ # @return [LUSI::API::ServiceAccount] the ServiceAccount instance representing the API's service user
81
+ def get_service_account_details
82
+ # There should only be one instance but get_instance returns an array for consistnecy with other classes
83
+ result = LUSI::API::ServiceAccount.get_instance(self)
84
+ result.length == 0 ? nil : result[0]
85
+ end
86
+
87
+ # Make a POST API call
88
+ # @see #call
89
+ alias post call
90
+
91
+ # Update the service user's password via the LUSI API
92
+ # @param password [String, nil] the new password (a random 16-character password is generated if this is omitted)
93
+ # @return [String] the new password
94
+ def update_service_account_password(password = nil)
95
+ @api_password = LUSI::API::ServiceAccount.update_service_account_password(self, password)
96
+ end
97
+
98
+ # Construct a full API method URL from its constituents
99
+ # @param path [String] the path of the API call (e.g. 'LUSIReference')
100
+ # @param endpoint [String] the endpoint of the API call (e.g. 'General.asmx')
101
+ # @param method [String] the method name of the API call (e.g. 'GetServiceAccountDetails')
102
+ # @return [String] the fully-qualified URL of the API method
103
+ def url(path = nil, endpoint = nil, method = nil)
104
+ "#{@api_root}/#{path}/#{endpoint}/#{method}"
105
+ end
106
+
107
+ protected
108
+
109
+ # Validate the XML response from an API call
110
+ # @param xml [Nokogiri::XML::Node] the parsed XML response
111
+ # @return [Nokogiri::XML::Node] the parsed <body> element of the XML response
112
+ # @raise [LUSI::API::Core::APIContentError] if the XML response indicates an error
113
+ def validate(xml)
114
+ header = LUSI::API::Core::XML.xml_at(xml, '//xmlns:Header')
115
+ status = LUSI::API::Core::XML.xml_content_at(header, 'xmlns:Status')
116
+ if status == 'Success'
117
+ # No errors, return the body content
118
+ LUSI::API::Core::XML.xml_at(xml, '//xmlns:Body')
119
+ else
120
+ # Errors present, raise APIContentError with the details
121
+ error = {
122
+ error_code: LUSI::API::Core::XML.xml_content_at(header, 'xmlns:ErrorCode'),
123
+ fault: LUSI::API::Core::XML.xml_content_at(header, 'xmlns:Fault'),
124
+ status: status
125
+ }
126
+ error_message = LUSI::API::Core::XML.xml_content_at(header, 'xmlns:ErrorMessage')
127
+ case error[:error_code]
128
+ when 'CLIENT004'
129
+ raise APIPermissionError.new(error_message, **error)
130
+ else
131
+ raise APIContentError.new(error_message, **error)
132
+ end
133
+ end
134
+ end
135
+
136
+ # Parse and validate the XML content from a LUSI API call
137
+ # @param response [String] the string-serialized XML response from the API
138
+ # @return [Nokogiri::XML::Document] the parsed <body> element of the XML response
139
+ # @raise [LUSIAPI::Core::APIContentError] if the XML parsing fails or the response indicates an error
140
+ def xml(response)
141
+
142
+ # Parse the HTTP response as XML
143
+ begin
144
+ xml = Nokogiri::XML(response.to_s)
145
+ rescue Nokogiri::SyntaxError => e
146
+ raise APIContentError('XML syntax error')
147
+ end
148
+
149
+ # Validate the XML and return the <body> element if successful
150
+ validate(xml)
151
+
152
+ end
153
+
154
+ end
155
+
156
+
157
+ # Mixin defining properties for a LUSI API endpoint URL, "extend LUSI::API::Core::Endpoint" to include as class
158
+ # methods
159
+ module Endpoint
160
+
161
+ # Returns the LUSI API endpoint
162
+ # @return [String] the LUSI API endpoint
163
+ def lusi_ws_endpoint
164
+ raise NotImplementedError
165
+ end
166
+
167
+ # Returns the LUSI API method
168
+ # @return [String] the LUSI API method
169
+ def lusi_ws_method
170
+ raise NotImplementedError
171
+ end
172
+
173
+ # Returns the LUSI API URL path
174
+ # @return [String] the LUSI API URL path
175
+ def lusi_ws_path
176
+ raise NotImplementedError
177
+ end
178
+
179
+ # Returns the root element name of the LUSI API XML response
180
+ # @return [String] the root XML element name
181
+ def lusi_ws_xml_root
182
+ raise NotImplementedError
183
+ end
184
+
185
+ end
186
+
187
+
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,101 @@
1
+ require 'lusi_api/core/xml'
2
+
3
+
4
+ module LUSI
5
+ module API
6
+ module Core
7
+
8
+
9
+ # Represents a LUSI code definition (identity code and text description)
10
+ class BasicCode
11
+
12
+ # @!attribute [rw] description
13
+ # @return [any, nil] the text description
14
+ attr_accessor :description
15
+
16
+ # @!attribute [rw] identity
17
+ # @return [any, nil] the identity code
18
+ attr_accessor :identity
19
+
20
+ # Initialises a new Code instance
21
+ # @param xml [Nokogiri::XML::Document, Nokogiri::XML::Node] the XML root of the code from the LUSI API
22
+ # @param lookup [LUSI::API::Core::Lookup::LookupService, nil] the lookup service for object resolution
23
+ # @param identity [any, nil] the default identity code
24
+ # @param description [String, nil] the default text description
25
+ # @return [void]
26
+ def initialize(xml = nil, lookup = nil, identity: nil, description: nil)
27
+ @description = LUSI::API::Core::XML.xml_content_at(xml, 'xmlns:Description', description)
28
+ @identity = LUSI::API::Core::XML.xml_content_at(xml, 'xmlns:Identity', identity)
29
+ end
30
+
31
+ # Returns a string representation of the code
32
+ # @return [String, nil] the string representation (text description)
33
+ def to_s
34
+ @description
35
+ end
36
+
37
+ # Default get_instance implementation
38
+ # - Instances cannot be retrieved through the LUSI API
39
+ def self.get_instance(*args, **kwargs)
40
+ raise NotImplementedError
41
+ end
42
+
43
+ # Default get_instance_params implementation
44
+ # - Instances cannot be retrieved through the LUSI API
45
+ protected
46
+
47
+ def self.get_instance_params(*args, **kwargs)
48
+ {}
49
+ end
50
+
51
+ end
52
+
53
+
54
+ # Represents a LUSI code definition which can be retrieved through the LUSI API
55
+ class Code < BasicCode
56
+
57
+ # Returns a Code instance for the specifiec LUSI API call
58
+ # @param api [LUSI:API::Core::API] the LUSI API instance
59
+ # @param lookup [LUSI::API::Core::Lookup::LookupService, nil] the lookup service for object resolution
60
+ # @param path [String] the LUSI API URL path
61
+ # @param endpoint [String] the LUSI API URL endpoint
62
+ # @param method [String] the LUSI API method
63
+ # @param xml_root [String] the XPath of the root element in the LUSI API response
64
+ # @param identity [any] the identity to search for
65
+ # @param description [String, nil] the description to search for
66
+ # @return [Array<LUSI::API::Core::Code>] the matching Code instances
67
+ # @yield [obj] Passes the Code instance to the block
68
+ # @yieldparam obj [LUSI::API::Core::Code] the Code instance
69
+ def self.get_instance(api = nil, lookup = nil, path = nil, endpoint = nil, method = nil, xml_root = nil, **kwargs)
70
+ params = get_instance_params(**kwargs)
71
+ xml = api.call(path, endpoint, method, **params)
72
+ LUSI::API::Core::XML.xml(xml, xml_root) do |c|
73
+ obj = new(c, lookup)
74
+ begin
75
+ yield(obj) if block_given?
76
+ rescue StandardError => e
77
+ puts e
78
+ end
79
+ obj
80
+ end
81
+ end
82
+
83
+ protected
84
+
85
+ # Returns a hash of parameters for the LUSI API call
86
+ # @param identity [any, nil] the identity code
87
+ # @param description [String. nil] the description
88
+ # @return [Hash<String, any>] the parameter hash for the LUSI API call
89
+ def self.get_instance_params(**kwargs)
90
+ {
91
+ Identity: kwargs[:identity] || '',
92
+ Description: kwargs[:description] || ''
93
+ }
94
+ end
95
+
96
+ end
97
+
98
+
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,47 @@
1
+ module LUSI
2
+ module API
3
+ module Core
4
+
5
+ # The root of the LUSI API exception hierarchy
6
+ class APIError < StandardError
7
+
8
+ def initialize(message = nil, url: nil)
9
+ super(message || 'LUSI API error')
10
+ @url = url
11
+ end
12
+
13
+ end
14
+
15
+
16
+ # Indicates an error with the LUSI API call (e.g. network error)
17
+ class APICallError < APIError; end
18
+
19
+
20
+ # Indicates a LUSI API timeout error
21
+ class APITimeoutError < APICallError; end
22
+
23
+
24
+ # Indicates a successful LUSI API call which returned invalid XML or a document containing an error header
25
+ class APIResponseError < APIError
26
+
27
+ def initialize(message = nil, status: nil, fault: nil, error_code: nil, **kwargs)
28
+ super(message, **kwargs)
29
+ @error_code = error_code
30
+ @fault = fault
31
+ @status = status
32
+ end
33
+
34
+ end
35
+
36
+
37
+ # Indicates an invalid response from the LUSI API
38
+ class APIContentError < APIResponseError; end
39
+
40
+
41
+ # Indicates a LUSI API permission-denied error
42
+ class APIPermissionError < APIResponseError; end
43
+
44
+
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,612 @@
1
+ require 'lusi_api/core/code'
2
+ require 'lusi_api/core/exceptions'
3
+ require 'lusi_api/calendar'
4
+ require 'lusi_api/country'
5
+ require 'lusi_api/organisation'
6
+ require 'lusi_api/person/staff'
7
+
8
+
9
+ module LUSI
10
+ module API
11
+ module Core
12
+ module Lookup
13
+ # The root of the lookup exception hierarchy
14
+ class LookupError < ::LUSI::API::Core::APIError
15
+
16
+ # @!attribute [rw] key
17
+ # @return [any] the key which triggered the lookup error
18
+ attr_accessor :key
19
+
20
+ # Initialises a new LookupError instance
21
+ # @param key [any] the key which triggered the lookup error
22
+ # @return [void]
23
+ def initialize(*args, key: nil)
24
+ super(*args)
25
+ @key = key
26
+ end
27
+
28
+ end
29
+
30
+ # Implements a caching lookup table
31
+ class LookupTable < Hash
32
+
33
+ # Initialises a new LookupTable instance
34
+ # @param (see Hash)
35
+ # @return [void]
36
+ def initialize(*args, &block)
37
+
38
+ # Initialise the superclass
39
+ super(*args)
40
+
41
+ # Define a default proc to look up and cache missing keys
42
+ # Note that if a hash has a default value set, this is lost (reset to nil) when a default_proc is set,
43
+ # so we preserve the default value here to use in the proc.
44
+ default_value = default
45
+ self.default_proc = Proc.new do |hash, key|
46
+ begin
47
+ # If the lookup succeeds, add the value to the hash and return the value.
48
+ self[key] = get_value(key)
49
+ rescue LookupError => e
50
+ # Lookup failed
51
+ if block
52
+ # Call the block passed by the caller
53
+ block.call(hash, key)
54
+ else
55
+ # Return the default value for the hash
56
+ default_value
57
+ end
58
+ end
59
+ end
60
+
61
+ end
62
+
63
+ # Retrieves the value for a key from some data source
64
+ # @param key [any] the key to search for
65
+ # @return [any] the corresponding value
66
+ # @raise [LookupError] if the data source lookup fails
67
+ def get_value(key)
68
+ raise LookupError.new(key)
69
+ end
70
+
71
+ end
72
+
73
+
74
+ # A caching lookup table which retrieves values from the LUSI API
75
+ class LUSILookupTable < LookupTable
76
+
77
+ # @!attribute [rw] api
78
+ # @return [LUSI::API::Core::API, nil] the LUSI API instance to use for lookups
79
+ attr_accessor :api
80
+
81
+ # Initialises a new LUSILookupTable instance
82
+ # @param api [LUSI::API::Core::API] the LUSI API instance to use
83
+ # @param result_class [Class] the class to instantiate from the LUSI API XML response
84
+ # @param key_attribute [Proc, String, Symbol, nil] an extractor to return the key attribute from the instance
85
+ # If a Proc is supplied, it will be called with the instance as the sole parameter.
86
+ # If a String or Symbol are supplied, the named attribute will be returned from the instance
87
+ # If nil or no extractor is supplied, the attribute 'identity' will be returned if it exists.
88
+ # @raise [NameError] if the specified attribute cannot be found
89
+ # @param loadable [Boolean, nil] if true, the lookup table is populated from a single API call; otherwise
90
+ # the lookup table is populated by individual API calls each time a key lookup fails
91
+ # @param param [String, nil] the LUSI API parameter to use for the key lookup
92
+ # @param load_params [Hash<String, any>, nil] default LUSI API parameters passed to the load call
93
+ # Other positional and keyword parameters are passed to the result class' #get_instance method
94
+ # @return [void]
95
+ def initialize(api = nil, result_class = nil, *args, key_attribute: nil, loadable: nil, param: nil,
96
+ load_params: nil, **kwargs, &block)
97
+ super(block)
98
+
99
+ key_attribute = :identity if key_attribute.nil?
100
+ if key_attribute.is_a? Proc
101
+ @key_attribute = key_attribute
102
+ else
103
+ @key_attribute = Proc.new { |obj| obj.nil? ? nil : obj.instance_variable_get("@#{key_attribute}") }
104
+ end
105
+
106
+ @api = api
107
+ @args = args || []
108
+ @kwargs = kwargs || {}
109
+ @load_params = load_params
110
+ @loadable = loadable ? true : false
111
+ @param = param
112
+ @result_class = result_class
113
+ end
114
+
115
+ # Retrieves the object corresponding to the key from the LUSI API and instantiates a result from the XML
116
+ # @param key [any] the key to search
117
+ # @return [any] an instance of the result_class for the matching object
118
+ def get_value(key)
119
+
120
+ # A loadable lookup table is populated with a single API call, so do regular key lookup
121
+ raise LookupError.new('lookup table is loadable') if @loadable
122
+
123
+ # For non-loadable lookup tables, call the LUSI API to get the key value and create a corresponding instance
124
+ xml = @api.call(@path, @endpoint, @method, { @param => key })
125
+ result_class.new(xml, xml_root)
126
+
127
+ end
128
+
129
+ # Retrieves all objects from the LUSI API and adds them to the lookup table
130
+ # @param clear [Boolean, nil] if true, clear the lookup table before loading
131
+ # @param lookup [LUSI::API::Core::Lookup::LookupService] the lookup service for object resolution
132
+ # @return [Boolean] true if the lookup table was loaded, false if it is not loadable
133
+ def load(clear = false, lookup = nil, **params)
134
+
135
+ # Bail out if this lookup table isn't loadable
136
+ return false unless @loadable
137
+
138
+ # Set up the LUSI API call parameters
139
+ params ||= @load_params || {}
140
+
141
+ # Clear the lookup table before loading if required
142
+ self.clear if clear
143
+
144
+ # Call the LUSI API
145
+ # - do not use the lookup service to resolve result_class instances
146
+ #xml = @api.call(@path, @endpoint, @method, **params)
147
+ #xml.xpath(@xml_root) do |x|
148
+ params.merge(@kwargs)
149
+ @result_class.get_instance(@api, lookup, *@args, use_lookup: false, **params) do |obj|
150
+ # Get the lookup key from the instance and add the instance to the hash
151
+ begin
152
+ key = @key_attribute.call(obj)
153
+ self[key] = obj
154
+ rescue NameError => e
155
+ # ?
156
+ end
157
+ end
158
+
159
+ # Return true to indicate successful loading
160
+ true
161
+
162
+ end
163
+
164
+ end
165
+
166
+ class LUSILookupTable < LookupTable
167
+
168
+ # @!attribute [rw] api
169
+ # @return [LUSI::API::Core::API, nil] the LUSI API instance to use for lookups
170
+ attr_accessor :api
171
+
172
+ # Initialises a new LUSILookupTable instance
173
+ # @param api [LUSI::API::Core::API] the LUSI API instance to use
174
+ # @param result_class [Class] the class to instantiate from the LUSI API XML response
175
+ # @param key_attribute [Proc, String, Symbol, nil] an extractor to return the key attribute from the instance
176
+ # If a Proc is supplied, it will be called with the instance as the sole parameter.
177
+ # If a String or Symbol are supplied, the named attribute will be returned from the instance
178
+ # If nil or no extractor is supplied, the attribute 'identity' will be returned if it exists.
179
+ # @raise [NameError] if the specified attribute cannot be found
180
+ # @param loadable [Boolean, nil] if true, the lookup table is populated from a single API call; otherwise
181
+ # the lookup table is populated by individual API calls each time a key lookup fails
182
+ # @param param [String, nil] the LUSI API parameter to use for the key lookup
183
+ # @param load_params [Hash<String, any>, nil] default LUSI API parameters passed to the load call
184
+ # Other positional and keyword parameters are passed to the result class' #get_instance method
185
+ # @return [void]
186
+ def initialize(api = nil, result_class = nil, *args, key_attribute: nil, loadable: nil, param: nil,
187
+ load_params: nil, **kwargs, &block)
188
+ super(block)
189
+
190
+ key_attribute = :identity if key_attribute.nil?
191
+ if key_attribute.is_a? Proc
192
+ @key_attribute = key_attribute
193
+ else
194
+ @key_attribute = Proc.new { |obj| obj.nil? ? nil : obj.instance_variable_get("@#{key_attribute}") }
195
+ end
196
+
197
+ @api = api
198
+ @args = args || []
199
+ @kwargs = kwargs || {}
200
+ @load_params = load_params
201
+ @loadable = loadable ? true : false
202
+ @param = param
203
+ @result_class = result_class
204
+ end
205
+
206
+ # Retrieves the object corresponding to the key from the LUSI API and instantiates a result from the XML
207
+ # @param key [any] the key to search
208
+ # @return [any] an instance of the result_class for the matching object
209
+ def get_value(key)
210
+
211
+ # A loadable lookup table is populated with a single API call, so do regular key lookup
212
+ raise LookupError.new('lookup table is loadable') if @loadable
213
+
214
+ # For non-loadable lookup tables, call the LUSI API to get the key value and create a corresponding instance
215
+ xml = @api.call(@path, @endpoint, @method, { @param => key })
216
+ result_class.new(xml, xml_root)
217
+
218
+ end
219
+
220
+ # Retrieves all objects from the LUSI API and adds them to the lookup table
221
+ # @param clear [Boolean, nil] if true, clear the lookup table before loading
222
+ # @param lookup [LUSI::API::Core::Lookup::LookupService] the lookup service for object resolution
223
+ # @return [Boolean] true if the lookup table was loaded, false if it is not loadable
224
+ def load(clear = false, lookup = nil, **params)
225
+
226
+ # Bail out if this lookup table isn't loadable
227
+ return false unless @loadable
228
+
229
+ # Set up the LUSI API call parameters
230
+ params ||= @load_params || {}
231
+
232
+ # Clear the lookup table before loading if required
233
+ self.clear if clear
234
+
235
+ # Call the LUSI API
236
+ # - do not use the lookup service to resolve result_class instances
237
+ #xml = @api.call(@path, @endpoint, @method, **params)
238
+ #xml.xpath(@xml_root) do |x|
239
+ params.merge(@kwargs)
240
+ @result_class.get_instance(@api, lookup, *@args, use_lookup: false, **params) do |obj|
241
+ # Get the lookup key from the instance and add the instance to the hash
242
+ begin
243
+ key = @key_attribute.call(obj)
244
+ self[key] = obj
245
+ rescue NameError => e
246
+ # ?
247
+ end
248
+ end
249
+
250
+ # Return true to indicate successful loading
251
+ true
252
+
253
+ end
254
+
255
+ end
256
+
257
+
258
+ # Collects a number of lookup tables into a single service
259
+ class LookupService
260
+
261
+ # Configuration for lookup services
262
+ # The structure is:
263
+ # {
264
+ # service: {
265
+ # available: include this service in the list of available services if true
266
+ # class: the LUSI API class returned by the lookup table (default: LUSI::API::Core::Code)
267
+ # create: create method name
268
+ # creates: the list of services created by this service
269
+ # depends: the list of services this service depends on
270
+ # load: load method name
271
+ # params: the LUSI API class #get_instance parameters
272
+ # }
273
+ # }
274
+ #
275
+ # The available option, if specifies, dictates whether the service is included in the list of available
276
+ # services. The default is true.
277
+ #
278
+ # The class option, if specified, is the Class instance of the object class stored in the lookup table.
279
+ # If no class is specified, the default is LUSI::API::Core::Code
280
+ #
281
+ # The create option, if specified, is a symbol (method name on the LookupService instance) or callable.
282
+ # The create method should accept an arbitrary parameter list (method(*params)) and return a configured
283
+ # lookup table instance or functional equivalent.
284
+ # If no create method is specified, the default is to create a LookupTable instance as follows:
285
+ # LUSILookupTable.new(@api, *params, loadable: true)
286
+ # where params is the list from the params option (see below)
287
+ #
288
+ # The creates option, if specified, is a list of services created by this service. This allows a single
289
+ # service definition to create multiple related lookup services which can be iterated in the list of
290
+ # available services.
291
+ #
292
+ # The depends option, if specified, specifies a list of lookup services on which the current lookup service
293
+ # depends. These services are automatically loaded before the current lookup service.
294
+ #
295
+ # The load option, if specified, is a symbol (method name on the LookupService instance) or callable.
296
+ # The load method should accepts two parameters:
297
+ # If no load method is specified, the default is to call the lookup table's load method.
298
+ #
299
+ # The params option specifies the parameters to the create method or LookupTable constructor as a list:
300
+ # [ lusi-api-url-path, lusi-api-endpoint, lusi-api-method, xml-root, result-class ]
301
+ #
302
+ #
303
+ SERVICES = {
304
+ absence_reason: {
305
+ params: ['LUSIReference', 'Lookup.asmx', 'GetAbsenceReasons', 'xmlns:AbsenceReason']
306
+ },
307
+ address_country: {
308
+ class: LUSI::API::Country::AddressCountry,
309
+ },
310
+ event_contact_type: {
311
+ params: ['LUSIReference', 'Lookup.asmx', 'GetEventContactTypes', 'xmlns:EventContactType']
312
+ },
313
+ leave_reason: {
314
+ params: ['LUSIReference', 'Lookup.asmx', 'GetLeaveReasons', 'xmlns:LeaveReason']
315
+ },
316
+ location_of_tuition: {
317
+ params: ['LUSIReference', 'Lookup.asmx', 'GetLocationsOfTuition', 'xmlns:LocationOfTuition']
318
+ },
319
+ nationality: {
320
+ class: LUSI::API::Country::Nationality
321
+ },
322
+ organisation: {
323
+ available: false,
324
+ create: :create_organisation,
325
+ creates: [:department, :faculty, :institution]
326
+ },
327
+ staff_course_role: {
328
+ class: LUSI::API::Person::StaffCourseRole
329
+ },
330
+ staff_relationship: {
331
+ params: ['LUSIReference', 'Lookup.asmx', 'GetStaffRelationships', 'xmlns:StaffRelationship']
332
+ },
333
+ student_category: {
334
+ params: ['LUSIReference', 'Lookup.asmx', 'GetStudentCategories', 'xmlns:StudentCategory']
335
+ },
336
+ subject_area: {
337
+ params: ['LUSIReference', 'Lookup.asmx', 'GetSubjectAreas', 'xmlns:SubjectArea']
338
+ },
339
+ week: {
340
+ class: LUSI::API::Calendar::Week,
341
+ depends: [:year],
342
+ load: :load_weeks
343
+ },
344
+ year: {
345
+ class: LUSI::API::Calendar::Year
346
+ }
347
+ }
348
+
349
+ # The list of available services - built lazily
350
+ @@services = nil
351
+
352
+ # Initialises a new LookupService instance
353
+ # @param api [LUSI::API::Core::API] the LUSI API instance
354
+ # @param (see #load)
355
+ # @return [void]
356
+ def initialize(api = nil, **services)
357
+ @api = api
358
+ clear
359
+ load(**services) unless services.nil? || services.empty?
360
+ end
361
+
362
+ # Clears all lookup tables from the LookupService instance
363
+ def clear
364
+ @lookups = {}
365
+ @organisation = nil
366
+ end
367
+
368
+ # Iterates over each lookup table
369
+ # @yield [service, lookup_table] passes the service name and corresponding lookup table to the block
370
+ # @yieldparam service [Symbol] the service name
371
+ # @yieldparam lookup [LUSI::API::Core::Lookup::LookupTable] the lookup table for the service
372
+ # @yieldreturn [void]
373
+ def each
374
+ @lookups.each { |service, lookup| yield(service, lookup) }
375
+ end
376
+
377
+ # Returns true if all specified services are defined
378
+ # Parameters are the service names to be checked
379
+ # @return [Boolean] true if all services are definied, false if any service is undefined
380
+ def has?(*services)
381
+ services.each { |service| return false unless @lookups.include?(service) }
382
+ true
383
+ end
384
+
385
+ # Returns the number of configured lookup tables
386
+ # @param service [Symbol, nil] the service to check
387
+ # if present, return the number of entries in the specified lookup table, otherwise return the number of
388
+ # lookup tables
389
+ # @return [Integer] the number of configured lookup tables (if service is unspecified) or the number of
390
+ # entries in the specified lookup table. If an invalid service is specified, 0 is returned.
391
+ def length(service = nil)
392
+ if service
393
+ lookup = @lookups[service]
394
+ lookup ? lookup.length : 0
395
+ else
396
+ @lookups.length
397
+ end
398
+ end
399
+
400
+ # Loads specified lookup services, or all services if none are specified.
401
+ # Each named parameter specifies a configuration for the lookup service: service: config
402
+ # False parameter values cause that service to be ignored.
403
+ # @return [void]
404
+ def load(**services)
405
+
406
+ # If no services are specified, load all services with default configuration
407
+ if services.nil? || services.empty?
408
+ services = {}
409
+ SERVICES.each_key { |key| services[key] = true }
410
+ end
411
+
412
+ # Create the services
413
+ services.each do |service, config|
414
+ # Ignore invalid and unconfigured services
415
+ create_lookup_service(service, config, services) if config && SERVICES.has_key?(service)
416
+ end
417
+
418
+ end
419
+
420
+ # Fetches a key from the specified lookup table
421
+ # @param service [Symbol] the lookup table to use
422
+ # @param key [any] the key to search for
423
+ # @param default [any] the default value if the lookup fails
424
+ # @return [Object] the value corresponding to the key
425
+ def lookup(service, key, default = nil)
426
+ lookup_method = get_callable(service, :lookup)
427
+ if lookup_method
428
+ lookup_method.call(service, key, default)
429
+ else
430
+ lookup = @lookups[service]
431
+ if lookup.is_a?(Hash)
432
+ lookup[key]
433
+ elsif lookup.is_a?(Method) || lookup.is_a?(Proc)
434
+ lookup.call(service, key, default)
435
+ else
436
+ default
437
+ end
438
+ end
439
+ end
440
+
441
+ # Returns the specified lookup table
442
+ # @param service [Symbol] the lookup table to return
443
+ # @return [LUSI::API::Core::Lookup::LookupTable] the lookup table
444
+ def service(service)
445
+ @lookups[service]
446
+ end
447
+
448
+ # Returns a list of configured services
449
+ # @param all [Boolean] if true, returns all available services; if false, returns only configured services
450
+ # @yield [service] Passes the service name to the block
451
+ # @yieldparam service [Symbol] the service name
452
+ def services(all = false)
453
+ if all
454
+ # Return all services defined in SERVICES
455
+ return @@services if @@services
456
+ @@services = []
457
+ SERVICES.each do |service, config|
458
+ if config.fetch(:available, true)
459
+ @@services.push(service)
460
+ yield(service) if block_given?
461
+ end
462
+ config.fetch(:creates, []).each do |created|
463
+ @@services.push(created)
464
+ yield(created) if block_given?
465
+ end
466
+ end
467
+ @@services
468
+ else
469
+ # Return all services configured in @lookups
470
+ result = @lookups.keys
471
+ result.each { |service| yield(service) } if block_given?
472
+ result
473
+ end
474
+ end
475
+
476
+ protected
477
+
478
+ # Creates a lookup service
479
+ # - the create method is responsible for loading the table
480
+ # @param service [Symbol] the lookup service being created
481
+ # @param config [any] the lookup service configuration
482
+ # @param services [Hash<Symbol, any>] the configuration hash for all lookup services
483
+ # @return [void]
484
+ def create_lookup_service(service, config, services)
485
+ create_method = get_callable(service, :create)
486
+ params = SERVICES[service][:params] || []
487
+ if create_method
488
+ lookup = create_method.call(service, config, services, *params)
489
+ else
490
+ lookup = create_lookup(service, config, services, *params)
491
+ end
492
+ end
493
+
494
+ # Creates a lookup table
495
+ # @param service [Symbol] the lookup service being created
496
+ # @param config [any] the lookup service configuration
497
+ # @param services [Hash<Symbol, any>] the configuration hash for all lookup services
498
+ # params is the list of parameters from the service definition in SERVICES
499
+ # @return [void]
500
+ def create_lookup(service, config, services, *params)
501
+ # Create the lookup table
502
+ result_class = SERVICES[service].fetch(:class, LUSI::API::Core::Code)
503
+ lookup = LUSILookupTable.new(@api, result_class, *params, loadable: true)
504
+ # Load the lookup table
505
+ begin
506
+ # Load dependencies
507
+ load_dependencies(service, services) if SERVICES[service][:depends]
508
+ # Load the table
509
+ load_lookup(service, config, lookup)
510
+ # Add the lookup table to the service if successful
511
+ @lookups[service] = lookup
512
+ rescue APIPermissionError => e
513
+ puts("Permission denied for #{service}")
514
+ end
515
+ end
516
+
517
+ # Creates organisation lookups
518
+ #
519
+ # @param service [Symbol] the lookup service being created
520
+ # @param config [any] the lookup service configuration
521
+ # @param services [Hash<Symbol, any>] the configuration hash for all lookup services
522
+ # params is the list of parameters from the service definition in SERVICES
523
+ # @return [void]
524
+ def create_organisation(service, config, services, *params)
525
+ # Create and load the organisation structure
526
+ @organisation = LUSI::API::Organisation::Organisation.new
527
+ @organisation.load(@api)
528
+ # Create lookups for each level of the organisation
529
+ @organisation.each_unit_type do |unit_type|
530
+ @lookups[unit_type] = Proc.new do |service, key, default|
531
+ @organisation.get(key, unit_type)
532
+ end
533
+ end
534
+ end
535
+
536
+ # Gets a method or proc of the LookupService instance
537
+ # @param service [Symbol] the lookup service
538
+ # @param type [Symbol] the type of method (:create | :load | :lookup)
539
+ # @return [Method, Proc] the corresponding method or proc, or nil if no method is found or an invalid
540
+ # parameter is passed
541
+ def get_callable(service, type)
542
+ callable = SERVICES[service] ? SERVICES[service][type] : nil
543
+ case
544
+ when callable.nil?
545
+ nil
546
+ when callable.is_a?(Symbol) || callable.is_a?(String)
547
+ # callable refers to a method on this LookupService instance
548
+ method(callable.to_sym)
549
+ when callable.is_a?(Method) || callable.is_a?(Proc)
550
+ # callable is already a callable object
551
+ callable
552
+ else
553
+ nil
554
+ end
555
+ end
556
+
557
+ # Loads service dependencies
558
+ # @param service [Symbol] the lookup service being configured
559
+ # @param services [Hash<Symbol, any>] the configuration hash for all lookup services
560
+ # @return [void]
561
+ def load_dependencies(service, services)
562
+ depends = SERVICES[service][:depends]
563
+ depends.each do |depend|
564
+ # Skip if the dependency already exists
565
+ continue if @lookups[depend]
566
+ # Get the config for the dependency, or use the default configuration if unspecified
567
+ config = services[depend] || true
568
+ # Create the lookup service if it doesn't already exist
569
+ create_lookup_service(depend, config, services)
570
+ end
571
+ end
572
+
573
+ # Loads a lookup table
574
+ # @param service [Symbol] the lookup service being configured
575
+ # @param config [any] the lookup service configuration
576
+ # @param lookup [LUSILookupTable] the lookup table to be loaded
577
+ # @return [LUSILookupTable] the lookup table
578
+ def load_lookup(service, config, lookup)
579
+ load_method = get_callable(service, :load)
580
+ if load_method
581
+ # Call the specified load method
582
+ load_method.call(service, config, lookup)
583
+ else
584
+ # Call the lookup table's load method
585
+ # - pass this LookupService as the lookup service for object resolution
586
+ lookup.load(false, self)
587
+ end
588
+ lookup
589
+ end
590
+
591
+ # Loads the academic weeks lookup table
592
+ # @param service [Symbol] the lookup service
593
+ # @param config [Array<String>] the list of year identity codes to load weeks for (default is all available weeks)
594
+ # @param lookup [LUSILookupTable] the lookup table being loaded
595
+ # @return [LUSILookupTable] the lookup table
596
+ def load_weeks(service, config, lookup)
597
+ if config.is_a?(Array) && !config.empty?
598
+ # Load weeks for each specified year (note: clear = false to accumulate results)
599
+ config.each { |year_identity| lookup.load(false, self, year_identity: year_identity) }
600
+ elsif config
601
+ # Load all weeks
602
+ lookup.load(false, self)
603
+ end
604
+ lookup
605
+ end
606
+
607
+ end
608
+
609
+ end
610
+ end
611
+ end
612
+ end