api-transformer 0.1.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 +14 -0
- data/.overcommit.yml +27 -0
- data/.rubocop.yml +9 -0
- data/.rubocop_todo.yml +15 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +358 -0
- data/Rakefile +9 -0
- data/api-transformer.gemspec +31 -0
- data/examples/ip_server.rb +19 -0
- data/lib/api/transformer.rb +1 -0
- data/lib/api_transformer/backend_request.rb +79 -0
- data/lib/api_transformer/backend_request_sender.rb +56 -0
- data/lib/api_transformer/backend_response.rb +62 -0
- data/lib/api_transformer/endpoint.rb +120 -0
- data/lib/api_transformer/errors.rb +4 -0
- data/lib/api_transformer/frontend_response.rb +52 -0
- data/lib/api_transformer/frontend_response_builder.rb +138 -0
- data/lib/api_transformer/params.rb +8 -0
- data/lib/api_transformer/rack/cookie_params.rb +38 -0
- data/lib/api_transformer/routes.rb +71 -0
- data/lib/api_transformer/version.rb +4 -0
- data/lib/api_transformer.rb +147 -0
- data/spec/server_spec.rb +746 -0
- data/spec/spec_helper.rb +3 -0
- metadata +199 -0
@@ -0,0 +1,62 @@
|
|
1
|
+
require "json"
|
2
|
+
require "hashie"
|
3
|
+
|
4
|
+
module ApiTransformer
|
5
|
+
# Container for backend response data
|
6
|
+
class BackendResponse
|
7
|
+
# Subclasses Hash so that it can interchangeably use string or symbol access
|
8
|
+
class Data < Hash
|
9
|
+
include Hashie::Extensions::IndifferentAccess
|
10
|
+
end
|
11
|
+
|
12
|
+
attr_reader :body
|
13
|
+
|
14
|
+
def initialize(http)
|
15
|
+
@http = http
|
16
|
+
@body = ""
|
17
|
+
|
18
|
+
@http.stream { |chunk| @body += chunk }
|
19
|
+
end
|
20
|
+
|
21
|
+
def success?
|
22
|
+
status >= 200 && status < 300
|
23
|
+
end
|
24
|
+
|
25
|
+
def status
|
26
|
+
@http.response_header.http_status
|
27
|
+
end
|
28
|
+
|
29
|
+
def json
|
30
|
+
@data ||= Data.try_convert(JSON.parse(@body))
|
31
|
+
rescue JSON::ParserError
|
32
|
+
{}
|
33
|
+
end
|
34
|
+
|
35
|
+
def [](key)
|
36
|
+
json[key]
|
37
|
+
end
|
38
|
+
|
39
|
+
def cookies
|
40
|
+
pairs = @http.response_header["SET_COOKIE"].split("\s*;\s*")
|
41
|
+
data = pairs.reduce({}) do |hash, pair|
|
42
|
+
key, value = pair.split("=")
|
43
|
+
hash.merge(key => value)
|
44
|
+
end
|
45
|
+
|
46
|
+
Data.try_convert(data)
|
47
|
+
end
|
48
|
+
|
49
|
+
def stream(&block)
|
50
|
+
if block
|
51
|
+
block.call(@body)
|
52
|
+
|
53
|
+
@http.stream(&block)
|
54
|
+
EM::Synchrony::Iterator.new([block]).each do |_, iter|
|
55
|
+
@http.callback { iter.next }
|
56
|
+
end
|
57
|
+
else
|
58
|
+
fail "a block is required when streaming"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module ApiTransformer
|
2
|
+
# Processes endpoint blocks
|
3
|
+
class Endpoint
|
4
|
+
def initialize(base_url, env, route)
|
5
|
+
@base_url = base_url
|
6
|
+
@env = env
|
7
|
+
@route = route
|
8
|
+
|
9
|
+
@backend_request_senders = []
|
10
|
+
@backend_responses = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
def run
|
14
|
+
fail "Endpoints must define a response block" unless @builder
|
15
|
+
|
16
|
+
send_requests
|
17
|
+
send_response
|
18
|
+
end
|
19
|
+
|
20
|
+
def complete
|
21
|
+
if @error
|
22
|
+
complete_error
|
23
|
+
else
|
24
|
+
complete_success
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def request(name, options = {}, &block)
|
29
|
+
return unless !options.key?(:when) || options[:when]
|
30
|
+
|
31
|
+
@backend_request_senders << BackendRequestSender.new(
|
32
|
+
name,
|
33
|
+
options.merge(base_url: @base_url),
|
34
|
+
block,
|
35
|
+
@env["client-headers"],
|
36
|
+
@route[:helper_blocks]
|
37
|
+
)
|
38
|
+
end
|
39
|
+
|
40
|
+
def response(options = {}, &block)
|
41
|
+
@builder = FrontendResponseBuilder.new(
|
42
|
+
@env,
|
43
|
+
options,
|
44
|
+
block,
|
45
|
+
@route
|
46
|
+
)
|
47
|
+
end
|
48
|
+
|
49
|
+
def stream(value)
|
50
|
+
@stream = value
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def send_requests
|
56
|
+
@multi = EventMachine::MultiRequest.new
|
57
|
+
iterator = EM::Synchrony::Iterator.new(@backend_request_senders)
|
58
|
+
|
59
|
+
iterator.each do |sender, iter|
|
60
|
+
send_request(sender, iter)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def send_request(sender, iter)
|
65
|
+
http = sender.send(@backend_responses)
|
66
|
+
@backend_responses[sender.request_name] = BackendResponse.new(http)
|
67
|
+
@multi.add sender.request_name, http
|
68
|
+
http.headers { iter.next }
|
69
|
+
rescue ApiTransformer::RequestError => e
|
70
|
+
@error = e
|
71
|
+
iter.next
|
72
|
+
end
|
73
|
+
|
74
|
+
def send_response
|
75
|
+
if @stream
|
76
|
+
status_and_headers
|
77
|
+
else
|
78
|
+
if @backend_request_senders.any? && !@error
|
79
|
+
EM::Synchrony::Iterator.new([@multi]).each do |multi, iter|
|
80
|
+
multi.callback { iter.next }
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
status_and_headers
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def status_and_headers
|
89
|
+
if @error
|
90
|
+
[400, {}]
|
91
|
+
else
|
92
|
+
@builder.status_and_headers(@backend_responses)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def complete_success
|
97
|
+
EM.synchrony do
|
98
|
+
@builder.do_streaming
|
99
|
+
|
100
|
+
if @backend_request_senders.any?
|
101
|
+
@multi.callback do
|
102
|
+
close
|
103
|
+
end
|
104
|
+
else
|
105
|
+
close
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def complete_error
|
111
|
+
@builder.stream_write @error
|
112
|
+
close
|
113
|
+
end
|
114
|
+
|
115
|
+
def close
|
116
|
+
@builder.send_body
|
117
|
+
@builder.stream_close
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require "json"
|
2
|
+
|
3
|
+
module ApiTransformer
|
4
|
+
# Container for frontend response data
|
5
|
+
class FrontendResponse
|
6
|
+
attr_accessor :status
|
7
|
+
attr_writer :body, :content_type
|
8
|
+
|
9
|
+
def set(key, value)
|
10
|
+
@hash ||= {}
|
11
|
+
@hash[key] = value
|
12
|
+
end
|
13
|
+
|
14
|
+
def set_cookie(key, value)
|
15
|
+
@cookies ||= {}
|
16
|
+
@cookies[key] = value
|
17
|
+
end
|
18
|
+
|
19
|
+
def body
|
20
|
+
@hash && @hash.to_json || @body
|
21
|
+
end
|
22
|
+
|
23
|
+
def headers
|
24
|
+
cookie_header.merge(content_type_header)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def cookie_header
|
30
|
+
if @cookies
|
31
|
+
pairs = @cookies.map { |key, value| "#{key}=#{value}" }
|
32
|
+
{ "Set-Cookie" => pairs.join("; ") }
|
33
|
+
else
|
34
|
+
{}
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def content_type_header
|
39
|
+
{ "Content-Type" => content_type }
|
40
|
+
end
|
41
|
+
|
42
|
+
def content_type
|
43
|
+
if @content_type
|
44
|
+
@content_type
|
45
|
+
elsif @hash
|
46
|
+
"application/json"
|
47
|
+
else
|
48
|
+
"text/plain"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
module ApiTransformer
|
2
|
+
# Processes the response block
|
3
|
+
class FrontendResponseBuilder
|
4
|
+
attr_reader :success
|
5
|
+
|
6
|
+
def initialize(env, options, block, route)
|
7
|
+
@env = env
|
8
|
+
@options = options
|
9
|
+
@block = block
|
10
|
+
@route = route
|
11
|
+
|
12
|
+
@frontend_response = FrontendResponse.new
|
13
|
+
end
|
14
|
+
|
15
|
+
def status_and_headers(backend_responses)
|
16
|
+
@route[:helper_blocks].each { |block| instance_eval(&block) }
|
17
|
+
instance_exec(backend_responses, &@block)
|
18
|
+
add_failure_handlers(backend_responses)
|
19
|
+
handle_success_or_failure(backend_responses)
|
20
|
+
|
21
|
+
[@frontend_response.status, @frontend_response.headers]
|
22
|
+
end
|
23
|
+
|
24
|
+
def body(value)
|
25
|
+
@frontend_response.body = value
|
26
|
+
end
|
27
|
+
|
28
|
+
def streaming?
|
29
|
+
options[:streaming]
|
30
|
+
end
|
31
|
+
|
32
|
+
def success(value = nil, &block)
|
33
|
+
@frontend_response.status = value
|
34
|
+
@success = block
|
35
|
+
end
|
36
|
+
|
37
|
+
def failure(run = true, &block)
|
38
|
+
run && !@failure && @failure = block
|
39
|
+
end
|
40
|
+
|
41
|
+
def status(value)
|
42
|
+
@frontend_response.status = value
|
43
|
+
end
|
44
|
+
|
45
|
+
def content_type(value)
|
46
|
+
@frontend_response.content_type = value
|
47
|
+
end
|
48
|
+
|
49
|
+
def cookie(key, value)
|
50
|
+
@frontend_response.set_cookie(key, value)
|
51
|
+
end
|
52
|
+
|
53
|
+
def attribute(key, value)
|
54
|
+
@frontend_response.set key, value
|
55
|
+
end
|
56
|
+
|
57
|
+
def object(key, klass, object_data)
|
58
|
+
@frontend_response.set key, object_hash(klass, object_data)
|
59
|
+
end
|
60
|
+
|
61
|
+
def array(key, klass, array_data)
|
62
|
+
value = array_data.map { |data| object_hash(klass, data) }
|
63
|
+
@frontend_response.set key, value
|
64
|
+
end
|
65
|
+
|
66
|
+
def send_body
|
67
|
+
stream_write @frontend_response.body
|
68
|
+
end
|
69
|
+
|
70
|
+
def stream(&block)
|
71
|
+
if block
|
72
|
+
@streaming_block = block
|
73
|
+
else
|
74
|
+
fail "a block is required when streaming"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def do_streaming
|
79
|
+
@streaming_block.call if @streaming_block
|
80
|
+
end
|
81
|
+
|
82
|
+
def stream_write(data)
|
83
|
+
@env.stream_send data
|
84
|
+
end
|
85
|
+
|
86
|
+
def stream_close
|
87
|
+
@env.stream_close
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
def add_failure_handlers(backend_responses)
|
93
|
+
@route[:failure_handlers].each do |block|
|
94
|
+
backend_responses.values.each do |backend_response|
|
95
|
+
instance_exec(backend_response, &block)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def handle_success_or_failure(backend_responses)
|
101
|
+
if @failure
|
102
|
+
handle_failure
|
103
|
+
elsif backend_responses.all? { |_, resp| resp.success? }
|
104
|
+
handle_success
|
105
|
+
else
|
106
|
+
unhandled_failure(backend_responses)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def handle_failure
|
111
|
+
set_failure_defaults
|
112
|
+
instance_eval(&@failure) if @failure
|
113
|
+
end
|
114
|
+
|
115
|
+
def handle_success
|
116
|
+
set_success_defaults
|
117
|
+
instance_eval(&@success) if @success
|
118
|
+
end
|
119
|
+
|
120
|
+
def unhandled_failure(backend_responses)
|
121
|
+
first_failure = backend_responses.values.first { |resp| !resp.success }
|
122
|
+
status first_failure.status
|
123
|
+
@frontend_response.body = first_failure.body
|
124
|
+
end
|
125
|
+
|
126
|
+
def object_hash(klass, data)
|
127
|
+
klass.new(data).to_hash
|
128
|
+
end
|
129
|
+
|
130
|
+
def set_success_defaults
|
131
|
+
@frontend_response.status ||= 200
|
132
|
+
end
|
133
|
+
|
134
|
+
def set_failure_defaults
|
135
|
+
@frontend_response.status ||= 400
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module ApiTransformer
|
2
|
+
module Rack
|
3
|
+
# Parses out cookies and make them available as request parameters
|
4
|
+
class CookieParams
|
5
|
+
# Cookie string parser
|
6
|
+
module Parser
|
7
|
+
def cookie_params(env)
|
8
|
+
cookie_string = env["HTTP_COOKIE"]
|
9
|
+
|
10
|
+
if cookie_string
|
11
|
+
cookie_pairs = cookie_string.split(/\s*;\s*/)
|
12
|
+
|
13
|
+
cookie_pairs.reduce({}) do |hash, pair|
|
14
|
+
key, value = pair.split("=")
|
15
|
+
hash.merge(key => value)
|
16
|
+
end
|
17
|
+
else
|
18
|
+
{}
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
include Goliath::Rack::Validator
|
24
|
+
include Parser
|
25
|
+
|
26
|
+
def initialize(app)
|
27
|
+
@app = app
|
28
|
+
end
|
29
|
+
|
30
|
+
def call(env)
|
31
|
+
Goliath::Rack::Validator.safely(env) do
|
32
|
+
env["params"].merge!(cookie_params(env))
|
33
|
+
@app.call(env)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
require "ostruct"
|
2
|
+
|
3
|
+
module ApiTransformer
|
4
|
+
# Collection of routes
|
5
|
+
class Routes
|
6
|
+
# A route
|
7
|
+
class Route < OpenStruct; end
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@routes = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
def add(args)
|
14
|
+
pattern, path_params = parse_path_definition(args[:path])
|
15
|
+
|
16
|
+
route = Route.new(
|
17
|
+
path_params: path_params,
|
18
|
+
options: args[:options],
|
19
|
+
block: args[:block],
|
20
|
+
failure_handlers: args[:failure_handlers],
|
21
|
+
helper_blocks: args[:helper_blocks]
|
22
|
+
)
|
23
|
+
|
24
|
+
store_route(args[:method], pattern, route)
|
25
|
+
end
|
26
|
+
|
27
|
+
def find(raw_method, path)
|
28
|
+
method = raw_method.downcase.to_sym
|
29
|
+
return unless @routes[method]
|
30
|
+
|
31
|
+
pattern, route = @routes[method].find do |pattern, _|
|
32
|
+
path.match(pattern)
|
33
|
+
end
|
34
|
+
|
35
|
+
[route, path_params(path, route, pattern)]
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def parse_path_definition(path)
|
41
|
+
parts = path.split(/\//)
|
42
|
+
|
43
|
+
pattern_string = parts
|
44
|
+
.map { |part| part.match(/^\:/) ? "(.*?)" : part }
|
45
|
+
.join("\/")
|
46
|
+
.tap { |inner| "^#{inner}$" }
|
47
|
+
|
48
|
+
pattern = Regexp.new("^#{pattern_string}$")
|
49
|
+
|
50
|
+
params = parts
|
51
|
+
.select { |part| part.match(/^\:/) }
|
52
|
+
.map { |part| part.gsub(/^\:/, "").to_sym }
|
53
|
+
|
54
|
+
[pattern, params]
|
55
|
+
end
|
56
|
+
|
57
|
+
def path_params(path, route, pattern)
|
58
|
+
if pattern
|
59
|
+
matches = path.match(pattern)[1..-1]
|
60
|
+
Hash[route[:path_params].zip(matches)]
|
61
|
+
else
|
62
|
+
{}
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def store_route(method, pattern, route)
|
67
|
+
@routes[method] ||= {}
|
68
|
+
@routes[method][pattern] = route
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
require "goliath"
|
2
|
+
|
3
|
+
require_relative "./api_transformer/endpoint"
|
4
|
+
require_relative "./api_transformer/errors"
|
5
|
+
require_relative "./api_transformer/backend_request"
|
6
|
+
require_relative "./api_transformer/backend_request_sender"
|
7
|
+
require_relative "./api_transformer/backend_response"
|
8
|
+
require_relative "./api_transformer/frontend_response"
|
9
|
+
require_relative "./api_transformer/frontend_response_builder"
|
10
|
+
require_relative "./api_transformer/params"
|
11
|
+
require_relative "./api_transformer/routes"
|
12
|
+
require_relative "./api_transformer/rack/cookie_params"
|
13
|
+
|
14
|
+
module ApiTransformer
|
15
|
+
# Inherit from this class to implement an API transformation server:
|
16
|
+
#
|
17
|
+
# class ExampleServer < ApiTransformer::Server
|
18
|
+
# base_url "http://ip.jsontest.com/"
|
19
|
+
#
|
20
|
+
# get "/ip" do |params|
|
21
|
+
# request :ip do
|
22
|
+
# path "/"
|
23
|
+
# method :get
|
24
|
+
# end
|
25
|
+
#
|
26
|
+
# response do |data|
|
27
|
+
# success do
|
28
|
+
# status 200
|
29
|
+
# attribute :your_ip, data[:ip][:ip]
|
30
|
+
# end
|
31
|
+
# end
|
32
|
+
# end
|
33
|
+
# end
|
34
|
+
#
|
35
|
+
class Server < Goliath::API
|
36
|
+
use Goliath::Rack::Params
|
37
|
+
use ApiTransformer::Rack::CookieParams
|
38
|
+
|
39
|
+
def on_headers(env, headers)
|
40
|
+
env["client-headers"] = headers
|
41
|
+
end
|
42
|
+
|
43
|
+
def response(env)
|
44
|
+
path = env[Goliath::Request::REQUEST_PATH]
|
45
|
+
route, path_params = @@routes.find(env["REQUEST_METHOD"], path)
|
46
|
+
|
47
|
+
if route
|
48
|
+
run_route(route, path_params, env)
|
49
|
+
else
|
50
|
+
[404, {}, "nope"]
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def run_route(route, path_params, env)
|
55
|
+
indifferent_params = Params.try_convert(params.merge(path_params))
|
56
|
+
endpoint = Endpoint.new(@@base_url, env, route)
|
57
|
+
headers = env["client-headers"]
|
58
|
+
|
59
|
+
route[:helper_blocks].each { |block| endpoint.instance_eval(&block) }
|
60
|
+
endpoint.instance_exec(indifferent_params, headers, &route[:block])
|
61
|
+
|
62
|
+
status, headers = endpoint.run
|
63
|
+
|
64
|
+
EM.next_tick do
|
65
|
+
endpoint.complete
|
66
|
+
end
|
67
|
+
|
68
|
+
streaming_response(status, headers)
|
69
|
+
end
|
70
|
+
|
71
|
+
class << self
|
72
|
+
def inherited(klass)
|
73
|
+
klass.use Goliath::Rack::Params
|
74
|
+
end
|
75
|
+
|
76
|
+
def base_url(url)
|
77
|
+
@@base_url = url
|
78
|
+
end
|
79
|
+
|
80
|
+
def namespace(path)
|
81
|
+
prior = [@namespace, failure_handlers.dup, helper_blocks.dup]
|
82
|
+
@namespace = @namespace.to_s + path
|
83
|
+
|
84
|
+
yield
|
85
|
+
|
86
|
+
@namespace, @failure_handlers, @helper_blocks = prior
|
87
|
+
end
|
88
|
+
|
89
|
+
def unhandled_failures(&block)
|
90
|
+
failure_handlers.unshift(block)
|
91
|
+
end
|
92
|
+
|
93
|
+
def helpers(&block)
|
94
|
+
helper_blocks.unshift(block)
|
95
|
+
end
|
96
|
+
|
97
|
+
def get(path = "", options = {}, &block)
|
98
|
+
add_route(:get, path, options, block)
|
99
|
+
end
|
100
|
+
|
101
|
+
def post(path = "", options = {}, &block)
|
102
|
+
add_route(:post, path, options, block)
|
103
|
+
end
|
104
|
+
|
105
|
+
def put(path = "", options = {}, &block)
|
106
|
+
add_route(:put, path, options, block)
|
107
|
+
end
|
108
|
+
|
109
|
+
def delete(path = "", options = {}, &block)
|
110
|
+
add_route(:delete, path, options, block)
|
111
|
+
end
|
112
|
+
|
113
|
+
def reset_routes
|
114
|
+
@@routes = Routes.new
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def add_route(method, path, options, block)
|
120
|
+
full_path = "#{@namespace}#{path}"
|
121
|
+
|
122
|
+
@@routes ||= Routes.new
|
123
|
+
@@routes.add(method: method, path: full_path, options: options,
|
124
|
+
block: block, failure_handlers: failure_handlers,
|
125
|
+
helper_blocks: helper_blocks)
|
126
|
+
end
|
127
|
+
|
128
|
+
def failure_handlers
|
129
|
+
@failure_handlers ||= []
|
130
|
+
end
|
131
|
+
|
132
|
+
def helper_blocks
|
133
|
+
@helper_blocks ||= []
|
134
|
+
end
|
135
|
+
|
136
|
+
def server
|
137
|
+
superclass
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
private
|
142
|
+
|
143
|
+
def server
|
144
|
+
self.class
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|