openapi_first 0.13.3 → 0.14.3
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 +4 -4
- data/.rubocop.yml +3 -2
- data/CHANGELOG.md +41 -1
- data/Gemfile.lock +46 -73
- data/README.md +16 -8
- data/benchmarks/Gemfile.lock +51 -69
- data/benchmarks/apps/committee.ru +3 -1
- data/benchmarks/apps/committee_with_response_validation.ru +29 -0
- data/benchmarks/apps/committee_with_sinatra.ru +31 -0
- data/benchmarks/apps/openapi.yaml +14 -14
- data/benchmarks/apps/openapi_first_with_hanami_api.ru +26 -0
- data/benchmarks/apps/openapi_first_with_response_validation.ru +22 -0
- data/benchmarks/benchmarks.rb +11 -14
- data/lib/openapi_first/app.rb +3 -6
- data/lib/openapi_first/{find_handler.rb → default_operation_resolver.rb} +5 -11
- data/lib/openapi_first/definition.rb +8 -3
- data/lib/openapi_first/operation.rb +43 -19
- data/lib/openapi_first/request_validation.rb +4 -3
- data/lib/openapi_first/responder.rb +5 -4
- data/lib/openapi_first/response_object.rb +0 -1
- data/lib/openapi_first/response_validation.rb +1 -2
- data/lib/openapi_first/response_validator.rb +1 -3
- data/lib/openapi_first/router.rb +5 -3
- data/lib/openapi_first/validation_format.rb +5 -0
- data/lib/openapi_first/version.rb +1 -1
- data/lib/openapi_first.rb +22 -15
- data/openapi_first.gemspec +6 -3
- metadata +22 -21
- data/.travis.yml +0 -8
@@ -28,15 +28,13 @@ paths:
|
|
28
28
|
content:
|
29
29
|
application/json:
|
30
30
|
schema:
|
31
|
-
type:
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
id:
|
39
|
-
type: string
|
31
|
+
type: object
|
32
|
+
required: [hello, id]
|
33
|
+
properties:
|
34
|
+
hello:
|
35
|
+
type: string
|
36
|
+
id:
|
37
|
+
type: string
|
40
38
|
/hello:
|
41
39
|
get:
|
42
40
|
operationId: find_things
|
@@ -61,11 +59,13 @@ paths:
|
|
61
59
|
content:
|
62
60
|
application/json:
|
63
61
|
schema:
|
64
|
-
type:
|
65
|
-
|
66
|
-
|
67
|
-
hello
|
68
|
-
|
62
|
+
type: array
|
63
|
+
items:
|
64
|
+
type: object
|
65
|
+
required: [hello]
|
66
|
+
properties:
|
67
|
+
hello:
|
68
|
+
type: string
|
69
69
|
default:
|
70
70
|
description: Error response
|
71
71
|
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'multi_json'
|
4
|
+
require 'openapi_first'
|
5
|
+
require 'hanami/api'
|
6
|
+
|
7
|
+
app = Class.new(Hanami::API) do
|
8
|
+
get '/hello/:id' do
|
9
|
+
json(hello: 'world', id: params.fetch(:id))
|
10
|
+
end
|
11
|
+
|
12
|
+
get '/hello' do
|
13
|
+
json([{ hello: 'world' }])
|
14
|
+
end
|
15
|
+
|
16
|
+
post '/hello' do
|
17
|
+
status 201
|
18
|
+
json(hello: 'world')
|
19
|
+
end
|
20
|
+
end.new
|
21
|
+
|
22
|
+
oas_path = File.absolute_path('./openapi.yaml', __dir__)
|
23
|
+
use OpenapiFirst::Router, spec: OpenapiFirst.load(oas_path)
|
24
|
+
use OpenapiFirst::RequestValidation
|
25
|
+
|
26
|
+
run app
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'multi_json'
|
4
|
+
require 'openapi_first'
|
5
|
+
|
6
|
+
namespace = Module.new do
|
7
|
+
def self.find_thing(params, _res)
|
8
|
+
{ hello: 'world', id: params.fetch(:id) }
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.find_things(_params, _res)
|
12
|
+
[{ hello: 'world' }]
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.create_thing(_params, res)
|
16
|
+
res.status = 201
|
17
|
+
{ hello: 'world' }
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
oas_path = File.absolute_path('./openapi.yaml', __dir__)
|
22
|
+
run OpenapiFirst.app(oas_path, namespace: namespace, response_validation: true)
|
data/benchmarks/benchmarks.rb
CHANGED
@@ -19,28 +19,25 @@ apps = Dir['./apps/*.ru'].each_with_object({}) do |config, hash|
|
|
19
19
|
end
|
20
20
|
apps.freeze
|
21
21
|
|
22
|
+
bench = lambda do |app|
|
23
|
+
examples.each do |example|
|
24
|
+
env, expected_status = example
|
25
|
+
100.times { app.call(env) }
|
26
|
+
response = app.call(env)
|
27
|
+
raise unless response[0] == expected_status
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
22
31
|
Benchmark.ips do |x|
|
23
32
|
apps.each do |config, app|
|
24
|
-
x.report(config)
|
25
|
-
examples.each do |example|
|
26
|
-
env, expected_status = example
|
27
|
-
response = app.call(env)
|
28
|
-
raise unless response[0] == expected_status
|
29
|
-
end
|
30
|
-
end
|
33
|
+
x.report(config) { bench.call(app) }
|
31
34
|
end
|
32
35
|
x.compare!
|
33
36
|
end
|
34
37
|
|
35
38
|
Benchmark.memory do |x|
|
36
39
|
apps.each do |config, app|
|
37
|
-
x.report(config)
|
38
|
-
examples.each do |example|
|
39
|
-
env, expected_status = example
|
40
|
-
response = app.call(env)
|
41
|
-
raise unless response[0] == expected_status
|
42
|
-
end
|
43
|
-
end
|
40
|
+
x.report(config) { bench.call(app) }
|
44
41
|
end
|
45
42
|
x.compare!
|
46
43
|
end
|
data/lib/openapi_first/app.rb
CHANGED
@@ -1,7 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'rack'
|
4
|
-
require 'logger'
|
5
4
|
|
6
5
|
module OpenapiFirst
|
7
6
|
class App
|
@@ -11,17 +10,15 @@ module OpenapiFirst
|
|
11
10
|
namespace:,
|
12
11
|
router_raise_error: false,
|
13
12
|
request_validation_raise_error: false,
|
14
|
-
response_validation: false
|
13
|
+
response_validation: false,
|
14
|
+
resolver: nil
|
15
15
|
)
|
16
16
|
@stack = Rack::Builder.app do
|
17
17
|
freeze_app
|
18
18
|
use OpenapiFirst::Router, spec: spec, raise_error: router_raise_error, parent_app: parent_app
|
19
19
|
use OpenapiFirst::RequestValidation, raise_error: request_validation_raise_error
|
20
20
|
use OpenapiFirst::ResponseValidation if response_validation
|
21
|
-
run OpenapiFirst::Responder.new(
|
22
|
-
spec: spec,
|
23
|
-
namespace: namespace
|
24
|
-
)
|
21
|
+
run OpenapiFirst::Responder.new(namespace: namespace, resolver: resolver)
|
25
22
|
end
|
26
23
|
end
|
27
24
|
|
@@ -3,20 +3,14 @@
|
|
3
3
|
require_relative 'utils'
|
4
4
|
|
5
5
|
module OpenapiFirst
|
6
|
-
class
|
7
|
-
def initialize(
|
6
|
+
class DefaultOperationResolver
|
7
|
+
def initialize(namespace)
|
8
8
|
@namespace = namespace
|
9
|
-
@handlers =
|
10
|
-
operation_id = operation.operation_id
|
11
|
-
handler = find_handler(operation_id)
|
12
|
-
next if handler.nil?
|
13
|
-
|
14
|
-
hash[operation_id] = handler
|
15
|
-
end
|
9
|
+
@handlers = {}
|
16
10
|
end
|
17
11
|
|
18
|
-
def
|
19
|
-
@handlers[
|
12
|
+
def call(operation)
|
13
|
+
@handlers[operation.name] ||= find_handler(operation['x-handler'] || operation['operationId'])
|
20
14
|
end
|
21
15
|
|
22
16
|
def find_handler(operation_id)
|
@@ -6,9 +6,14 @@ module OpenapiFirst
|
|
6
6
|
class Definition
|
7
7
|
attr_reader :filepath, :operations
|
8
8
|
|
9
|
-
def initialize(
|
10
|
-
@filepath =
|
11
|
-
|
9
|
+
def initialize(resolved, filepath)
|
10
|
+
@filepath = filepath
|
11
|
+
methods = %w[get head post put patch delete trace options]
|
12
|
+
@operations = resolved['paths'].flat_map do |path, path_item|
|
13
|
+
path_item.slice(*methods).map do |request_method, _operation_object|
|
14
|
+
Operation.new(path, request_method, path_item)
|
15
|
+
end
|
16
|
+
end
|
12
17
|
end
|
13
18
|
end
|
14
19
|
end
|
@@ -1,29 +1,30 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'forwardable'
|
4
|
-
require 'json_schemer'
|
5
4
|
require_relative 'schema_validation'
|
6
5
|
require_relative 'utils'
|
7
6
|
require_relative 'response_object'
|
8
7
|
|
9
8
|
module OpenapiFirst
|
10
|
-
class Operation
|
9
|
+
class Operation # rubocop:disable Metrics/ClassLength
|
11
10
|
extend Forwardable
|
12
|
-
def_delegators
|
13
|
-
:
|
14
|
-
:
|
15
|
-
:request_body,
|
16
|
-
:operation_id
|
11
|
+
def_delegators :operation_object,
|
12
|
+
:[],
|
13
|
+
:dig
|
17
14
|
|
18
15
|
WRITE_METHODS = Set.new(%w[post put patch delete]).freeze
|
19
16
|
private_constant :WRITE_METHODS
|
20
17
|
|
21
|
-
|
22
|
-
|
18
|
+
attr_reader :path, :method
|
19
|
+
|
20
|
+
def initialize(path, request_method, path_item_object)
|
21
|
+
@path = path
|
22
|
+
@method = request_method
|
23
|
+
@path_item_object = path_item_object
|
23
24
|
end
|
24
25
|
|
25
|
-
def
|
26
|
-
|
26
|
+
def operation_id
|
27
|
+
operation_object['operationId']
|
27
28
|
end
|
28
29
|
|
29
30
|
def read?
|
@@ -34,6 +35,10 @@ module OpenapiFirst
|
|
34
35
|
WRITE_METHODS.include?(method)
|
35
36
|
end
|
36
37
|
|
38
|
+
def request_body
|
39
|
+
operation_object['requestBody']
|
40
|
+
end
|
41
|
+
|
37
42
|
def parameters_schema
|
38
43
|
@parameters_schema ||= begin
|
39
44
|
parameters_json_schema = build_parameters_json_schema
|
@@ -53,6 +58,7 @@ module OpenapiFirst
|
|
53
58
|
raise ResponseInvalid, "Response has no content-type for '#{name}'" unless content_type
|
54
59
|
|
55
60
|
media_type = find_content_for_content_type(content, content_type)
|
61
|
+
|
56
62
|
unless media_type
|
57
63
|
message = "Response content type not found '#{content_type}' for '#{name}'"
|
58
64
|
raise ResponseContentTypeNotFoundError, message
|
@@ -62,7 +68,7 @@ module OpenapiFirst
|
|
62
68
|
end
|
63
69
|
|
64
70
|
def request_body_schema(request_content_type)
|
65
|
-
content =
|
71
|
+
content = operation_object.dig('requestBody', 'content')
|
66
72
|
media_type = find_content_for_content_type(content, request_content_type)
|
67
73
|
schema = media_type&.fetch('schema', nil)
|
68
74
|
return unless schema
|
@@ -71,8 +77,9 @@ module OpenapiFirst
|
|
71
77
|
end
|
72
78
|
|
73
79
|
def response_for(status)
|
74
|
-
|
75
|
-
|
80
|
+
response_content = response_by_code(status)
|
81
|
+
return response_content if response_content
|
82
|
+
|
76
83
|
message = "Response status code or default not found: #{status} for '#{name}'"
|
77
84
|
raise OpenapiFirst::ResponseCodeNotFoundError, message
|
78
85
|
end
|
@@ -83,6 +90,15 @@ module OpenapiFirst
|
|
83
90
|
|
84
91
|
private
|
85
92
|
|
93
|
+
def response_by_code(status)
|
94
|
+
operation_object.dig('responses', status.to_s) ||
|
95
|
+
operation_object.dig('responses', 'default')
|
96
|
+
end
|
97
|
+
|
98
|
+
def operation_object
|
99
|
+
@path_item_object[method]
|
100
|
+
end
|
101
|
+
|
86
102
|
def find_content_for_content_type(content, request_content_type)
|
87
103
|
content.fetch(request_content_type) do |_|
|
88
104
|
type = request_content_type.split(';')[0]
|
@@ -91,24 +107,32 @@ module OpenapiFirst
|
|
91
107
|
end
|
92
108
|
|
93
109
|
def build_parameters_json_schema
|
94
|
-
|
110
|
+
parameters = all_parameters
|
111
|
+
return unless parameters&.any?
|
95
112
|
|
96
|
-
|
97
|
-
params = Rack::Utils.parse_nested_query(parameter
|
113
|
+
parameters.each_with_object(new_node) do |parameter, schema|
|
114
|
+
params = Rack::Utils.parse_nested_query(parameter['name'])
|
98
115
|
generate_schema(schema, params, parameter)
|
99
116
|
end
|
100
117
|
end
|
101
118
|
|
119
|
+
def all_parameters
|
120
|
+
parameters = @path_item_object['parameters']&.dup || []
|
121
|
+
parameters_on_operation = operation_object['parameters']
|
122
|
+
parameters.concat(parameters_on_operation) if parameters_on_operation
|
123
|
+
parameters
|
124
|
+
end
|
125
|
+
|
102
126
|
def generate_schema(schema, params, parameter)
|
103
127
|
required = Set.new(schema['required'])
|
104
128
|
params.each do |key, value|
|
105
|
-
required << key if parameter
|
129
|
+
required << key if parameter['required']
|
106
130
|
if value.is_a? Hash
|
107
131
|
property_schema = new_node
|
108
132
|
generate_schema(property_schema, value, parameter)
|
109
133
|
Utils.deep_merge!(schema['properties'], { key => property_schema })
|
110
134
|
else
|
111
|
-
schema['properties'][key] = parameter
|
135
|
+
schema['properties'][key] = parameter['schema']
|
112
136
|
end
|
113
137
|
end
|
114
138
|
schema['required'] = required.to_a
|
@@ -1,7 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'rack'
|
4
|
-
require 'json_schemer'
|
5
4
|
require 'multi_json'
|
6
5
|
require_relative 'inbox'
|
7
6
|
require_relative 'router_required'
|
@@ -63,13 +62,13 @@ module OpenapiFirst
|
|
63
62
|
end
|
64
63
|
|
65
64
|
def validate_request_content_type!(content_type, operation)
|
66
|
-
return if operation.request_body.content
|
65
|
+
return if operation.request_body.dig('content', content_type)
|
67
66
|
|
68
67
|
halt_with_error(415)
|
69
68
|
end
|
70
69
|
|
71
70
|
def validate_request_body_presence!(body, operation)
|
72
|
-
return unless operation.request_body
|
71
|
+
return unless operation.request_body['required'] && body.empty?
|
73
72
|
|
74
73
|
halt_with_error(415, 'Request body is required')
|
75
74
|
end
|
@@ -142,6 +141,8 @@ module OpenapiFirst
|
|
142
141
|
end
|
143
142
|
|
144
143
|
def parse_array_parameter(value, schema)
|
144
|
+
return value if value.nil? || value.empty?
|
145
|
+
|
145
146
|
array = value.is_a?(Array) ? value : value.split(',')
|
146
147
|
return array unless schema['items']
|
147
148
|
|
@@ -1,13 +1,14 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'rack'
|
4
|
+
require 'multi_json'
|
4
5
|
require_relative 'inbox'
|
5
|
-
require_relative '
|
6
|
+
require_relative 'default_operation_resolver'
|
6
7
|
|
7
8
|
module OpenapiFirst
|
8
9
|
class Responder
|
9
|
-
def initialize(
|
10
|
-
@resolver = resolver
|
10
|
+
def initialize(namespace: nil, resolver: nil)
|
11
|
+
@resolver = resolver || DefaultOperationResolver.new(namespace)
|
11
12
|
@namespace = namespace
|
12
13
|
end
|
13
14
|
|
@@ -24,7 +25,7 @@ module OpenapiFirst
|
|
24
25
|
private
|
25
26
|
|
26
27
|
def find_handler(operation)
|
27
|
-
handler = @resolver
|
28
|
+
handler = @resolver.call(operation)
|
28
29
|
raise NotImplementedError, "Could not find handler for #{operation.name}" unless handler
|
29
30
|
|
30
31
|
handler
|
data/lib/openapi_first/router.rb
CHANGED
@@ -2,7 +2,6 @@
|
|
2
2
|
|
3
3
|
require 'rack'
|
4
4
|
require 'hanami/router'
|
5
|
-
require_relative 'utils'
|
6
5
|
|
7
6
|
module OpenapiFirst
|
8
7
|
class Router
|
@@ -40,7 +39,10 @@ module OpenapiFirst
|
|
40
39
|
|
41
40
|
def raise_error(env)
|
42
41
|
req = Rack::Request.new(env)
|
43
|
-
msg =
|
42
|
+
msg =
|
43
|
+
"Could not find definition for #{req.request_method} '#{
|
44
|
+
req.path
|
45
|
+
}' in API description #{@filepath}"
|
44
46
|
raise NotFoundError, msg
|
45
47
|
end
|
46
48
|
|
@@ -54,7 +56,7 @@ module OpenapiFirst
|
|
54
56
|
end
|
55
57
|
|
56
58
|
def build_router(operations) # rubocop:disable Metrics/AbcSize
|
57
|
-
router = Hanami::Router.new
|
59
|
+
router = Hanami::Router.new
|
58
60
|
operations.each do |operation|
|
59
61
|
normalized_path = operation.path.gsub('{', ':').gsub('}', '')
|
60
62
|
if operation.operation_id.nil?
|
@@ -19,6 +19,11 @@ module OpenapiFirst
|
|
19
19
|
title: "has not a valid #{error.dig('schema', 'format')} format",
|
20
20
|
detail: "#{error['data'].inspect} is not a valid #{error.dig('schema', 'format')} format"
|
21
21
|
}
|
22
|
+
elsif error['type'] == 'enum'
|
23
|
+
{
|
24
|
+
title: "value #{error['data'].inspect} is not defined in enum",
|
25
|
+
detail: "value can be one of #{error.dig('schema', 'enum')&.join(', ')}"
|
26
|
+
}
|
22
27
|
elsif error['type'] == 'required'
|
23
28
|
missing_keys = error['details']['missing_keys']
|
24
29
|
{
|
data/lib/openapi_first.rb
CHANGED
@@ -1,16 +1,16 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'yaml'
|
4
|
-
require '
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
4
|
+
require 'json_refs'
|
5
|
+
require_relative 'openapi_first/definition'
|
6
|
+
require_relative 'openapi_first/version'
|
7
|
+
require_relative 'openapi_first/inbox'
|
8
|
+
require_relative 'openapi_first/router'
|
9
|
+
require_relative 'openapi_first/request_validation'
|
10
|
+
require_relative 'openapi_first/response_validator'
|
11
|
+
require_relative 'openapi_first/response_validation'
|
12
|
+
require_relative 'openapi_first/responder'
|
13
|
+
require_relative 'openapi_first/app'
|
14
14
|
|
15
15
|
module OpenapiFirst
|
16
16
|
OPERATION = 'openapi_first.operation'
|
@@ -24,11 +24,12 @@ module OpenapiFirst
|
|
24
24
|
end
|
25
25
|
|
26
26
|
def self.load(spec_path, only: nil)
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
27
|
+
resolved = Dir.chdir(File.dirname(spec_path)) do
|
28
|
+
content = YAML.load_file(File.basename(spec_path))
|
29
|
+
JsonRefs.call(content, resolve_local_ref: true, resolve_file_ref: true)
|
30
|
+
end
|
31
|
+
resolved['paths'].filter!(&->(key, _) { only.call(key) }) if only
|
32
|
+
Definition.new(resolved, spec_path)
|
32
33
|
end
|
33
34
|
|
34
35
|
def self.app(
|
@@ -78,11 +79,17 @@ module OpenapiFirst
|
|
78
79
|
end
|
79
80
|
|
80
81
|
class Error < StandardError; end
|
82
|
+
|
81
83
|
class NotFoundError < Error; end
|
84
|
+
|
82
85
|
class NotImplementedError < RuntimeError; end
|
86
|
+
|
83
87
|
class ResponseInvalid < Error; end
|
88
|
+
|
84
89
|
class ResponseCodeNotFoundError < ResponseInvalid; end
|
90
|
+
|
85
91
|
class ResponseContentTypeNotFoundError < ResponseInvalid; end
|
92
|
+
|
86
93
|
class ResponseBodyInvalidError < ResponseInvalid; end
|
87
94
|
|
88
95
|
class RequestInvalidError < Error
|
data/openapi_first.gemspec
CHANGED
@@ -20,7 +20,7 @@ Gem::Specification.new do |spec|
|
|
20
20
|
spec.metadata['changelog_uri'] = 'https://github.com/ahx/openapi_first/blob/master/CHANGELOG.md'
|
21
21
|
else
|
22
22
|
raise 'RubyGems 2.0 or newer is required to protect against ' \
|
23
|
-
|
23
|
+
'public gem pushes.'
|
24
24
|
end
|
25
25
|
|
26
26
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
@@ -35,15 +35,18 @@ Gem::Specification.new do |spec|
|
|
35
35
|
spec.required_ruby_version = '>= 2.6.0'
|
36
36
|
|
37
37
|
spec.add_runtime_dependency 'deep_merge', '>= 1.2.1'
|
38
|
-
spec.add_runtime_dependency 'hanami-router', '~> 2.0.
|
38
|
+
spec.add_runtime_dependency 'hanami-router', '~> 2.0.alpha4'
|
39
39
|
spec.add_runtime_dependency 'hanami-utils', '~> 2.0.alpha1'
|
40
|
+
spec.add_runtime_dependency 'json_refs', '>= 0.1.7'
|
40
41
|
spec.add_runtime_dependency 'json_schemer', '~> 0.2.16'
|
41
42
|
spec.add_runtime_dependency 'multi_json', '~> 1.14'
|
42
|
-
spec.add_runtime_dependency 'oas_parser', '~> 0.25.1'
|
43
43
|
spec.add_runtime_dependency 'rack', '~> 2.2'
|
44
44
|
|
45
45
|
spec.add_development_dependency 'bundler', '~> 2'
|
46
46
|
spec.add_development_dependency 'rack-test', '~> 1'
|
47
47
|
spec.add_development_dependency 'rake', '~> 13'
|
48
48
|
spec.add_development_dependency 'rspec', '~> 3'
|
49
|
+
spec.metadata = {
|
50
|
+
'rubygems_mfa_required' => 'true'
|
51
|
+
}
|
49
52
|
end
|