lusi_api 0.1.11

Sign up to get free protection for your applications and to get access to all the features.
@@ -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