borrow_direct 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (76) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +14 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +183 -0
  6. data/Rakefile +11 -0
  7. data/bd_metrics/finditem_measure.rb +61 -0
  8. data/bd_metrics/isbn.txt +1088 -0
  9. data/bd_metrics/lccn.txt +1668 -0
  10. data/bd_metrics/oclc.txt +167 -0
  11. data/borrow_direct.gemspec +30 -0
  12. data/lib/borrow_direct/authentication.rb +55 -0
  13. data/lib/borrow_direct/defaults.rb +38 -0
  14. data/lib/borrow_direct/error.rb +17 -0
  15. data/lib/borrow_direct/find_item.rb +149 -0
  16. data/lib/borrow_direct/generate_query.rb +78 -0
  17. data/lib/borrow_direct/request.rb +185 -0
  18. data/lib/borrow_direct/request_item.rb +119 -0
  19. data/lib/borrow_direct/request_query.rb +124 -0
  20. data/lib/borrow_direct/util.rb +18 -0
  21. data/lib/borrow_direct/version.rb +3 -0
  22. data/lib/borrow_direct.rb +19 -0
  23. data/test/authentication_test.rb +79 -0
  24. data/test/find_item_test.rb +159 -0
  25. data/test/generate_query_test.rb +91 -0
  26. data/test/request_item_test.rb +109 -0
  27. data/test/request_query_test.rb +113 -0
  28. data/test/request_test.rb +141 -0
  29. data/test/support/assertions.rb +23 -0
  30. data/test/support/vcr_filter.rb +45 -0
  31. data/test/test_helper.rb +39 -0
  32. data/test/util_test.rb +32 -0
  33. data/test/vcr_cassettes/Authentication/Makes_a_request_succesfully.yml +52 -0
  34. data/test/vcr_cassettes/Authentication/Raises_for_bad_library_symbol.yml +52 -0
  35. data/test/vcr_cassettes/Authentication/Raises_for_bad_patron_barcode.yml +53 -0
  36. data/test/vcr_cassettes/Authentication/get_auth_id/raises_for_a_bad_library_symbol.yml +52 -0
  37. data/test/vcr_cassettes/Authentication/get_auth_id/raises_for_a_bad_patron_barcode.yml +53 -0
  38. data/test/vcr_cassettes/Authentication/get_auth_id/returns_an_auth_id_for_a_good_request.yml +52 -0
  39. data/test/vcr_cassettes/Authentication/raw_request_to_verify_HTTP_api/.yml +52 -0
  40. data/test/vcr_cassettes/FindItem/_find_item_request/finds_a_locally_available_item.yml +49 -0
  41. data/test/vcr_cassettes/FindItem/_find_item_request/finds_a_requestable_item.yml +49 -0
  42. data/test/vcr_cassettes/FindItem/_find_item_request/finds_an_item_that_does_not_exist_in_BD.yml +50 -0
  43. data/test/vcr_cassettes/FindItem/_find_item_request/with_expected_error_PUBFI002/returns_result.yml +40 -0
  44. data/test/vcr_cassettes/FindItem/_find_item_request/works_with_multiple_values.yml +49 -0
  45. data/test/vcr_cassettes/FindItem/find_with_Response/has_an_auth_id.yml +49 -0
  46. data/test/vcr_cassettes/FindItem/find_with_Response/has_nil_auth_id_when_BD_doesn_t_want_to_give_us_one.yml +40 -0
  47. data/test/vcr_cassettes/FindItem/find_with_Response/has_nil_pickup_locations_when_BD_doesn_t_want_to_give_us_them.yml +40 -0
  48. data/test/vcr_cassettes/FindItem/find_with_Response/has_pickup_locations.yml +49 -0
  49. data/test/vcr_cassettes/FindItem/find_with_Response/not_requestable_for_item_that_BD_returns_PUBFI002.yml +40 -0
  50. data/test/vcr_cassettes/FindItem/find_with_Response/not_requestable_for_item_that_does_not_exist_in_BD.yml +50 -0
  51. data/test/vcr_cassettes/FindItem/find_with_Response/not_requestable_for_item_that_no_libraries_will_lend.yml +50 -0
  52. data/test/vcr_cassettes/FindItem/find_with_Response/not_requestable_for_locally_available_item.yml +49 -0
  53. data/test/vcr_cassettes/FindItem/find_with_Response/requestable_for_requestable_item.yml +49 -0
  54. data/test/vcr_cassettes/FindItem/find_with_Response/requestable_with_multiple_items_if_at_least_one_is_requestable.yml +49 -0
  55. data/test/vcr_cassettes/Request/authentication_id/automatically_fetches_one_when_needed.yml +52 -0
  56. data/test/vcr_cassettes/Request/authentication_id/can_refetch_when_instructed.yml +52 -0
  57. data/test/vcr_cassettes/Request/can_make_a_succesful_request.yml +49 -0
  58. data/test/vcr_cassettes/Request/gets_BD_error_info.yml +41 -0
  59. data/test/vcr_cassettes/Request/raises_on_bad_path.yml +53 -0
  60. data/test/vcr_cassettes/Request/raises_on_bad_request_hash.yml +63 -0
  61. data/test/vcr_cassettes/Request/with_expected_errors/still_returns_result.yml +41 -0
  62. data/test/vcr_cassettes/RequestItem/make_request/make_request_for_a_locally_available_item.yml +90 -0
  63. data/test/vcr_cassettes/RequestItem/make_request/make_request_for_a_requestable_item.yml +89 -0
  64. data/test/vcr_cassettes/RequestItem/make_request/make_request_for_an_unrequestable_item.yml +91 -0
  65. data/test/vcr_cassettes/RequestItem/make_request/raises_for_unrequestable.yml +91 -0
  66. data/test/vcr_cassettes/RequestItem/make_request/returns_number_for_succesful_request.yml +89 -0
  67. data/test/vcr_cassettes/RequestItem/make_request/says_no_for_item_that_BD_returns_PUBRI004.yml +89 -0
  68. data/test/vcr_cassettes/RequestItem/raw_requests_an_unrequestable_item.yml +91 -0
  69. data/test/vcr_cassettes/RequestItem/uses_manually_set_auth_id.yml +89 -0
  70. data/test/vcr_cassettes/RequestItem/with_pickup_location_and_requestable_item/still_works.yml +90 -0
  71. data/test/vcr_cassettes/RequestQuery/raw_request_query_request/returns_results.yml +381 -0
  72. data/test/vcr_cassettes/RequestQuery/raw_request_to_verify_the_BD_HTTP_API.yml +381 -0
  73. data/test/vcr_cassettes/RequestQuery/requests/fetches_default_records.yml +384 -0
  74. data/test/vcr_cassettes/RequestQuery/requests/fetches_full_records.yml +481 -0
  75. data/test/vcr_cassettes/top_level_describe/an_inner_describe/.yml +76 -0
  76. metadata +262 -0
