lusi_api 0.1.11
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +58 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +235 -0
- data/Rakefile +8 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/lusi_api.rb +5 -0
- data/lib/lusi_api/calendar.rb +234 -0
- data/lib/lusi_api/core/api.rb +190 -0
- data/lib/lusi_api/core/code.rb +101 -0
- data/lib/lusi_api/core/exceptions.rb +47 -0
- data/lib/lusi_api/core/lookup.rb +612 -0
- data/lib/lusi_api/core/util.rb +102 -0
- data/lib/lusi_api/core/xml.rb +168 -0
- data/lib/lusi_api/country.rb +111 -0
- data/lib/lusi_api/course.rb +1300 -0
- data/lib/lusi_api/enrolment.rb +247 -0
- data/lib/lusi_api/organisation.rb +291 -0
- data/lib/lusi_api/person/staff.rb +115 -0
- data/lib/lusi_api/person/student.rb +551 -0
- data/lib/lusi_api/service_account.rb +121 -0
- data/lib/lusi_api/service_method.rb +52 -0
- data/lib/lusi_api/version.rb +5 -0
- data/lib/lusi_api/vle.rb +329 -0
- data/lusi_api.gemspec +36 -0
- metadata +182 -0
@@ -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
|