mais_person_client 0.0.1

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.
data/README.md ADDED
@@ -0,0 +1,82 @@
1
+ [![Gem Version](https://badge.fury.io/rb/mais_person_client.svg)](https://badge.fury.io/rb/mais_person_client)
2
+ [![CircleCI](https://circleci.com/gh/sul-dlss/mais_person_client.svg?style=svg)](https://circleci.com/gh/sul-dlss/mais_person_client)
3
+ [![codecov](https://codecov.io/github/sul-dlss/mais_person_client/graph/badge.svg?token=A6B03FJ981)](https://codecov.io/github/sul-dlss/mais_person_client)
4
+
5
+ # mais_person_client
6
+ API client for accessing MAIS's Person endpoints.
7
+
8
+ MAIS's Person API provides access to information for Stanford users.
9
+
10
+ ## API Documentation
11
+
12
+ API docs: https://uit.stanford.edu/developers/apis/person
13
+ API Schema: https://uit.stanford.edu/service/registry/person-data
14
+
15
+ ## Installation
16
+
17
+ Install the gem and add to the application's Gemfile by executing:
18
+
19
+ $ bundle add mais_peron_client
20
+
21
+ If bundler is not being used to manage dependencies, install the gem by executing:
22
+
23
+ $ gem install mais_person_client
24
+
25
+ ## Usage
26
+
27
+ For one-off requests:
28
+
29
+ ```ruby
30
+ require "mais_person_client"
31
+
32
+ # NOTE: The settings below live in the consumer, not in the gem.
33
+ # The user_agent string can be changed by consumers as requested by MaIS for tracking
34
+ client = MaisPersonClient.configure(
35
+ api_key: Settings.mais_person.api_key,
36
+ api_cert: Settings.mais_person.api_cert,
37
+ base_url: Settings.mais_person.base_url,
38
+ user_agent: 'some-user-agent-string-to-send-in-requests' # defaults to 'stanford-library'
39
+ )
40
+ client.fetch_user('nataliex') # get a single user by sunet
41
+ client.fetch_users # return all users
42
+ ```
43
+
44
+ You can also invoke methods directly on the client class, which is useful in a Rails application environment where you might initialize the client in an
45
+ initializer and then invoke client methods in many other contexts where you want to be sure configuration has already occurred, e.g.:
46
+
47
+ ```ruby
48
+ # config/initializers/mais_person_client.rb
49
+ MaisPersonClient.configure(
50
+ api_key: Settings.mais_person.api_key,
51
+ api_cert: Settings.mais_person.api_cert,
52
+ base_url: Settings.mais_person.base_url,
53
+ )
54
+
55
+ # app/services/my_mais_person_service.rb
56
+ # ...
57
+ def get_user(sunet)
58
+ MaisPersonClient.fetch_user(sunet)
59
+ end
60
+ # ...
61
+ ```
62
+
63
+ ## Development
64
+
65
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
66
+
67
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
68
+
69
+ ## VCR Cassettes
70
+
71
+ VCR gem is used to record the results of the API calls for the tests. If you need to record or re-create existing cassettes, you may need to adjust expectations in the tests as the results coming back from the API may be different than when the cassettes were recorded.
72
+
73
+ To record new cassettes:
74
+ 1. Join VPN.
75
+ 2. Temporarily adjust the configuration (api_key, api_cert for the MaIS UAT URL) at the top of `spec/mais_person_client_spec.rb` so it matches the real MaIS UAT environment.
76
+ 3. Add your new spec with a new cassette name (or delete a previous cassette to re-create it).
77
+ 4. Run just that new spec (important: else previous specs may use cassettes that have redacted credentials, causing your new spec to fail).
78
+ 5. You should get a new cassette with the name you specified in the spec.
79
+ 6. Look at the cassette. If it has real person data, you will want to redact most of it since there will be private information in there. Make the expectation match the redaction.
80
+ 7. Set your configuration at the top of the spec back to the fake api_key and api_cert values.
81
+ 8. The spec that checks for a raised exception when fetching all users may need to be handcrafted in the cassette to look it raised a 500. It's hard to get the actual URL to produce a 500 on this call.
82
+ 9. Re-run all the specs - they should pass now without making real calls.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+ require 'rubocop/rake_task'
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[rubocop spec]
@@ -0,0 +1,376 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MaisPersonClient
4
+ # Model for a Person from the MAIS Person API
5
+ class Person
6
+ HOME_ADDRESS_TYPES = %w[home permanent].freeze
7
+
8
+ # Struct definitions for complex nodes
9
+ PersonName = Struct.new(:type, :visibility, :full_name, :first_name, :first_nval, :middle, :middle_nval,
10
+ :last, :last_nval)
11
+ Address = Struct.new(:type, :visibility, :full_address, :line, :city, :state, :state_code, :postal_code, :country,
12
+ :country_alpha2, :country_alpha3, :country_numeric, :affnum)
13
+ Telephone = Struct.new(:type, :visibility, :full_number, :icc, :area, :number, :affnum)
14
+ Email = Struct.new(:type, :visibility, :full_email, :user, :host)
15
+ Url = Struct.new(:type, :visibility, :url)
16
+ Location = Struct.new(:code, :type, :visibility, :location)
17
+ Identifier = Struct.new(:type, :visibility, :nval, :value)
18
+ Department = Struct.new(:affnum, :name, :organization, :adminid, :level2orgid, :level2orgname, :regid)
19
+ Affiliation = Struct.new(:affnum, :effective, :organization, :type, :visibility, :name, :department, :description,
20
+ :affdata, :place)
21
+ AffData = Struct.new(:affnum, :type, :code, :value)
22
+ Place = Struct.new(:type, :affnum, :address, :qbfr, :telephone)
23
+ EmergencyContact = Struct.new(:number, :primary, :sync_permanent, :visibility, :contact_name,
24
+ :contact_relationship, :contact_relationship_code, :contact_telephones,
25
+ :contact_address)
26
+
27
+ attr_reader :xml
28
+
29
+ def initialize(xml)
30
+ @xml = Nokogiri::XML(xml)
31
+ @xml.remove_namespaces!
32
+ end
33
+
34
+ # Root attributes
35
+ def card
36
+ xml.root['card']
37
+ end
38
+
39
+ def listing
40
+ xml.root['listing']
41
+ end
42
+
43
+ def name_attr
44
+ xml.root['name']
45
+ end
46
+
47
+ def regid
48
+ xml.root['regid']
49
+ end
50
+
51
+ def relationship
52
+ xml.root['relationship']
53
+ end
54
+
55
+ def source
56
+ xml.root['source']
57
+ end
58
+
59
+ def sunetid
60
+ xml.root['sunetid']
61
+ end
62
+
63
+ def univid
64
+ xml.root['univid']
65
+ end
66
+
67
+ # Names (multiple possible)
68
+ def names
69
+ xml.xpath('//name').map { |name_node| build_person_name(name_node) }
70
+ end
71
+
72
+ def registered_name
73
+ names.find { |name| name.type == 'registered' }
74
+ end
75
+
76
+ def display_name
77
+ names.find { |name| name.type == 'display' }
78
+ end
79
+
80
+ # Titles (multiple possible)
81
+ def titles
82
+ xml.xpath('//title').map do |title_node|
83
+ {
84
+ type: title_node['type'],
85
+ visibility: title_node['visibility'],
86
+ title: title_node.text
87
+ }
88
+ end
89
+ end
90
+
91
+ def job_title
92
+ titles.find { |title| title[:type] == 'job' }&.[](:title)
93
+ end
94
+
95
+ # Biodemo
96
+ def gender
97
+ xml.at_xpath('//biodemo/gender')&.text
98
+ end
99
+
100
+ def biodemo_visibility
101
+ xml.at_xpath('//biodemo')&.[]('visibility')
102
+ end
103
+
104
+ # Addresses (multiple possible)
105
+ def addresses
106
+ xml.xpath('//address').map { |addr_node| build_address(addr_node) }
107
+ end
108
+
109
+ # Telephones (multiple possible)
110
+ def telephones
111
+ xml.xpath('//telephone').map { |tel_node| build_telephone(tel_node) }
112
+ end
113
+
114
+ # Emails (multiple possible)
115
+ def emails
116
+ xml.xpath('//email').map do |email_node|
117
+ Email.new(
118
+ email_node['type'],
119
+ email_node['visibility'],
120
+ email_node.children.first&.text&.strip,
121
+ email_node.at_xpath('user')&.text,
122
+ email_node.at_xpath('host')&.text
123
+ )
124
+ end
125
+ end
126
+
127
+ def primary_email
128
+ emails.find { |email| email.type == 'primary' }&.full_email
129
+ end
130
+
131
+ # URLs (multiple possible)
132
+ def urls
133
+ xml.xpath('//url').map do |url_node|
134
+ Url.new(
135
+ url_node['type'],
136
+ url_node['visibility'],
137
+ url_node.text
138
+ )
139
+ end
140
+ end
141
+
142
+ def homepage
143
+ urls.find { |url| url.type == 'homepage' }&.url
144
+ end
145
+
146
+ # Locations (multiple possible)
147
+ def locations
148
+ xml.xpath('//location').map do |loc_node|
149
+ Location.new(
150
+ loc_node['code'],
151
+ loc_node['type'],
152
+ loc_node['visibility'],
153
+ loc_node.text
154
+ )
155
+ end
156
+ end
157
+
158
+ # Places (multiple possible - home, work, etc.)
159
+ def places
160
+ xml.xpath('//place').map { |place_node| build_place(place_node) }
161
+ end
162
+
163
+ # Affiliations (multiple possible)
164
+ def affiliations
165
+ xml.xpath('//affiliation').map { |aff_node| build_affiliation(aff_node) }
166
+ end
167
+
168
+ # Identifiers (multiple)
169
+ def identifiers
170
+ xml.xpath('//identifier').map do |id_node|
171
+ Identifier.new(
172
+ id_node['type'],
173
+ id_node['visibility'],
174
+ id_node['nval'],
175
+ id_node.text
176
+ )
177
+ end
178
+ end
179
+
180
+ def identifier_by_type(type)
181
+ identifiers.find { |id| id.type == type }&.value
182
+ end
183
+
184
+ # Privacy groups
185
+ def privgroups
186
+ xml.xpath('//privgroup').map(&:text)
187
+ end
188
+
189
+ # eduPerson attributes
190
+ def eduperson_primary_affiliation
191
+ xml.at_xpath('//edupersonprimaryaffiliation')&.text
192
+ end
193
+
194
+ def eduperson_affiliations
195
+ xml.xpath('//edupersonaffiliation').map(&:text)
196
+ end
197
+
198
+ # Emergency contacts
199
+ def emergency_contacts
200
+ xml.xpath('//emergency_contact').map { |contact_node| build_emergency_contact(contact_node) }
201
+ end
202
+
203
+ # Convenience methods for common lookups
204
+ def work_address
205
+ addresses.find { |addr| addr.type == 'work' }
206
+ end
207
+
208
+ def home_address
209
+ addresses.find { |addr| HOME_ADDRESS_TYPES.include?(addr.type) }
210
+ end
211
+
212
+ def work_phone
213
+ telephones.find { |tel| tel.type == 'work' }
214
+ end
215
+
216
+ def mobile_phone
217
+ telephones.find { |tel| tel.type == 'mobile' }
218
+ end
219
+
220
+ def orcid
221
+ identifier_by_type('orcid')
222
+ end
223
+
224
+ def directory_id
225
+ identifier_by_type('directory')
226
+ end
227
+
228
+ private
229
+
230
+ def build_person_name(name_node)
231
+ PersonName.new(
232
+ name_node['type'],
233
+ name_node['visibility'],
234
+ name_node.children.first&.text&.strip,
235
+ name_node.at_xpath('first')&.text,
236
+ name_node.at_xpath('first')&.[]('nval'),
237
+ name_node.at_xpath('middle')&.text,
238
+ name_node.at_xpath('middle')&.[]('nval'),
239
+ name_node.at_xpath('last')&.text,
240
+ name_node.at_xpath('last')&.[]('nval')
241
+ )
242
+ end
243
+
244
+ def build_address(addr_node)
245
+ lines = addr_node.xpath('line').map(&:text)
246
+ Address.new(
247
+ addr_node['type'],
248
+ addr_node['visibility'],
249
+ addr_node.children.first&.text&.strip,
250
+ lines.length == 1 ? lines.first : lines,
251
+ addr_node.at_xpath('city')&.text,
252
+ addr_node.at_xpath('state')&.text,
253
+ addr_node.at_xpath('state')&.[]('code'),
254
+ addr_node.at_xpath('postalcode')&.text,
255
+ addr_node.at_xpath('country')&.text,
256
+ addr_node.at_xpath('country')&.[]('alpha2'),
257
+ addr_node.at_xpath('country')&.[]('alpha3'),
258
+ addr_node.at_xpath('country')&.[]('numeric'),
259
+ addr_node['affnum']
260
+ )
261
+ end
262
+
263
+ def build_telephone(tel_node)
264
+ Telephone.new(
265
+ tel_node['type'],
266
+ tel_node['visibility'],
267
+ tel_node.children.first&.text&.strip,
268
+ tel_node.at_xpath('icc')&.text,
269
+ tel_node.at_xpath('area')&.text,
270
+ tel_node.at_xpath('number')&.text,
271
+ tel_node['affnum']
272
+ )
273
+ end
274
+
275
+ def build_department(dept_node)
276
+ return nil unless dept_node
277
+
278
+ org_node = dept_node.at_xpath('organization')
279
+ Department.new(
280
+ dept_node['affnum'],
281
+ dept_node.children.first&.text&.strip,
282
+ org_node&.text,
283
+ org_node&.[]('adminid'),
284
+ org_node&.[]('level2orgid'),
285
+ org_node&.[]('level2orgname'),
286
+ org_node&.[]('regid')
287
+ )
288
+ end
289
+
290
+ def build_affdata_array(aff_node)
291
+ aff_node.xpath('affdata').map do |data_node|
292
+ AffData.new(
293
+ data_node['affnum'],
294
+ data_node['type'],
295
+ data_node['code'],
296
+ data_node.text
297
+ )
298
+ end
299
+ end
300
+
301
+ def build_places_for_affiliation(aff_node)
302
+ aff_node.xpath('place').map { |place_node| build_place(place_node) }
303
+ end
304
+
305
+ def build_place(place_node)
306
+ place_addresses = place_node.xpath('address').map { |addr_node| build_address(addr_node) }
307
+ place_telephones = place_node.xpath('telephone').map { |tel_node| build_telephone(tel_node) }
308
+
309
+ Place.new(
310
+ place_node['type'],
311
+ place_node['affnum'],
312
+ place_addresses,
313
+ place_node.at_xpath('qbfr')&.text,
314
+ place_telephones
315
+ )
316
+ end
317
+
318
+ def build_emergency_contact_telephones(contact_node)
319
+ contact_node.xpath('contact_telephone').map do |tel_node|
320
+ Telephone.new(
321
+ tel_node['type'],
322
+ tel_node['visibility'],
323
+ tel_node.children.first&.text&.strip,
324
+ tel_node.at_xpath('icc')&.text,
325
+ tel_node.at_xpath('area')&.text,
326
+ tel_node.at_xpath('number')&.text,
327
+ nil
328
+ )
329
+ end
330
+ end
331
+
332
+ def build_emergency_contact_address(contact_node)
333
+ addr_node = contact_node.at_xpath('contact_address')
334
+ return nil unless addr_node
335
+
336
+ build_address(addr_node)
337
+ end
338
+
339
+ def build_affiliation(aff_node)
340
+ department = build_department(aff_node.at_xpath('department'))
341
+ affdata = build_affdata_array(aff_node)
342
+ aff_places = build_places_for_affiliation(aff_node)
343
+
344
+ Affiliation.new(
345
+ aff_node['affnum'],
346
+ aff_node['effective'],
347
+ aff_node['organization'],
348
+ aff_node['type'],
349
+ aff_node['visibility'],
350
+ aff_node.children.first&.text&.strip,
351
+ department,
352
+ aff_node.at_xpath('description')&.text,
353
+ affdata,
354
+ aff_places
355
+ )
356
+ end
357
+
358
+ def build_emergency_contact(contact_node)
359
+ contact_telephones = build_emergency_contact_telephones(contact_node)
360
+ contact_address = build_emergency_contact_address(contact_node)
361
+ rel_node = contact_node.at_xpath('contact_relationship')
362
+
363
+ EmergencyContact.new(
364
+ contact_node['number'],
365
+ contact_node['primary'] == 'true',
366
+ contact_node['sync_permanent'] == 'true',
367
+ contact_node['visibility'],
368
+ contact_node.at_xpath('contact_name')&.text,
369
+ rel_node&.text,
370
+ rel_node&.[]('code'),
371
+ contact_telephones,
372
+ contact_address
373
+ )
374
+ end
375
+ end
376
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MaisPersonClient
4
+ # Handles unexpected responses when communicating with Mais
5
+ class UnexpectedResponse
6
+ # Error raised when the Mais API returns a 401 Unauthorized
7
+ class UnauthorizedError < StandardError; end
8
+
9
+ # Error raised when the Mais API returns a 500 error
10
+ class ServerError < StandardError; end
11
+
12
+ # Error raised when the Mais API returns a response with an error message in it
13
+ class ResponseError < StandardError; end
14
+
15
+ def self.call(response)
16
+ case response.status
17
+ when 401
18
+ raise UnauthorizedError, "There was a problem with authentication: #{response.body}"
19
+ when 500
20
+ raise ServerError, "Mais server error: #{response.body}"
21
+ else
22
+ raise StandardError, "Unexpected response: #{response.status} #{response.body}"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MaisPersonClient
4
+ VERSION = '0.0.1'
5
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/module/delegation'
4
+
5
+ require 'faraday'
6
+ require 'faraday/retry'
7
+ require 'openssl'
8
+ require 'ostruct'
9
+ require 'nokogiri'
10
+ require 'singleton'
11
+ require 'zeitwerk'
12
+
13
+ # Load the gem's internal dependencies: use Zeitwerk instead of needing to manually require classes
14
+ Zeitwerk::Loader.for_gem.setup
15
+
16
+ # Client for interacting with MAIS's Person API
17
+ class MaisPersonClient
18
+ include Singleton
19
+
20
+ class << self
21
+ # @param api_key [String] the api_key provided by MAIS
22
+ # @param api_cert [String] the api_cert provided by MAIS
23
+ # @param base_url [String] the base URL for the API
24
+ # @param user_agent [String] the user agent to use for requests (default: 'stanford-library')
25
+ def configure(api_key:, api_cert:, base_url:, user_agent: 'stanford-library')
26
+ # rubocop:disable Style/OpenStructUse
27
+ instance.config = OpenStruct.new(
28
+ api_key:,
29
+ api_cert:,
30
+ base_url:,
31
+ user_agent:
32
+ )
33
+ # rubocop:enable Style/OpenStructUse
34
+
35
+ self
36
+ end
37
+
38
+ delegate :config, :fetch_user, to: :instance
39
+ end
40
+
41
+ attr_accessor :config
42
+
43
+ # Fetch a user details
44
+ # @param [string] sunet to fetch
45
+ # @return [<Person>, nil] user or nil if not found
46
+ def fetch_user(sunetid)
47
+ get_response("/doc/person/#{sunetid}", allow404: true)
48
+ end
49
+
50
+ private
51
+
52
+ def get_response(path, allow404: false)
53
+ response = conn.get(path)
54
+
55
+ return if allow404 && response.status == 404
56
+
57
+ return UnexpectedResponse.call(response) unless response.success?
58
+
59
+ response.body
60
+ end
61
+
62
+ def conn
63
+ conn = Faraday.new(url: config.base_url) do |faraday|
64
+ configure_faraday(faraday)
65
+ end
66
+ conn.options.timeout = 500
67
+ conn.options.open_timeout = 10
68
+ conn.headers[:user_agent] = config.user_agent
69
+ conn
70
+ end
71
+
72
+ def configure_faraday(faraday)
73
+ faraday.request :retry, max: 3,
74
+ interval: 0.5,
75
+ interval_randomness: 0.5,
76
+ backoff_factor: 2
77
+
78
+ # Configure SSL for client certificate authentication (unless we are using bogus fake values in spec)
79
+ return if config.api_key.include?('fakekey')
80
+
81
+ cert = OpenSSL::X509::Certificate.new(config.api_cert)
82
+ key = OpenSSL::PKey::RSA.new(config.api_key)
83
+ faraday.ssl.client_cert = cert
84
+ faraday.ssl.client_key = key
85
+ end
86
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'mais_person_client/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'mais_person_client'
9
+ spec.version = MaisPersonClient::VERSION
10
+ spec.authors = ['Peter Mangiafico']
11
+ spec.email = ['pmangiafico@stanford.edu']
12
+
13
+ spec.summary = "Interface for interacting with the MAIS's Person API."
14
+ spec.description = "This provides API interaction with the MAIS's Person API"
15
+ spec.homepage = 'https://github.com/sul-dlss/mais_person_client'
16
+ spec.required_ruby_version = '>= 3.2.0'
17
+
18
+ spec.metadata['homepage_uri'] = spec.homepage
19
+ spec.metadata['source_code_uri'] = 'https://github.com/sul-dlss/mais_person_client'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/sul-dlss/mais_person_client/releases'
21
+ spec.metadata['rubygems_mfa_required'] = 'true'
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
+ spec.files = Dir.chdir(__dir__) do
26
+ `git ls-files -z`.split("\x0").reject do |f|
27
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
28
+ end
29
+ end
30
+ spec.bindir = 'exe'
31
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
+ spec.require_paths = ['lib']
33
+
34
+ spec.add_dependency 'activesupport', '>= 4.2'
35
+ spec.add_dependency 'faraday'
36
+ spec.add_dependency 'faraday-retry'
37
+ spec.add_dependency 'nokogiri'
38
+ spec.add_dependency 'ostruct'
39
+ spec.add_dependency 'zeitwerk'
40
+
41
+ spec.add_development_dependency 'byebug'
42
+ spec.add_development_dependency 'pry'
43
+ spec.add_development_dependency 'rake', '~> 13.0'
44
+ spec.add_development_dependency 'reline'
45
+ spec.add_development_dependency 'rspec', '~> 3.0'
46
+ spec.add_development_dependency 'rubocop'
47
+ spec.add_development_dependency 'rubocop-capybara'
48
+ spec.add_development_dependency 'rubocop-factory_bot'
49
+ spec.add_development_dependency 'rubocop-performance'
50
+ spec.add_development_dependency 'rubocop-rspec'
51
+ spec.add_development_dependency 'rubocop-rspec_rails'
52
+ spec.add_development_dependency 'simplecov'
53
+ spec.add_development_dependency 'vcr'
54
+ spec.add_development_dependency 'webmock'
55
+ end