@@ -0,0 +1,167 @@
1
+ 10065659
2
+ 10586552
3
+ 1081684
4
+ 10904204
5
+ 1124917
6
+ 11803073
7
+ 11910543
8
+ 123077828
9
+ 123209896
10
+ 12748813
11
+ 1331857
12
+ 1379728
13
+ 1424222
14
+ 1434991
15
+ 1478791
16
+ 1480801
17
+ 14814735
18
+ 1536398
19
+ 1564931
20
+ 1565627
21
+ 1567377
22
+ 1586310
23
+ 1638942
24
+ 1639814
25
+ 16402936
26
+ 1643323
27
+ 1644869
28
+ 1645522
29
+ 1714985
30
+ 1751778
31
+ 1751795
32
+ 1752305
33
+ 1755507
34
+ 1763246
35
+ 1764190
36
+ 1765691
37
+ 1767509
38
+ 1779849
39
+ 18141357
40
+ 18654422
41
+ 190859513
42
+ 2033274
43
+ 20400968
44
+ 22167445
45
+ 2251554
46
+ 226114275
47
+ 2264017
48
+ 232115955
49
+ 232117723
50
+ 24072815
51
+ 2410175
52
+ 2445140
53
+ 2464767
54
+ 247189142
55
+ 2520764
56
+ 29874562
57
+ 2993219
58
+ 300267691
59
+ 30404911
60
+ 31042626
61
+ 31218891
62
+ 31949646
63
+ 3306860
64
+ 34085858
65
+ 34134679
66
+ 34601690
67
+ 36289392
68
+ 36491673
69
+ 36818836
70
+ 36950362
71
+ 37320198
72
+ 37939040
73
+ 38161063
74
+ 38309698
75
+ 38537165
76
+ 38589589
77
+ 38866515
78
+ 38871666
79
+ 38911795
80
+ 38912661
81
+ 38945637
82
+ 39003731
83
+ 3900884
84
+ 39148292
85
+ 39224682
86
+ 41070319
87
+ 41092744
88
+ 41407365
89
+ 41882904
90
+ 41973550
91
+ 41978558
92
+ 42635201
93
+ 4339970
94
+ 43437977
95
+ 43573821
96
+ 43829445
97
+ 44178619
98
+ 44519004
99
+ 44520978
100
+ 44685364
101
+ 44715152
102
+ 45267760
103
+ 45421405
104
+ 4652347
105
+ 46822658
106
+ 47075834
107
+ 47076058
108
+ 47377635
109
+ 47671164
110
+ 47727849
111
+ 47810818
112
+ 47815833
113
+ 48017624
114
+ 48151618
115
+ 48801390
116
+ 48880094
117
+ 48887313
118
+ 49286131
119
+ 49604992
120
+ 498003413
121
+ 49849538
122
+ 49920040
123
+ 50167015
124
+ 50245479
125
+ 50427037
126
+ 50732308
127
+ 50784557
128
+ 51244236
129
+ 51761890
130
+ 52616475
131
+ 54449599
132
+ 5461534
133
+ 55201024
134
+ 5520474
135
+ 55737776
136
+ 55803866
137
+ 57004362
138
+ 57378025
139
+ 60616144
140
+ 60620970
141
+ 60623349
142
+ 60624664
143
+ 60626878
144
+ 60627706
145
+ 60628611
146
+ 607405987
147
+ 61125729
148
+ 611639238
149
+ 61311169
150
+ 613617204
151
+ 61523343
152
+ 6291542
153
+ 6294105
154
+ 641269190
155
+ 6415579
156
+ 67618577
157
+ 696271299
158
+ 702602240
159
+ 762029593
160
+ 809393
161
+ 8287652
162
+ 830989328
163
+ 84844215
164
+ 85489765
165
+ 94139525
166
+ 9438862
167
+
@@ -0,0 +1,30 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'borrow_direct/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "borrow_direct"
8
+ spec.version = BorrowDirect::VERSION
9
+ spec.authors = ["Jonathan Rochkind"]
10
+ spec.email = ["jonathan@dnil.net"]
11
+ spec.summary = %q{Ruby tools for interacting with the Borrow Direct consortial services}
12
+ spec.homepage = ""
13
+ spec.license = "MIT"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0")
16
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
+ spec.require_paths = ["lib"]
19
+
20
+ spec.add_dependency "httpclient", "~> 2.4"
21
+
22
+ #spec.add_development_dependency "bundler", ">= 1.6.2", "< 2"
23
+ spec.add_development_dependency "rake", "~> 10.0"
24
+
25
+ spec.add_development_dependency "minitest", "~> 5.0"
26
+ spec.add_development_dependency "minitest-vcr", ">= 1.0.2", "< 2"
27
+ spec.add_development_dependency "vcr", "~> 2.9"
28
+ spec.add_development_dependency "webmock", "~> 1.11"
29
+
30
+ end
@@ -0,0 +1,55 @@
1
+ require 'borrow_direct/request'
2
+
3
+ module BorrowDirect
4
+
5
+ # The BD Authorization API
6
+ # http://borrowdirect.pbworks.com/w/file/83346673/Authorization%20Service.docx
7
+ #
8
+ # For now, always calls with 'patron' type.
9
+ class Authentication < Request
10
+ attr_reader :patron_barcode, :patron_library_symbol
11
+
12
+ @@api_path = "/portal-service/user/authentication/patron"
13
+
14
+ # BorrowDirect::Authentication.new(barcode)
15
+ # BorrowDirect::Authentication.new(barcode, library_symbol)
16
+ def initialize(patron_barcode,
17
+ patron_library_symbol = Defaults.library_symbol)
18
+ super(@@api_path)
19
+
20
+ @patron_barcode = patron_barcode
21
+ @patron_library_symbol = patron_library_symbol
22
+ end
23
+
24
+ # Returns raw Hash results of the Authentication request
25
+ # See also #get_auth_id
26
+ def authentication_request
27
+ self.request authentication_request_hash(self.patron_barcode, self.patron_library_symbol)
28
+ end
29
+
30
+ # Makes a request and returns the "AId" value which is used as input
31
+ # "AuthorizationId" in other API calls.
32
+ #
33
+ # If one can't be obtained for some reason, will raise BorrowDirect::Error
34
+ def get_auth_id
35
+ response = authentication_request
36
+
37
+ if response["Authentication"] && response["Authentication"]["AuthnUserInfo"] && response["Authentication"]["AuthnUserInfo"]["AId"]
38
+ return response["Authentication"]["AuthnUserInfo"]["AId"]
39
+ else
40
+ raise BorrowDirect::Error.new("Could not obtain AId from Authorization API call: #{response.inspect}")
41
+ end
42
+ end
43
+
44
+ def authentication_request_hash(patron_barcode, library_symbol)
45
+ {
46
+ "AuthenticationInformation" => {
47
+ "LibrarySymbol" => library_symbol,
48
+ "PatronId" => patron_barcode
49
+ }
50
+ }
51
+ end
52
+
53
+ end
54
+
55
+ end
@@ -0,0 +1,38 @@
1
+ require 'borrow_direct'
2
+
3
+ module BorrowDirect
4
+ # Some defaults for BD requests, including some you might def want to set
5
+ # at app boot, perhaps in a Rails initializer.
6
+ #
7
+ # To use the production BD system instead of test system:
8
+ # BorrowDirect::Defaults.api_base = BorrowDirect::Defaults::PRODUCTION_API_BASE
9
+ #
10
+ # To set your library's BD symbol as a default:
11
+ # BorrowDirect::Defaults.library_symbol = "YOURSYMBOL"
12
+ #
13
+ # To set a default generic patron barcode to use for FindItem requests
14
+ # BorrowDirect::Defaults.find_item_patron_barcode = "99999999999"
15
+ class Defaults
16
+ TEST_API_BASE = "https://bdtest.relais-host.com/"
17
+ PRODUCTION_API_BASE = "NOT_YET_AVAILABLE"
18
+
19
+ TEST_HTML_BASE = "https://bdtest.relaisd2d.com/service-proxy?command=query"
20
+ PRODUCTION_HTML_BASE = "https://borrow-direct.relaisd2d.com/service-proxy?command=query"
21
+
22
+ class << self
23
+ attr_accessor :api_base, :partnership_id, :find_item_patron_barcode, :library_symbol
24
+ attr_accessor :html_base_url
25
+
26
+ # used for HTTPClient send, connection, AND receive timeouts, so
27
+ # theoretically could take 3x this, but unlikely, usually it's just
28
+ # receive that might timeout. In seconds. Default 30s.
29
+ attr_accessor :timeout
30
+ end
31
+
32
+ self.api_base = BorrowDirect::Defaults::TEST_API_BASE
33
+ self.partnership_id = "BD"
34
+ self.timeout = 30
35
+ self.html_base_url = TEST_HTML_BASE
36
+
37
+ end
38
+ end
@@ -0,0 +1,17 @@
1
+ module BorrowDirect
2
+ class Error < StandardError
3
+ attr_reader :bd_code
4
+
5
+ def initialize(msg, bd_code = nil)
6
+ @bd_code = bd_code
7
+ if @bd_code
8
+ msg = "#{@bd_code}: #{msg}"
9
+ end
10
+ super(msg)
11
+ end
12
+
13
+ end
14
+
15
+ class HttpError < Error ; end
16
+ class HttpTimeoutError < HttpError ; end
17
+ end
@@ -0,0 +1,149 @@
1
+ require 'borrow_direct'
2
+ require 'borrow_direct/request'
3
+
4
+ module BorrowDirect
5
+ # The BorrowDirect FindItem service, for discovering item availability
6
+ # http://borrowdirect.pbworks.com/w/file/83346676/Find%20Item%20Service.docx
7
+ #
8
+ # BorrowDirect::FindItem.new(patron_barcode).bd_requestability?(:isbn => isbn)
9
+ # # or set BorrowDirect::Defaults.find_item_patron_barcode to make patron barcode
10
+ # # optional and use a default patron barcode
11
+ #
12
+ # You can also use #find_item_request to get the raw BD response as a ruby hash
13
+ class FindItem < Request
14
+ attr_reader :patron_barcode, :patron_library_symbol
15
+
16
+ @@api_path = "/dws/item/available"
17
+ @@valid_search_types = %w{ISBN ISSN LCCN OCLC PHRASE}
18
+
19
+
20
+ def initialize(patron_barcode = Defaults.find_item_patron_barcode,
21
+ patron_library_symbol = Defaults.library_symbol)
22
+ super(@@api_path)
23
+
24
+ @patron_barcode = patron_barcode
25
+ @patron_library_symbol = patron_library_symbol
26
+
27
+ # BD sometimes unpredictably returns this error when it means
28
+ # "no results", other times it doens't. We don't want to raise on it.
29
+ self.expected_error_codes << "PUBFI002"
30
+ end
31
+
32
+ # need to send a key and value for a valid exact_search type
33
+ # type can be string or symbol, lowercase or uppercase.
34
+ #
35
+ # Returns the actual complete BD response hash. You may want
36
+ # #bd_requestable? instead
37
+ #
38
+ # finder.find_item_request(:isbn => "12345545456")
39
+ #
40
+ # You can request multiple values which BD will treat as an 'OR'/union -- sort
41
+ # of. BD does unpredictable things here, be careful.
42
+ #
43
+ # finder.find_item_request(:isbn => ["12345545456", "99999999"])
44
+ def find_item_request(options)
45
+ search_type, search_value = nil, nil
46
+ options.each_pair do |key, value|
47
+ if @@valid_search_types.include? key.to_s.upcase
48
+ if search_type || search_value
49
+ raise ArgumentError.new("Only one search criteria at a time is allowed: '#{options}'")
50
+ end
51
+
52
+ search_type, search_value = key, value
53
+ end
54
+ end
55
+ unless search_type && search_value
56
+ raise ArgumentError.new("Missing valid search type and value: '#{options}'")
57
+ end
58
+
59
+ request exact_search_request_hash(search_type, search_value)
60
+ end
61
+
62
+ # need to send a key and value for a valid exact_search type
63
+ # type can be string or symbol, lowercase or uppercase.
64
+ #
65
+ # Returns a BorrowDirect::FindItem::Response object, from which you
66
+ # can find out requestability, list of pickup locations, etc.
67
+ def find(options)
68
+ BorrowDirect::FindItem::Response.new find_item_request(options)
69
+ end
70
+
71
+ protected
72
+
73
+ # Produce BD request hash for exact search of type eg "ISBN"
74
+ # value can be a singel value, or an array of values. For array,
75
+ # BD will "OR" them.
76
+ def exact_search_request_hash(type, value)
77
+ # turn it into an array if it's not one already
78
+ values = Array(value)
79
+
80
+ hash = {
81
+ "PartnershipId" => Defaults.partnership_id,
82
+ "Credentials" => {
83
+ "LibrarySymbol" => self.patron_library_symbol,
84
+ "Barcode" => self.patron_barcode
85
+ },
86
+ "ExactSearch" => []
87
+ }
88
+
89
+ values.each do |value|
90
+ hash["ExactSearch"] << {
91
+ "Type" => type.to_s.upcase,
92
+ "Value" => value
93
+ }
94
+ end
95
+
96
+ return hash
97
+ end
98
+
99
+ class Response
100
+ include BorrowDirect::Util
101
+
102
+ attr_reader :response_hash
103
+
104
+ def initialize(hash)
105
+ @response_hash = hash
106
+ end
107
+
108
+
109
+ # Returns true or false -- can the item actually be requested
110
+ # via BorrowDirect.
111
+ #
112
+ # finder.find(:isbn => "12345545456").requestable?
113
+ def requestable?
114
+ # Sometimes a PUBFI002 error code isn't really an error,
115
+ # but just means not available.
116
+ if response_hash && response_hash["Error"] && (response_hash["Error"]["ErrorNumber"] == "PUBFI002")
117
+ return false
118
+ end
119
+
120
+ # Items that are available locally, and thus not requestable via BD, can
121
+ # only be found by looking at the RequestMessage, bah
122
+ h = response_hash["Item"]["RequestLink"]
123
+ if h && h["RequestMessage"] == "This item is available locally"
124
+ return false
125
+ end
126
+
127
+ return response_hash["Item"]["Available"].to_s == "true"
128
+ end
129
+
130
+ # Returns the AuthorizationID returned by FindItem API call,
131
+ # or nil if none is available. Nil _can_ be returned, for
132
+ # instance when BD returns a NotFound error instead of a good
133
+ # response.
134
+ def auth_id
135
+ hash_key_path response_hash, "Item", "AuthorizationId"
136
+ end
137
+
138
+ # Can be nil in some cases if not requestable?
139
+ # if requestable?, should be an array of Strings.
140
+ def pickup_locations
141
+ hash_key_path response_hash, "Item", "PickupLocations", "PickupLocation"
142
+ end
143
+
144
+
145
+ end
146
+
147
+
148
+ end
149
+ end
@@ -0,0 +1,78 @@
1
+ require 'cgi'
2
+
3
+ module BorrowDirect
4
+ # Generate a "deep link" to query results in BD's native
5
+ # HTML interface.
6
+ class GenerateQuery
7
+ attr_accessor :url_base
8
+
9
+ # Hash from our own API argument to BD field code
10
+ @@fields = {
11
+ :keyword => "term",
12
+ :title => "ti",
13
+ :author => "au",
14
+ :subject => "su",
15
+ :isbn => "isbn",
16
+ :issn => "issn"
17
+ }
18
+
19
+ def initialize(url_base = nil)
20
+ self.url_base = (url_base || BorrowDirect::Defaults.html_base_url)
21
+ end
22
+
23
+ # query_with(:title => "one two", :author => "three four")
24
+ # valid keys are those supported by BD HTML interface:
25
+ # :title, :author, :isbn, :subject, :keyword, :isbn, :issn
26
+ #
27
+ # For now, the value is always searched as a phrase, and multiple
28
+ # fields are always 'and'd. We may enhance/expand later.
29
+ #
30
+ # Returns an un-escaped query, still needs to be put into a URL
31
+ def query_with(options)
32
+ clauses = []
33
+
34
+ options.each_pair do |field, value|
35
+ code = @@fields[field]
36
+
37
+ raise ArgumentError.new("Don't recognize field code `#{field}`") unless code
38
+
39
+ clauses << %Q{#{code}="#{escape_query_value value}"}
40
+ end
41
+
42
+ return clauses.join(" and ")
43
+ end
44
+
45
+ # Pass in :title, :author, :isbn, etc -- if we have an isbn or issn,
46
+ # we'll use that alone, otherwise we'll use title and author
47
+ def best_known_item_query_with(options)
48
+ if options[:isbn]
49
+ return query_with(options.dup.delete_if {|k| k != :isbn})
50
+ elsif options[:issn]
51
+ return query_with(options.dup.delete_if {|k| k != :issn})
52
+ else
53
+ return query_with options
54
+ end
55
+ end
56
+
57
+ def query_url_with(options)
58
+ query = query_with(options)
59
+
60
+ return self.url_base + '?' + "query=#{CGI.escape query}"
61
+ end
62
+
63
+ def best_known_item_query_url_with(options)
64
+ query = best_known_item_query_with(options)
65
+
66
+ return self.url_base + '?' + "query=#{CGI.escape query}"
67
+ end
68
+
69
+ # We don't really know how to escape, for now
70
+ # we just remove double quotes and parens, and replace with spaces.
71
+ # those seem to cause problems, and that seems to work.
72
+ def escape_query_value(str)
73
+ str.gsub(/[")()]/, ' ')
74
+ end
75
+
76
+
77
+ end
78
+ end