active_endpoint 0.2.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.
- checksums.yaml +7 -0
- data/.gitignore +55 -0
- data/.rspec +2 -0
- data/.rubocop.yml +60 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +25 -0
- data/Gemfile.lock +140 -0
- data/LICENSE +21 -0
- data/README.md +233 -0
- data/Rakefile +6 -0
- data/active_endpoint.gemspec +28 -0
- data/app/assets/config/active_endpoint_manifest.js +2 -0
- data/app/assets/javascripts/active_endpoint/application.js +2 -0
- data/app/assets/stylesheets/active_endpoint/application.css +10 -0
- data/app/controllers/active_endpoint/application_controller.rb +5 -0
- data/app/controllers/active_endpoint/dashboard_controller.rb +7 -0
- data/app/controllers/active_endpoint/probes_controller.rb +22 -0
- data/app/controllers/active_endpoint/unregistred_probes_controller.rb +14 -0
- data/app/helpers/active_endpoint/application_helper.rb +105 -0
- data/app/models/active_endpoint/application_record.rb +5 -0
- data/app/models/active_endpoint/probe.rb +52 -0
- data/app/models/active_endpoint/unregistred_probe.rb +3 -0
- data/app/views/active_endpoint/application/_flashes.html.erb +5 -0
- data/app/views/active_endpoint/application/_header.html.erb +18 -0
- data/app/views/active_endpoint/dashboard/_blacklist.html.erb +57 -0
- data/app/views/active_endpoint/dashboard/_constraints.html.erb +117 -0
- data/app/views/active_endpoint/dashboard/_settings.html.erb +13 -0
- data/app/views/active_endpoint/dashboard/_tags.html.erb +20 -0
- data/app/views/active_endpoint/dashboard/index.html.erb +8 -0
- data/app/views/active_endpoint/probes/index.html.erb +28 -0
- data/app/views/active_endpoint/probes/show.html.erb +43 -0
- data/app/views/active_endpoint/probes/show_response.html.erb +9 -0
- data/app/views/active_endpoint/unregistred_probes/index.html.erb +28 -0
- data/app/views/layouts/active_endpoint/application.html.erb +39 -0
- data/bin/console +14 -0
- data/bin/rails +13 -0
- data/bin/setup +8 -0
- data/config/routes.rb +8 -0
- data/lib/active_endpoint.rb +60 -0
- data/lib/active_endpoint/concerns/configurable.rb +29 -0
- data/lib/active_endpoint/concerns/constraintable.rb +51 -0
- data/lib/active_endpoint/concerns/optionable.rb +41 -0
- data/lib/active_endpoint/concerns/rails_routable.rb +49 -0
- data/lib/active_endpoint/engine.rb +15 -0
- data/lib/active_endpoint/extentions/active_record.rb +30 -0
- data/lib/active_endpoint/logger.rb +28 -0
- data/lib/active_endpoint/proxy.rb +64 -0
- data/lib/active_endpoint/rails/middleware.rb +17 -0
- data/lib/active_endpoint/rails/railtie.rb +13 -0
- data/lib/active_endpoint/request.rb +79 -0
- data/lib/active_endpoint/response.rb +17 -0
- data/lib/active_endpoint/routes/blacklist.rb +67 -0
- data/lib/active_endpoint/routes/cache/proxy.rb +22 -0
- data/lib/active_endpoint/routes/cache/proxy/redis_store_proxy.rb +41 -0
- data/lib/active_endpoint/routes/cache/store.rb +73 -0
- data/lib/active_endpoint/routes/constraint_rule.rb +38 -0
- data/lib/active_endpoint/routes/constraints.rb +81 -0
- data/lib/active_endpoint/routes/matcher.rb +51 -0
- data/lib/active_endpoint/routes/momento.rb +81 -0
- data/lib/active_endpoint/storage.rb +112 -0
- data/lib/active_endpoint/tags.rb +15 -0
- data/lib/active_endpoint/version.rb +3 -0
- data/lib/generators/active_endpoint/install_generator.rb +35 -0
- data/lib/generators/templates/active_endpoint.rb +40 -0
- data/lib/generators/templates/migration.erb +30 -0
- metadata +109 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module ActiveEndpoint
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace ActiveEndpoint
|
|
4
|
+
|
|
5
|
+
initializer 'active_endpoint.assets.precompile' do |app|
|
|
6
|
+
app.config.assets.precompile += %w()
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
config.generators do |g|
|
|
10
|
+
g.orm :active_record
|
|
11
|
+
g.template_engine :erb
|
|
12
|
+
g.test_framework :rspec
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module ActiveEndpoint
|
|
2
|
+
module Extentions
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
METHODS = {
|
|
5
|
+
greater_than: '>',
|
|
6
|
+
greater_than_or_equal_to: '>=',
|
|
7
|
+
equal_to: '=',
|
|
8
|
+
less_than: '<',
|
|
9
|
+
less_than_or_equal_to: '<='
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
def tagged_as(tag, tags = ActiveEndpoint.tags.definition)
|
|
13
|
+
return [] unless tags.keys.include?(tag)
|
|
14
|
+
|
|
15
|
+
time_operators = tags[tag]
|
|
16
|
+
last_operator_index = time_operators.keys.length - 1
|
|
17
|
+
|
|
18
|
+
query = ''
|
|
19
|
+
time_operators.each_with_index do |(key, value), index|
|
|
20
|
+
operator = METHODS[key]
|
|
21
|
+
duration = (value.to_f / 1000).to_s
|
|
22
|
+
and_operator = last_operator_index == index ? '' : ' AND '
|
|
23
|
+
query << 'duration ' + operator + ' ' + duration + ' ' + and_operator
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
where(query)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module ActiveEndpoint
|
|
2
|
+
class Logger
|
|
3
|
+
class << self
|
|
4
|
+
def info(caller, info, force = nil)
|
|
5
|
+
return if force.nil? || !force
|
|
6
|
+
logger.info(message(caller, info))
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def debug(caller, message)
|
|
10
|
+
logger.debug(message(caller, message))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def error(caller, error)
|
|
14
|
+
logger.error(message(caller, error))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def logger
|
|
20
|
+
::Rails.logger
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def message(caller, message)
|
|
24
|
+
"ActiveEndpoint::Logger [#{caller}] - #{message}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module ActiveEndpoint
|
|
2
|
+
class Proxy
|
|
3
|
+
def initialize
|
|
4
|
+
@created_at = Time.now
|
|
5
|
+
@matcher = ActiveEndpoint::Routes::Matcher.new
|
|
6
|
+
@logger = ActiveEndpoint.logger
|
|
7
|
+
@notifier = ActiveSupport::Notifications
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def track(env, &block)
|
|
11
|
+
request = ActiveEndpoint::Request.new(env)
|
|
12
|
+
|
|
13
|
+
if ActiveEndpoint.log_debug_info
|
|
14
|
+
@logger.debug('ActiveEndpoint::Blacklist', ActiveEndpoint.blacklist.inspect)
|
|
15
|
+
@logger.debug('ActiveEndpoint::Constraints', ActiveEndpoint.constraints.inspect)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if @matcher.whitelisted?(request)
|
|
19
|
+
track_begin(request)
|
|
20
|
+
status, headers, response = yield block
|
|
21
|
+
track_end(response)
|
|
22
|
+
[status, headers, response]
|
|
23
|
+
else
|
|
24
|
+
register(request) if @matcher.unregistred?(request)
|
|
25
|
+
|
|
26
|
+
yield block
|
|
27
|
+
end
|
|
28
|
+
rescue => error
|
|
29
|
+
@logger.error(self.class, error)
|
|
30
|
+
|
|
31
|
+
yield block
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def track_begin(request)
|
|
37
|
+
@request = request.probe
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def track_end(response, finished_at = Time.now)
|
|
41
|
+
@response = ActiveEndpoint::Response.new(response).probe
|
|
42
|
+
@finished_at = finished_at
|
|
43
|
+
|
|
44
|
+
probe = {
|
|
45
|
+
created_at: @created_at,
|
|
46
|
+
finished_at: @finished_at,
|
|
47
|
+
request: @request,
|
|
48
|
+
response: @response
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@notifier.instrument('active_endpoint.tracked_probe', probe: probe) if @matcher.allow_account?(@request)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def register(request)
|
|
55
|
+
unregistred = {
|
|
56
|
+
created_at: @created_at,
|
|
57
|
+
finished_at: @finished_at,
|
|
58
|
+
request: request.probe
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@notifier.instrument('active_endpoint.unregistred_probe', probe: unregistred) if @matcher.allow_register?(request)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module ActiveEndpoint
|
|
2
|
+
module Rails
|
|
3
|
+
class Middleware
|
|
4
|
+
def initialize(app)
|
|
5
|
+
@app = app
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call(env)
|
|
9
|
+
dup._call(env, ActiveEndpoint::Proxy.new)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def _call(env, proxy)
|
|
13
|
+
proxy.track(env) { @app.call(env) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module ActiveEndpoint
|
|
2
|
+
module Rails
|
|
3
|
+
class Railtie < ::Rails::Railtie
|
|
4
|
+
initializer 'active_endpoint.configure_rails_initialization' do |app|
|
|
5
|
+
ActiveSupport.on_load(:active_record) do
|
|
6
|
+
ActiveRecord::Base.send(:extend, ::ActiveEndpoint::Extentions::ActiveRecord)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
app.middleware.insert(0, ActiveEndpoint::Rails::Middleware)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module ActiveEndpoint
|
|
2
|
+
class Request < Rack::Request
|
|
3
|
+
include RailsRoutable
|
|
4
|
+
|
|
5
|
+
def probe
|
|
6
|
+
{
|
|
7
|
+
base_url: base_url,
|
|
8
|
+
body: body,
|
|
9
|
+
content_charset: content_charset,
|
|
10
|
+
content_length: content_length,
|
|
11
|
+
content_type: content_type,
|
|
12
|
+
endpoint: endpoint,
|
|
13
|
+
fullpath: fullpath,
|
|
14
|
+
http_version: http_version,
|
|
15
|
+
http_connection: http_connection,
|
|
16
|
+
http_accept_encoding: http_accept_encoding,
|
|
17
|
+
http_accept_language: http_accept_language,
|
|
18
|
+
ip: ip,
|
|
19
|
+
media_type: media_type,
|
|
20
|
+
media_type_params: media_type_params,
|
|
21
|
+
method: method,
|
|
22
|
+
params: params,
|
|
23
|
+
path: path,
|
|
24
|
+
path_info: path_info,
|
|
25
|
+
pattern: pattern,
|
|
26
|
+
port: port,
|
|
27
|
+
protocol: protocol,
|
|
28
|
+
query_string: query_string,
|
|
29
|
+
request_method: request_method,
|
|
30
|
+
server_name: server_name,
|
|
31
|
+
ssl: ssl?,
|
|
32
|
+
url: url,
|
|
33
|
+
xhr: xhr?
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def method
|
|
38
|
+
request_method.downcase.to_sym
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def endpoint
|
|
42
|
+
rails_endpoint_name(rails_endpoint(self))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def http_accept_encoding
|
|
48
|
+
get_header('HTTP_ACCEPT_ENCODING')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def http_accept_language
|
|
52
|
+
get_header('HTTP_ACCEPT_LANGUAGE')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def http_connection
|
|
56
|
+
get_header('HTTP_CONNECTION')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def http_version
|
|
60
|
+
get_header('HTTP_VERSION')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def params
|
|
64
|
+
rails_request_params(self)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def pattern
|
|
68
|
+
rails_route_pattern(self)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def protocol
|
|
72
|
+
get_header('SERVER_PROTOCOL')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def server_name
|
|
76
|
+
get_header('SERVER_NAME')
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module ActiveEndpoint
|
|
2
|
+
module Routes
|
|
3
|
+
class Blacklist < Momento
|
|
4
|
+
def initialize
|
|
5
|
+
super(Array)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def add_endpoint(options)
|
|
11
|
+
@endpoints << fetch_endpoint(options)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def add_resources(options)
|
|
15
|
+
resources = fetch_resources(options)
|
|
16
|
+
actions = fetch_actions(options)
|
|
17
|
+
scope = fetch_scope(options)
|
|
18
|
+
|
|
19
|
+
if actions.present? && actions.any?
|
|
20
|
+
temp_actions = []
|
|
21
|
+
if resources.is_a?(Array)
|
|
22
|
+
resources.each do |controller_name|
|
|
23
|
+
actions.each { |action| temp_actions << "#{controller_name}##{action}" }
|
|
24
|
+
end
|
|
25
|
+
else
|
|
26
|
+
actions.each { |action| temp_actions << "#{resources}##{action}" }
|
|
27
|
+
end
|
|
28
|
+
@actions += apply(scope, temp_actions)
|
|
29
|
+
else
|
|
30
|
+
temp_resources = []
|
|
31
|
+
if resources.is_a?(Array)
|
|
32
|
+
resources.each { |resource| temp_resources << resource }
|
|
33
|
+
else
|
|
34
|
+
temp_resources << resources
|
|
35
|
+
end
|
|
36
|
+
@resources += apply(scope, temp_resources)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def add_scopes(options)
|
|
41
|
+
scope = fetch_scope(options)
|
|
42
|
+
@scopes << scope unless @scopes.include?(scope)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def present_endpoint?(request)
|
|
46
|
+
@endpoints.include?(request[:endpoint])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def present_resource?(request)
|
|
50
|
+
reduce_state(@resources, request)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def present_action?(request)
|
|
54
|
+
@actions.include?(request[:endpoint])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def present_scope?(request)
|
|
58
|
+
reduce_state(@scopes, request)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def apply(scope, collection)
|
|
62
|
+
return collection unless scope.present?
|
|
63
|
+
collection.map { |subject| "#{scope}/#{subject}" }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module ActiveEndpoint
|
|
2
|
+
module Routes
|
|
3
|
+
module Cache
|
|
4
|
+
module Proxy
|
|
5
|
+
class AdapterError < ::StandardError; end
|
|
6
|
+
|
|
7
|
+
CLIENTS = {
|
|
8
|
+
redis: 'RedisStoreProxy'
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
def self.build(adapter)
|
|
12
|
+
unless CLIENTS.keys.include?(adapter)
|
|
13
|
+
message "You try to use unsupported cache store adapter! #{adapter}\n"
|
|
14
|
+
raise ActiveEndpoint::Routes::Cache::Proxy::AdapterError.new(message)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
"ActiveEndpoint::Routes::Cache::Proxy::#{CLIENTS[adapter]}".constantize.new
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module ActiveEndpoint
|
|
2
|
+
module Routes
|
|
3
|
+
module Cache
|
|
4
|
+
module Proxy
|
|
5
|
+
class RedisStoreProxy
|
|
6
|
+
def initialize
|
|
7
|
+
@prefix = ActiveEndpoint.cache_prefix
|
|
8
|
+
@store = ::Redis::Store.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def read(unprefixed_key)
|
|
12
|
+
@store.get("#{@prefix}:#{unprefixed_key}")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def write(unprefixed_key, value, expires_in = nil)
|
|
16
|
+
store_key = store_key(unprefixed_key)
|
|
17
|
+
|
|
18
|
+
if expires_in.present?
|
|
19
|
+
@store.setex(store_key, expires_in, value)
|
|
20
|
+
else
|
|
21
|
+
@store.set(store_key, value)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def expires_in(unprefixed_key)
|
|
28
|
+
time = @store.ttl(store_key(unprefixed_key)).to_i
|
|
29
|
+
time == -1 || time == -2 ? 0 : time
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def store_key(unprefixed_key)
|
|
35
|
+
"#{@prefix}:#{unprefixed_key}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module ActiveEndpoint
|
|
2
|
+
module Routes
|
|
3
|
+
module Cache
|
|
4
|
+
class Store
|
|
5
|
+
delegate :read, to: :@store
|
|
6
|
+
delegate :write, to: :@store
|
|
7
|
+
delegate :expires_in, to: :@store
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@store = ActiveEndpoint::Routes::Cache::Proxy.build(ActiveEndpoint.cache_store_client)
|
|
11
|
+
@logger = ActiveEndpoint.logger
|
|
12
|
+
@notifier = ActiveSupport::Notifications
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def allow?(rule)
|
|
16
|
+
rule_cache_allow?(rule) && storage_cache_allow?(rule)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def unregistred?(probe)
|
|
20
|
+
path = probe[:path]
|
|
21
|
+
read(path).present? ? false : write(path, :unregistred)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def rule_cache_allow?(options)
|
|
27
|
+
cache_allow?(:rule, options)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def storage_cache_allow?(options)
|
|
31
|
+
cache_allow?(:storage, options) do |key, period|
|
|
32
|
+
expired = {
|
|
33
|
+
key: key,
|
|
34
|
+
period: period
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@notifier.instrument('active_endpoint.clean_expired', expired: expired)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def cache_allow?(prefix, options, &block)
|
|
42
|
+
|
|
43
|
+
key = "#{prefix}:#{options[:key]}"
|
|
44
|
+
constraints = options[prefix]
|
|
45
|
+
|
|
46
|
+
cache = read(key)
|
|
47
|
+
limit = cache.nil? ? cache : cache.to_i
|
|
48
|
+
period = expires_in(key)
|
|
49
|
+
|
|
50
|
+
if ActiveEndpoint.log_debug_info
|
|
51
|
+
@logger.debug('ActiveEndpoint::Cache::Store Prefix', prefix)
|
|
52
|
+
@logger.debug('ActiveEndpoint::Cache::Store Limit', limit)
|
|
53
|
+
@logger.debug('ActiveEndpoint::Cache::Store Period', period)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
limited = limit.present? && limit.zero?
|
|
57
|
+
expired = period.zero?
|
|
58
|
+
|
|
59
|
+
if limit.nil? && expired && block_given?
|
|
60
|
+
yield(options[:key], constraints[:period])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if limit.present? && !expired
|
|
64
|
+
return false if limited
|
|
65
|
+
write(key, limit - 1, period)
|
|
66
|
+
else
|
|
67
|
+
write(key, constraints[:limit] - 1, constraints[:period])
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|