exaonruby 1.0.0

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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/exaonruby.gemspec +37 -0
  5. data/exe/exa +7 -0
  6. data/lib/exa/cli.rb +458 -0
  7. data/lib/exa/client.rb +210 -0
  8. data/lib/exa/configuration.rb +81 -0
  9. data/lib/exa/endpoints/answer.rb +109 -0
  10. data/lib/exa/endpoints/contents.rb +141 -0
  11. data/lib/exa/endpoints/events.rb +71 -0
  12. data/lib/exa/endpoints/find_similar.rb +154 -0
  13. data/lib/exa/endpoints/imports.rb +145 -0
  14. data/lib/exa/endpoints/monitors.rb +193 -0
  15. data/lib/exa/endpoints/research.rb +158 -0
  16. data/lib/exa/endpoints/search.rb +195 -0
  17. data/lib/exa/endpoints/webhooks.rb +161 -0
  18. data/lib/exa/endpoints/webset_enrichments.rb +162 -0
  19. data/lib/exa/endpoints/webset_items.rb +90 -0
  20. data/lib/exa/endpoints/webset_searches.rb +137 -0
  21. data/lib/exa/endpoints/websets.rb +214 -0
  22. data/lib/exa/errors.rb +180 -0
  23. data/lib/exa/resources/answer_response.rb +101 -0
  24. data/lib/exa/resources/base.rb +56 -0
  25. data/lib/exa/resources/contents_response.rb +123 -0
  26. data/lib/exa/resources/event.rb +84 -0
  27. data/lib/exa/resources/import.rb +137 -0
  28. data/lib/exa/resources/monitor.rb +205 -0
  29. data/lib/exa/resources/paginated_response.rb +87 -0
  30. data/lib/exa/resources/research_task.rb +165 -0
  31. data/lib/exa/resources/search_response.rb +111 -0
  32. data/lib/exa/resources/search_result.rb +95 -0
  33. data/lib/exa/resources/webhook.rb +152 -0
  34. data/lib/exa/resources/webset.rb +491 -0
  35. data/lib/exa/resources/webset_item.rb +256 -0
  36. data/lib/exa/utils/parameter_converter.rb +159 -0
  37. data/lib/exa/utils/webhook_handler.rb +239 -0
  38. data/lib/exa/version.rb +7 -0
  39. data/lib/exa.rb +130 -0
  40. metadata +146 -0
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ module Resources
7
+ class WebsetItem < Base
8
+ # @return [String] Unique identifier
9
+ def id
10
+ get(:id)
11
+ end
12
+
13
+ # @return [String] Object type (always "webset_item")
14
+ def object
15
+ get(:object)
16
+ end
17
+
18
+ # @return [String] Source type: search, import
19
+ def source
20
+ get(:source)
21
+ end
22
+
23
+ # @return [String, nil] Source identifier
24
+ def source_id
25
+ get(:source_id)
26
+ end
27
+
28
+ # @return [String] Parent webset ID
29
+ def webset_id
30
+ get(:webset_id)
31
+ end
32
+
33
+ # @return [WebsetItemProperties, nil] Item properties
34
+ def properties
35
+ props = get(:properties)
36
+ return nil unless props
37
+
38
+ WebsetItemProperties.new(props)
39
+ end
40
+
41
+ # @return [Array<WebsetItemEvaluation>] Evaluation results
42
+ def evaluations
43
+ @evaluations ||= (get(:evaluations, []) || []).map { |data| WebsetItemEvaluation.new(data) }
44
+ end
45
+
46
+ # @return [Array<WebsetItemEnrichmentResult>] Enrichment results
47
+ def enrichments
48
+ @enrichments ||= (get(:enrichments, []) || []).map { |data| WebsetItemEnrichmentResult.new(data) }
49
+ end
50
+
51
+ # @return [Time, nil] Creation timestamp
52
+ def created_at
53
+ parse_time(get(:created_at))
54
+ end
55
+
56
+ # @return [Time, nil] Last update timestamp
57
+ def updated_at
58
+ parse_time(get(:updated_at))
59
+ end
60
+
61
+ # @return [String, nil] Item type from properties
62
+ def type
63
+ dig(:properties, :type)
64
+ end
65
+
66
+ # @return [String, nil] URL from properties
67
+ def url
68
+ dig(:properties, :url)
69
+ end
70
+
71
+ # @return [String, nil] Description from properties
72
+ def description
73
+ dig(:properties, :description)
74
+ end
75
+
76
+ # @return [Boolean] Whether all evaluations passed
77
+ def all_criteria_satisfied?
78
+ evaluations.all?(&:satisfied?)
79
+ end
80
+
81
+ # @return [Array<WebsetItemEvaluation>] Failed evaluations
82
+ def failed_evaluations
83
+ evaluations.reject(&:satisfied?)
84
+ end
85
+
86
+ private
87
+
88
+ def parse_time(value)
89
+ return nil unless value
90
+
91
+ Time.parse(value)
92
+ rescue ArgumentError
93
+ nil
94
+ end
95
+
96
+ def inspectable_attributes
97
+ { id: id, type: type, url: url }
98
+ end
99
+ end
100
+
101
+ class WebsetItemProperties < Base
102
+ # @return [String, nil] Entity type: person, company, research_paper
103
+ def type
104
+ get(:type)
105
+ end
106
+
107
+ # @return [String, nil] URL
108
+ def url
109
+ get(:url)
110
+ end
111
+
112
+ # @return [String, nil] Description
113
+ def description
114
+ get(:description)
115
+ end
116
+
117
+ # @return [Hash, nil] Person information if type is person
118
+ def person
119
+ get(:person)
120
+ end
121
+
122
+ # @return [Hash, nil] Company information if type is company
123
+ def company
124
+ get(:company)
125
+ end
126
+
127
+ # @return [Hash, nil] Research paper information if type is research_paper
128
+ def research_paper
129
+ get(:research_paper)
130
+ end
131
+
132
+ # Person attributes (when type is person)
133
+
134
+ # @return [String, nil] Person's name
135
+ def person_name
136
+ dig(:person, :name)
137
+ end
138
+
139
+ # @return [String, nil] Person's location
140
+ def person_location
141
+ dig(:person, :location)
142
+ end
143
+
144
+ # @return [String, nil] Person's position/title
145
+ def person_position
146
+ dig(:person, :position)
147
+ end
148
+
149
+ # @return [String, nil] Person's company name
150
+ def person_company_name
151
+ dig(:person, :company, :name)
152
+ end
153
+
154
+ # @return [String, nil] Person's picture URL
155
+ def person_picture_url
156
+ dig(:person, :picture_url)
157
+ end
158
+
159
+ # Company attributes (when type is company)
160
+
161
+ # @return [String, nil] Company name
162
+ def company_name
163
+ dig(:company, :name)
164
+ end
165
+
166
+ # @return [String, nil] Company location
167
+ def company_location
168
+ dig(:company, :location)
169
+ end
170
+ end
171
+
172
+ class WebsetItemEvaluation < Base
173
+ # @return [String, nil] Criterion description
174
+ def criterion
175
+ get(:criterion)
176
+ end
177
+
178
+ # @return [String, nil] Reasoning for the evaluation
179
+ def reasoning
180
+ get(:reasoning)
181
+ end
182
+
183
+ # @return [String, nil] Satisfaction status: yes, no, unknown
184
+ def satisfied_status
185
+ get(:satisfied)
186
+ end
187
+
188
+ # @return [Boolean] Whether the criterion was satisfied
189
+ def satisfied?
190
+ satisfied_status == "yes"
191
+ end
192
+
193
+ # @return [Boolean] Whether satisfaction is unknown
194
+ def unknown?
195
+ satisfied_status == "unknown"
196
+ end
197
+
198
+ # @return [Array<Hash>] References supporting the evaluation
199
+ def references
200
+ get(:references, [])
201
+ end
202
+ end
203
+
204
+ class WebsetItemEnrichmentResult < Base
205
+ # @return [String] Object type
206
+ def object
207
+ get(:object)
208
+ end
209
+
210
+ # @return [String] Status: pending, completed, failed
211
+ def status
212
+ get(:status)
213
+ end
214
+
215
+ # @return [String, nil] Format: text, enum, number, boolean, date
216
+ def format
217
+ get(:format)
218
+ end
219
+
220
+ # @return [Array, String, Integer, Boolean, nil] Result value(s)
221
+ def result
222
+ get(:result)
223
+ end
224
+
225
+ # @return [String, nil] Reasoning for the result
226
+ def reasoning
227
+ get(:reasoning)
228
+ end
229
+
230
+ # @return [Array<Hash>] References used
231
+ def references
232
+ get(:references, [])
233
+ end
234
+
235
+ # @return [String, nil] Parent enrichment ID
236
+ def enrichment_id
237
+ get(:enrichment_id)
238
+ end
239
+
240
+ # @return [Boolean] Whether completed successfully
241
+ def completed?
242
+ status == "completed"
243
+ end
244
+
245
+ # @return [Boolean] Whether still pending
246
+ def pending?
247
+ status == "pending"
248
+ end
249
+
250
+ # @return [Boolean] Whether failed
251
+ def failed?
252
+ status == "failed"
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ module Utils
7
+ class ParameterConverter
8
+ SNAKE_TO_CAMEL_MAP = {
9
+ api_key: :apiKey,
10
+ num_results: :numResults,
11
+ include_domains: :includeDomains,
12
+ exclude_domains: :excludeDomains,
13
+ start_crawl_date: :startCrawlDate,
14
+ end_crawl_date: :endCrawlDate,
15
+ start_published_date: :startPublishedDate,
16
+ end_published_date: :endPublishedDate,
17
+ include_text: :includeText,
18
+ exclude_text: :excludeText,
19
+ additional_queries: :additionalQueries,
20
+ user_location: :userLocation,
21
+ livecrawl_timeout: :livecrawlTimeout,
22
+ subpage_target: :subpageTarget,
23
+ highlight_query: :highlightQuery,
24
+ num_sentences: :numSentences,
25
+ max_characters: :maxCharacters,
26
+ external_id: :externalId,
27
+ source_id: :sourceId,
28
+ webset_id: :websetId,
29
+ search_id: :searchId,
30
+ enrichment_id: :enrichmentId,
31
+ item_id: :itemId,
32
+ success_rate: :successRate,
33
+ time_left: :timeLeft,
34
+ next_cursor: :nextCursor,
35
+ has_more: :hasMore,
36
+ created_at: :createdAt,
37
+ updated_at: :updatedAt,
38
+ canceled_at: :canceledAt,
39
+ canceled_reason: :canceledReason,
40
+ failed_at: :failedAt,
41
+ failed_reason: :failedReason,
42
+ failed_message: :failedMessage,
43
+ last_run: :lastRun,
44
+ next_run_at: :nextRunAt,
45
+ picture_url: :pictureUrl,
46
+ cost_dollars: :costDollars,
47
+ request_id: :requestId,
48
+ resolved_search_type: :resolvedSearchType,
49
+ search_type: :searchType,
50
+ published_date: :publishedDate,
51
+ highlight_scores: :highlightScores
52
+ }.freeze
53
+
54
+ CAMEL_TO_SNAKE_MAP = SNAKE_TO_CAMEL_MAP.invert.freeze
55
+
56
+ class << self
57
+ # Converts Ruby-style snake_case keys to API-style camelCase
58
+ # @param hash [Hash] Hash with snake_case keys
59
+ # @return [Hash] Hash with camelCase keys
60
+ def to_api_params(hash)
61
+ return hash unless hash.is_a?(Hash)
62
+
63
+ hash.each_with_object({}) do |(key, value), result|
64
+ api_key = convert_key_to_camel(key)
65
+ result[api_key] = convert_value_to_api(value)
66
+ end
67
+ end
68
+
69
+ # Converts API-style camelCase keys to Ruby-style snake_case
70
+ # @param hash [Hash] Hash with camelCase keys
71
+ # @return [Hash] Hash with snake_case keys
72
+ def from_api_response(hash)
73
+ return hash unless hash.is_a?(Hash)
74
+
75
+ hash.each_with_object({}) do |(key, value), result|
76
+ ruby_key = convert_key_to_snake(key)
77
+ result[ruby_key] = convert_value_from_api(value)
78
+ end
79
+ end
80
+
81
+ # Formats a Time or Date object to ISO 8601 string
82
+ # @param value [Time, Date, String, nil] Value to format
83
+ # @return [String, nil] ISO 8601 formatted string
84
+ def format_date(value)
85
+ case value
86
+ when Time
87
+ value.utc.iso8601(3)
88
+ when Date
89
+ value.to_time.utc.iso8601(3)
90
+ when String
91
+ value
92
+ end
93
+ end
94
+
95
+ # Parses an ISO 8601 string to Time object
96
+ # @param value [String, nil] ISO 8601 string
97
+ # @return [Time, nil] Parsed Time object
98
+ def parse_date(value)
99
+ return nil unless value.is_a?(String)
100
+
101
+ Time.parse(value)
102
+ rescue ArgumentError
103
+ nil
104
+ end
105
+
106
+ private
107
+
108
+ # @param key [Symbol, String] Key to convert
109
+ # @return [String] camelCase key
110
+ def convert_key_to_camel(key)
111
+ sym_key = key.to_sym
112
+ return SNAKE_TO_CAMEL_MAP[sym_key].to_s if SNAKE_TO_CAMEL_MAP.key?(sym_key)
113
+
114
+ key.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }
115
+ end
116
+
117
+ # @param key [Symbol, String] Key to convert
118
+ # @return [Symbol] snake_case key
119
+ def convert_key_to_snake(key)
120
+ str_key = key.to_s
121
+ sym_key = key.to_sym
122
+ return CAMEL_TO_SNAKE_MAP[sym_key] if CAMEL_TO_SNAKE_MAP.key?(sym_key)
123
+
124
+ str_key.gsub(/([A-Z])/) { "_#{::Regexp.last_match(1).downcase}" }.to_sym
125
+ end
126
+
127
+ # @param value [Object] Value to convert for API
128
+ # @return [Object] Converted value
129
+ def convert_value_to_api(value)
130
+ case value
131
+ when Hash
132
+ to_api_params(value)
133
+ when Array
134
+ value.map { |v| convert_value_to_api(v) }
135
+ when Time, Date
136
+ format_date(value)
137
+ when Symbol
138
+ value.to_s.gsub("_", " ")
139
+ else
140
+ value
141
+ end
142
+ end
143
+
144
+ # @param value [Object] Value from API response
145
+ # @return [Object] Converted value
146
+ def convert_value_from_api(value)
147
+ case value
148
+ when Hash
149
+ from_api_response(value)
150
+ when Array
151
+ value.map { |v| convert_value_from_api(v) }
152
+ else
153
+ value
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ require "openssl"
6
+ require "json"
7
+
8
+ module Exa
9
+ module Utils
10
+ # Webhook signature verification and payload handling for n8n, Zapier, and direct integrations
11
+ #
12
+ # This module provides utilities for verifying Exa webhook signatures and
13
+ # parsing webhook payloads. It can be used with any webhook receiver including
14
+ # Rails, Sinatra, n8n, Zapier, or custom Ruby applications.
15
+ #
16
+ # @example Verify a webhook in Rails
17
+ # class WebhooksController < ApplicationController
18
+ # skip_before_action :verify_authenticity_token
19
+ #
20
+ # def exa
21
+ # raw_body = request.raw_post
22
+ # signature = request.headers["X-Exa-Signature"]
23
+ # timestamp = request.headers["X-Exa-Timestamp"]
24
+ #
25
+ # if Exa::Utils::WebhookHandler.verify_signature(raw_body, signature, secret: ENV["EXA_WEBHOOK_SECRET"])
26
+ # event = Exa::Utils::WebhookHandler.parse_event(raw_body)
27
+ # process_event(event)
28
+ # head :ok
29
+ # else
30
+ # head :unauthorized
31
+ # end
32
+ # end
33
+ # end
34
+ #
35
+ # @example Verify in Sinatra
36
+ # post "/webhooks/exa" do
37
+ # raw_body = request.body.read
38
+ # signature = request.env["HTTP_X_EXA_SIGNATURE"]
39
+ #
40
+ # if Exa::Utils::WebhookHandler.verify_signature(raw_body, signature, secret: ENV["EXA_WEBHOOK_SECRET"])
41
+ # event = Exa::Utils::WebhookHandler.parse_event(raw_body)
42
+ # # Process event
43
+ # status 200
44
+ # else
45
+ # status 401
46
+ # end
47
+ # end
48
+ class WebhookHandler
49
+ # Default tolerance for timestamp validation (5 minutes)
50
+ DEFAULT_TOLERANCE = 300
51
+
52
+ # Signature header name
53
+ SIGNATURE_HEADER = "X-Exa-Signature"
54
+
55
+ # Timestamp header name
56
+ TIMESTAMP_HEADER = "X-Exa-Timestamp"
57
+
58
+ class << self
59
+ # Verifies the webhook signature using HMAC-SHA256
60
+ #
61
+ # @param payload [String] Raw request body
62
+ # @param signature [String] Signature from X-Exa-Signature header
63
+ # @param secret [String] Webhook secret from webhook creation
64
+ # @param timestamp [String, nil] Optional timestamp from X-Exa-Timestamp header
65
+ # @param tolerance [Integer] Maximum age of webhook in seconds (default: 300)
66
+ #
67
+ # @return [Boolean] true if signature is valid
68
+ #
69
+ # @example Basic verification
70
+ # WebhookHandler.verify_signature(payload, signature, secret: "whsec_...")
71
+ #
72
+ # @example With timestamp validation
73
+ # WebhookHandler.verify_signature(
74
+ # payload,
75
+ # signature,
76
+ # secret: "whsec_...",
77
+ # timestamp: request.headers["X-Exa-Timestamp"],
78
+ # tolerance: 300
79
+ # )
80
+ def verify_signature(payload, signature, secret:, timestamp: nil, tolerance: DEFAULT_TOLERANCE)
81
+ return false if payload.nil? || payload.empty?
82
+ return false if signature.nil? || signature.empty?
83
+ return false if secret.nil? || secret.empty?
84
+
85
+ # Validate timestamp if provided
86
+ if timestamp
87
+ return false unless valid_timestamp?(timestamp, tolerance)
88
+ end
89
+
90
+ # Calculate expected signature
91
+ expected = compute_signature(payload, secret, timestamp)
92
+
93
+ # Use secure comparison to prevent timing attacks
94
+ secure_compare(expected, signature)
95
+ end
96
+
97
+ # Computes HMAC-SHA256 signature for a payload
98
+ #
99
+ # @param payload [String] Raw request body
100
+ # @param secret [String] Webhook secret
101
+ # @param timestamp [String, nil] Optional timestamp to include
102
+ #
103
+ # @return [String] Hex-encoded signature
104
+ def compute_signature(payload, secret, timestamp = nil)
105
+ # If timestamp provided, prepend it to payload (common pattern)
106
+ signed_payload = timestamp ? "#{timestamp}.#{payload}" : payload
107
+
108
+ OpenSSL::HMAC.hexdigest(
109
+ OpenSSL::Digest.new("sha256"),
110
+ secret,
111
+ signed_payload
112
+ )
113
+ end
114
+
115
+ # Parses a webhook payload into an Event resource
116
+ #
117
+ # @param payload [String] Raw JSON request body
118
+ # @return [Exa::Resources::Event] Parsed event
119
+ #
120
+ # @raise [Exa::InvalidRequestError] if payload is invalid JSON
121
+ def parse_event(payload)
122
+ data = JSON.parse(payload)
123
+ data_symbolized = symbolize_keys(data)
124
+
125
+ Resources::Event.new(
126
+ ParameterConverter.from_api_response(data_symbolized)
127
+ )
128
+ rescue JSON::ParserError => e
129
+ raise InvalidRequestError, "Invalid webhook payload: #{e.message}"
130
+ end
131
+
132
+ # Parses headers from different framework formats
133
+ #
134
+ # @param headers [Hash] Request headers
135
+ # @return [Hash] Normalized headers with signature and timestamp
136
+ #
137
+ # @example Rails headers
138
+ # WebhookHandler.extract_headers(request.headers)
139
+ # # => { signature: "abc123", timestamp: "1703001234" }
140
+ #
141
+ # @example Rack headers
142
+ # WebhookHandler.extract_headers(env)
143
+ # # => { signature: "abc123", timestamp: "1703001234" }
144
+ def extract_headers(headers)
145
+ signature = headers["X-Exa-Signature"] ||
146
+ headers["HTTP_X_EXA_SIGNATURE"] ||
147
+ headers["x-exa-signature"] ||
148
+ headers[:x_exa_signature]
149
+
150
+ timestamp = headers["X-Exa-Timestamp"] ||
151
+ headers["HTTP_X_EXA_TIMESTAMP"] ||
152
+ headers["x-exa-timestamp"] ||
153
+ headers[:x_exa_timestamp]
154
+
155
+ { signature: signature, timestamp: timestamp }
156
+ end
157
+
158
+ # Convenience method to verify and parse in one call
159
+ #
160
+ # @param payload [String] Raw request body
161
+ # @param headers [Hash] Request headers
162
+ # @param secret [String] Webhook secret
163
+ # @param tolerance [Integer] Maximum age of webhook in seconds
164
+ #
165
+ # @return [Exa::Resources::Event] Parsed event if verification succeeds
166
+ #
167
+ # @raise [Exa::AuthenticationError] if signature verification fails
168
+ # @raise [Exa::InvalidRequestError] if payload is invalid
169
+ def construct_event(payload, headers, secret:, tolerance: DEFAULT_TOLERANCE)
170
+ extracted = extract_headers(headers)
171
+
172
+ unless verify_signature(
173
+ payload,
174
+ extracted[:signature],
175
+ secret: secret,
176
+ timestamp: extracted[:timestamp],
177
+ tolerance: tolerance
178
+ )
179
+ raise AuthenticationError, "Webhook signature verification failed"
180
+ end
181
+
182
+ parse_event(payload)
183
+ end
184
+
185
+ private
186
+
187
+ # Validates that timestamp is within tolerance
188
+ #
189
+ # @param timestamp [String] Unix timestamp string
190
+ # @param tolerance [Integer] Maximum age in seconds
191
+ # @return [Boolean] true if timestamp is valid
192
+ def valid_timestamp?(timestamp, tolerance)
193
+ ts = Integer(timestamp)
194
+ age = Time.now.to_i - ts
195
+ age.abs <= tolerance
196
+ rescue ArgumentError, TypeError
197
+ false
198
+ end
199
+
200
+ # Secure string comparison to prevent timing attacks
201
+ #
202
+ # @param a [String] First string
203
+ # @param b [String] Second string
204
+ # @return [Boolean] true if strings are equal
205
+ def secure_compare(a, b)
206
+ return false if a.nil? || b.nil?
207
+ return false unless a.bytesize == b.bytesize
208
+
209
+ # Use OpenSSL's secure comparison if available (Ruby 2.5+)
210
+ if defined?(OpenSSL.secure_compare)
211
+ OpenSSL.secure_compare(a, b)
212
+ else
213
+ # Fallback for older Ruby
214
+ result = 0
215
+ a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
216
+ result.zero?
217
+ end
218
+ end
219
+
220
+ # Recursively symbolizes hash keys
221
+ #
222
+ # @param obj [Object] Object to process
223
+ # @return [Object] Object with symbolized keys
224
+ def symbolize_keys(obj)
225
+ case obj
226
+ when Hash
227
+ obj.each_with_object({}) do |(key, value), result|
228
+ result[key.to_sym] = symbolize_keys(value)
229
+ end
230
+ when Array
231
+ obj.map { |item| symbolize_keys(item) }
232
+ else
233
+ obj
234
+ end
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ VERSION = "1.0.0"
7
+ end