brainstem 1.1.1 → 1.3.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 +4 -4
- data/CHANGELOG.md +81 -4
- data/Gemfile.lock +9 -9
- data/README.md +134 -37
- data/brainstem.gemspec +1 -1
- data/lib/brainstem/api_docs/endpoint.rb +40 -18
- data/lib/brainstem/api_docs/formatters/markdown/endpoint_formatter.rb +27 -22
- data/lib/brainstem/api_docs/formatters/markdown/helper.rb +9 -0
- data/lib/brainstem/api_docs/formatters/markdown/presenter_formatter.rb +14 -6
- data/lib/brainstem/api_docs/presenter.rb +3 -7
- data/lib/brainstem/concerns/controller_dsl.rb +138 -14
- data/lib/brainstem/concerns/presenter_dsl.rb +39 -6
- data/lib/brainstem/dsl/array_block_field.rb +25 -0
- data/lib/brainstem/dsl/block_field.rb +69 -0
- data/lib/brainstem/dsl/configuration.rb +13 -5
- data/lib/brainstem/dsl/field.rb +15 -1
- data/lib/brainstem/dsl/fields_block.rb +20 -2
- data/lib/brainstem/dsl/hash_block_field.rb +30 -0
- data/lib/brainstem/presenter.rb +10 -6
- data/lib/brainstem/presenter_validator.rb +20 -11
- data/lib/brainstem/version.rb +1 -1
- data/spec/brainstem/api_docs/endpoint_spec.rb +347 -14
- data/spec/brainstem/api_docs/formatters/markdown/endpoint_formatter_spec.rb +106 -13
- data/spec/brainstem/api_docs/formatters/markdown/helper_spec.rb +19 -0
- data/spec/brainstem/api_docs/formatters/markdown/presenter_formatter_spec.rb +150 -37
- data/spec/brainstem/api_docs/presenter_spec.rb +85 -18
- data/spec/brainstem/concerns/controller_dsl_spec.rb +615 -31
- data/spec/brainstem/concerns/inheritable_configuration_spec.rb +32 -9
- data/spec/brainstem/concerns/presenter_dsl_spec.rb +99 -25
- data/spec/brainstem/dsl/array_block_field_spec.rb +43 -0
- data/spec/brainstem/dsl/block_field_spec.rb +188 -0
- data/spec/brainstem/dsl/field_spec.rb +86 -20
- data/spec/brainstem/dsl/hash_block_field_spec.rb +166 -0
- data/spec/brainstem/presenter_collection_spec.rb +24 -24
- data/spec/brainstem/presenter_spec.rb +233 -9
- data/spec/brainstem/query_strategies/filter_and_search_spec.rb +1 -1
- data/spec/spec_helpers/presenters.rb +8 -0
- data/spec/spec_helpers/schema.rb +13 -0
- metadata +15 -6
@@ -71,7 +71,7 @@ module Brainstem
|
|
71
71
|
def <=>(other)
|
72
72
|
|
73
73
|
# Any unordered routes are assigned an index of +ACTION_ORDER.count+.
|
74
|
-
ordered_actions_count
|
74
|
+
ordered_actions_count = ACTION_ORDER.count
|
75
75
|
own_action_priority = ACTION_ORDER.index(action.to_s) || ordered_actions_count
|
76
76
|
other_action_priority = ACTION_ORDER.index(other.action.to_s) || ordered_actions_count
|
77
77
|
|
@@ -120,32 +120,54 @@ module Brainstem
|
|
120
120
|
|
121
121
|
|
122
122
|
#
|
123
|
-
# Returns a hash of all
|
124
|
-
#
|
125
|
-
# root-level non-nested params.
|
123
|
+
# Returns a hash of all params nested under the specified root or
|
124
|
+
# parent fields along with their type, item type & children.
|
126
125
|
#
|
127
|
-
# @return [Hash{Symbol =>
|
128
|
-
# nested under them
|
126
|
+
# @return [Hash{Symbol => Hash}] root keys and their type info, item info & children
|
127
|
+
# nested under them.
|
129
128
|
#
|
130
|
-
def
|
131
|
-
@
|
132
|
-
valid_params
|
133
|
-
.
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
129
|
+
def params_configuration_tree
|
130
|
+
@params_configuration_tree ||= begin
|
131
|
+
valid_params
|
132
|
+
.to_h
|
133
|
+
.deep_dup
|
134
|
+
.with_indifferent_access
|
135
|
+
.inject(ActiveSupport::HashWithIndifferentAccess.new) do |result, (field_name_proc, field_config)|
|
136
|
+
|
137
|
+
next result if field_config[:nodoc]
|
138
|
+
|
139
|
+
field_name = evaluate_field_name(field_name_proc)
|
140
|
+
if field_config.has_key?(:ancestors)
|
141
|
+
ancestors = field_config[:ancestors].map { |ancestor_key| evaluate_field_name(ancestor_key) }
|
142
|
+
|
143
|
+
parent = ancestors.inject(result) do |traversed_hash, ancestor_name|
|
144
|
+
traversed_hash[ancestor_name] ||= { :_config => { type: 'hash' } }
|
145
|
+
traversed_hash[ancestor_name]
|
141
146
|
end
|
142
147
|
|
143
|
-
|
148
|
+
parent[field_name] = { :_config => field_config.except(:root, :ancestors) }
|
149
|
+
else
|
150
|
+
result[field_name] = { :_config => field_config }
|
144
151
|
end
|
152
|
+
|
153
|
+
result
|
154
|
+
end
|
145
155
|
end
|
146
156
|
end
|
147
157
|
|
148
158
|
|
159
|
+
#
|
160
|
+
# Evaluate field name if proc and symbolize it.
|
161
|
+
#
|
162
|
+
def evaluate_field_name(field_name_or_proc)
|
163
|
+
return field_name_or_proc if field_name_or_proc.nil?
|
164
|
+
|
165
|
+
field_name = field_name_or_proc.respond_to?(:call) ? field_name_or_proc.call(controller.const) : field_name_or_proc
|
166
|
+
field_name.to_sym
|
167
|
+
end
|
168
|
+
alias_method :evaluate_root_name, :evaluate_field_name
|
169
|
+
|
170
|
+
|
149
171
|
#
|
150
172
|
# Retrieves the +presents+ settings.
|
151
173
|
#
|
@@ -83,36 +83,34 @@ module Brainstem
|
|
83
83
|
# Formats each parameter.
|
84
84
|
#
|
85
85
|
def format_params!
|
86
|
-
return unless endpoint.
|
86
|
+
return unless endpoint.params_configuration_tree.any?
|
87
87
|
|
88
88
|
output << md_h5("Valid Parameters")
|
89
89
|
output << md_ul do
|
90
|
-
endpoint.
|
91
|
-
|
92
|
-
buff += parameter_with_indent_level(
|
93
|
-
root_param_name,
|
94
|
-
endpoint.valid_params[root_param_name],
|
95
|
-
0
|
96
|
-
)
|
97
|
-
else
|
98
|
-
text = md_inline_code(root_param_name) + "\n"
|
99
|
-
|
100
|
-
child_keys.each do |param_name|
|
101
|
-
text += parameter_with_indent_level(
|
102
|
-
param_name,
|
103
|
-
endpoint.valid_params[param_name],
|
104
|
-
1
|
105
|
-
)
|
106
|
-
end
|
107
|
-
|
108
|
-
buff << md_li(text)
|
109
|
-
end
|
110
|
-
|
90
|
+
endpoint.params_configuration_tree.inject("") do |buff, (param_name, param_config)|
|
91
|
+
buff << format_param_tree!("", param_name, param_config)
|
111
92
|
buff
|
112
93
|
end
|
113
94
|
end
|
114
95
|
end
|
115
96
|
|
97
|
+
#
|
98
|
+
# Formats the parent parameter and its children
|
99
|
+
#
|
100
|
+
def format_param_tree!(buffer, param_name, param_config, indentation = 0)
|
101
|
+
buffer += parameter_with_indent_level(
|
102
|
+
param_name,
|
103
|
+
param_config[:_config],
|
104
|
+
indentation
|
105
|
+
)
|
106
|
+
|
107
|
+
children = param_config.except(:_config) || []
|
108
|
+
children.each do |child_param_name, child_param_config|
|
109
|
+
buffer = format_param_tree!(buffer, child_param_name, child_param_config, indentation + 1)
|
110
|
+
end
|
111
|
+
|
112
|
+
buffer
|
113
|
+
end
|
116
114
|
|
117
115
|
#
|
118
116
|
# Formats a given parameter with a variable indent level. Useful for
|
@@ -120,11 +118,16 @@ module Brainstem
|
|
120
118
|
#
|
121
119
|
# @param [String] name the param name
|
122
120
|
# @param [Hash] options information pertinent to the param
|
121
|
+
# @option [Boolean] options :required
|
123
122
|
# @option [Boolean] options :legacy
|
124
123
|
# @option [Boolean] options :recursive
|
125
124
|
# @option [String,Symbol] options :only Deprecated: use +actions+
|
126
125
|
# block instead
|
127
126
|
# @option [String] options :info the doc string for the param
|
127
|
+
# @option [String] options :type The type of the field.
|
128
|
+
# e.g. string, integer, boolean, array, hash
|
129
|
+
# @option [String] options :item_type The type of the items in the field.
|
130
|
+
# Ideally used when the type of the field is an array or hash.
|
128
131
|
# @param [Integer] indent how many levels the output should be
|
129
132
|
# indented from normal
|
130
133
|
#
|
@@ -132,10 +135,12 @@ module Brainstem
|
|
132
135
|
options = options.dup
|
133
136
|
text = md_inline_code(title)
|
134
137
|
|
138
|
+
text += md_inline_type(options.delete(:type), options.delete(:item_type)) if options.has_key?(:type)
|
135
139
|
text += " - #{options.delete(:info)}" if options.has_key?(:info)
|
136
140
|
|
137
141
|
if options.keys.any?
|
138
142
|
text += "\n"
|
143
|
+
text += md_li("Required: #{options[:required].to_s}", indent + 1) if options.has_key?(:required) && options[:required]
|
139
144
|
text += md_li("Legacy: #{options[:legacy].to_s}", indent + 1) if options.has_key?(:legacy)
|
140
145
|
text += md_li("Recursive: #{options[:recursive].to_s}", indent + 1) if options.has_key?(:recursive)
|
141
146
|
text.chomp!
|
@@ -69,6 +69,15 @@ module Brainstem
|
|
69
69
|
def md_a(text, link)
|
70
70
|
"[#{text}](#{link})"
|
71
71
|
end
|
72
|
+
|
73
|
+
|
74
|
+
def md_inline_type(type, item_type = nil)
|
75
|
+
return "" if type.blank?
|
76
|
+
|
77
|
+
text = type.to_s.capitalize
|
78
|
+
text += "<#{item_type.to_s.capitalize}>" if item_type.present?
|
79
|
+
" (#{md_inline_code(text)})"
|
80
|
+
end
|
72
81
|
end
|
73
82
|
end
|
74
83
|
end
|
@@ -62,7 +62,7 @@ module Brainstem
|
|
62
62
|
|
63
63
|
def format_field_leaf(field, indent_level)
|
64
64
|
text = md_inline_code(field.name.to_s)
|
65
|
-
text <<
|
65
|
+
text << md_inline_type(field.type, field.options[:item_type])
|
66
66
|
|
67
67
|
text << "\n"
|
68
68
|
text << md_li(field.description, indent_level + 1) if field.description
|
@@ -85,9 +85,9 @@ module Brainstem
|
|
85
85
|
def format_field_branch(branch, indent_level = 0)
|
86
86
|
branch.inject("") do |buffer, (name, field)|
|
87
87
|
if nested_field?(field)
|
88
|
-
sub_fields =
|
88
|
+
sub_fields = format_field_leaf(field, indent_level) + "\n"
|
89
89
|
sub_fields << format_field_branch(field.to_h, indent_level + 1)
|
90
|
-
buffer
|
90
|
+
buffer += md_li(sub_fields, indent_level)
|
91
91
|
else
|
92
92
|
buffer += md_li(format_field_leaf(field, indent_level), indent_level)
|
93
93
|
end
|
@@ -96,7 +96,7 @@ module Brainstem
|
|
96
96
|
|
97
97
|
|
98
98
|
def nested_field?(field)
|
99
|
-
|
99
|
+
field.respond_to?(:configuration)
|
100
100
|
end
|
101
101
|
|
102
102
|
|
@@ -120,10 +120,18 @@ module Brainstem
|
|
120
120
|
output << md_ul do
|
121
121
|
presenter.valid_filters.inject("") do |buffer, (name, opts)|
|
122
122
|
text = md_inline_code(name)
|
123
|
+
text << md_inline_type(opts[:type])
|
124
|
+
|
125
|
+
if opts[:info] || opts[:items]
|
126
|
+
description = opts[:info].to_s
|
127
|
+
|
128
|
+
if opts[:items].present?
|
129
|
+
description += "." unless description =~ /\.\s*\z/
|
130
|
+
description += " Available values: #{opts[:items].join(', ')}."
|
131
|
+
end
|
123
132
|
|
124
|
-
if opts[:info]
|
125
133
|
text << "\n"
|
126
|
-
text << md_li(
|
134
|
+
text << md_li(description, 1)
|
127
135
|
text.chomp!
|
128
136
|
end
|
129
137
|
|
@@ -105,12 +105,8 @@ module Brainstem
|
|
105
105
|
|
106
106
|
|
107
107
|
def valid_fields(fields = configuration[:fields])
|
108
|
-
fields.to_h.reject do |
|
109
|
-
|
110
|
-
valid_fields_in(v).none?
|
111
|
-
else
|
112
|
-
invalid_field?(v)
|
113
|
-
end
|
108
|
+
fields.to_h.reject do |_, field|
|
109
|
+
invalid_field?(field) || (nested_field?(field) && valid_fields_in(field).none?)
|
114
110
|
end
|
115
111
|
end
|
116
112
|
alias_method :valid_fields_in, :valid_fields
|
@@ -122,7 +118,7 @@ module Brainstem
|
|
122
118
|
|
123
119
|
|
124
120
|
def nested_field?(field)
|
125
|
-
|
121
|
+
field.respond_to?(:configuration)
|
126
122
|
end
|
127
123
|
|
128
124
|
|
@@ -53,6 +53,14 @@ module Brainstem
|
|
53
53
|
end
|
54
54
|
|
55
55
|
|
56
|
+
#
|
57
|
+
# Temporary implementation to track controllers that have been documented.
|
58
|
+
#
|
59
|
+
def documented!
|
60
|
+
configuration[brainstem_params_context][:documented] = true
|
61
|
+
end
|
62
|
+
|
63
|
+
|
56
64
|
#
|
57
65
|
# Specifies that the scope should not be documented. Setting this on
|
58
66
|
# the default context will force the controller to be undocumented,
|
@@ -127,7 +135,7 @@ module Brainstem
|
|
127
135
|
# method accepting the controller constant and returning one
|
128
136
|
#
|
129
137
|
def model_params(root = Proc.new { |klass| klass.brainstem_model_name }, &block)
|
130
|
-
with_options(
|
138
|
+
with_options(format_root_ancestry_options(root), &block)
|
131
139
|
end
|
132
140
|
|
133
141
|
|
@@ -136,16 +144,37 @@ module Brainstem
|
|
136
144
|
# the info sent with it.
|
137
145
|
#
|
138
146
|
# @param [Symbol] field_name the name of the param
|
147
|
+
# @param [String,Symbol] type the data type of the field. If not specified, will default to `string`.
|
139
148
|
# @param [Hash] options
|
140
149
|
# @option options [String] :info the documentation for the param
|
141
150
|
# @option options [String,Symbol] :root if this is a nested param,
|
142
151
|
# under which param should it be nested?
|
143
152
|
# @option options [Boolean] :nodoc should this param appear in the
|
144
153
|
# documentation?
|
154
|
+
# @option options [Boolean] :required if the param is required for
|
155
|
+
# the endpoint
|
156
|
+
# @option options [String,Symbol] :item_type The data type of the items contained in a field.
|
157
|
+
# Ideally used when the data type of the field is an `array`, `object` or `hash`.
|
145
158
|
#
|
146
|
-
def valid(field_name,
|
159
|
+
def valid(field_name, type = nil, options = {}, &block)
|
147
160
|
valid_params = configuration[brainstem_params_context][:valid_params]
|
148
|
-
|
161
|
+
field_config = format_field_configuration(type, options, &block)
|
162
|
+
|
163
|
+
# Inherit `nodoc` attribute from parent
|
164
|
+
parent_key = (options[:ancestors] || []).reverse.first
|
165
|
+
field_config[:nodoc] = true if parent_key && valid_params[parent_key] && valid_params[parent_key][:nodoc]
|
166
|
+
|
167
|
+
# Rollup `required` attribute to ancestors if true
|
168
|
+
if field_config[:required]
|
169
|
+
(options[:ancestors] || []).reverse.each do |ancestor_key|
|
170
|
+
valid_params[ancestor_key][:required] = true if valid_params.has_key?(ancestor_key)
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
procified_field_name = format_field_name(field_name)
|
175
|
+
valid_params[procified_field_name] = field_config
|
176
|
+
|
177
|
+
with_options(format_field_ancestry_options(procified_field_name, field_config), &block) if block_given?
|
149
178
|
end
|
150
179
|
|
151
180
|
|
@@ -209,8 +238,7 @@ module Brainstem
|
|
209
238
|
# be output in the documentation.
|
210
239
|
#
|
211
240
|
def description(text, options = { nodoc: false })
|
212
|
-
configuration[brainstem_params_context][:description] =
|
213
|
-
options.merge(info: text)
|
241
|
+
configuration[brainstem_params_context][:description] = options.merge(info: text)
|
214
242
|
end
|
215
243
|
|
216
244
|
|
@@ -230,12 +258,114 @@ module Brainstem
|
|
230
258
|
# output in the documentation.
|
231
259
|
#
|
232
260
|
def title(text, options = { nodoc: false })
|
233
|
-
configuration[brainstem_params_context][:title] =
|
234
|
-
|
261
|
+
configuration[brainstem_params_context][:title] = options.merge(info: text)
|
262
|
+
end
|
263
|
+
|
264
|
+
#
|
265
|
+
# Converts the field name into a Proc.
|
266
|
+
#
|
267
|
+
# @param [String, Symbol, Proc] text The title to set
|
268
|
+
# @return [Proc]
|
269
|
+
#
|
270
|
+
def format_field_name(field_name_or_proc)
|
271
|
+
field_name_or_proc.respond_to?(:call) ? field_name_or_proc : Proc.new { field_name_or_proc.to_s }
|
272
|
+
end
|
273
|
+
alias_method :format_root_name, :format_field_name
|
274
|
+
|
275
|
+
|
276
|
+
#
|
277
|
+
# Formats the ancestry options of the field. Returns a hash with ancestors & root.
|
278
|
+
#
|
279
|
+
def format_root_ancestry_options(root_name)
|
280
|
+
root_proc = format_root_name(root_name)
|
281
|
+
ancestors = [root_proc]
|
282
|
+
|
283
|
+
{ root: root_proc, ancestors: ancestors }.with_indifferent_access.reject { |_, v| v.blank? }
|
284
|
+
end
|
285
|
+
|
286
|
+
|
287
|
+
#
|
288
|
+
# Formats the ancestry options of the field. Returns a hash with ancestors & root.
|
289
|
+
#
|
290
|
+
def format_field_ancestry_options(field_name_proc, options = {})
|
291
|
+
ancestors = options[:ancestors].try(:dup) || []
|
292
|
+
ancestors << field_name_proc
|
293
|
+
|
294
|
+
{ ancestors: ancestors }.with_indifferent_access.reject { |_, v| v.blank? }
|
295
|
+
end
|
296
|
+
|
297
|
+
|
298
|
+
#
|
299
|
+
# Formats the configuration of the field and returns the default configuration if not specified.
|
300
|
+
#
|
301
|
+
def format_field_configuration(type = nil, options = {}, &block)
|
302
|
+
options = type if type.is_a?(Hash) && options.empty?
|
303
|
+
|
304
|
+
options[:type] = sanitize_param_data_type(type, &block)
|
305
|
+
options[:item_type] = options[:item_type].to_s if options.has_key?(:item_type)
|
306
|
+
|
307
|
+
DEFAULT_PARAM_OPTIONS.merge(options).with_indifferent_access
|
308
|
+
end
|
309
|
+
|
310
|
+
DEFAULT_PARAM_OPTIONS = { nodoc: false, required: false }
|
311
|
+
private_constant :DEFAULT_PARAM_OPTIONS
|
312
|
+
|
313
|
+
|
314
|
+
#
|
315
|
+
# Returns the type of the param and adds a deprecation warning if not specified.
|
316
|
+
#
|
317
|
+
def sanitize_param_data_type(type, &block)
|
318
|
+
if type.is_a?(Hash) || type.blank?
|
319
|
+
deprecated_type_warning
|
320
|
+
type = block_given? ? DEFAULT_BLOCK_DATA_TYPE : DEFAULT_DATA_TYPE
|
321
|
+
end
|
322
|
+
|
323
|
+
type.to_s
|
324
|
+
end
|
325
|
+
|
326
|
+
DEFAULT_DATA_TYPE = 'string'
|
327
|
+
private_constant :DEFAULT_DATA_TYPE
|
328
|
+
|
329
|
+
DEFAULT_BLOCK_DATA_TYPE = 'hash'
|
330
|
+
private_constant :DEFAULT_BLOCK_DATA_TYPE
|
331
|
+
|
332
|
+
|
333
|
+
#
|
334
|
+
# Adds deprecation warning if the type argument is not specified when defining a valid param.
|
335
|
+
#
|
336
|
+
def deprecated_type_warning
|
337
|
+
ActiveSupport::Deprecation.warn(
|
338
|
+
'Please specify the `type` of the parameter as the second argument. If not specified, '\
|
339
|
+
'it will default to `:string`. This default behavior will be deprecated in the next major '\
|
340
|
+
'version and will need to be explicitly specified. e.g. `post.valid :message, :text, required: true`',
|
341
|
+
caller
|
342
|
+
)
|
235
343
|
end
|
236
344
|
end
|
237
345
|
|
238
346
|
|
347
|
+
def valid_params_tree(requested_context = action_name.to_sym)
|
348
|
+
contextual_key(requested_context, :valid_params)
|
349
|
+
.to_h
|
350
|
+
.inject(ActiveSupport::HashWithIndifferentAccess.new) do |hsh, (field_name_proc, field_config)|
|
351
|
+
|
352
|
+
field_name = field_name_proc.call(self.class)
|
353
|
+
if field_config.has_key?(:ancestors)
|
354
|
+
ancestors = field_config[:ancestors].map { |ancestor_key| ancestor_key.call(self.class) }
|
355
|
+
parent = ancestors.inject(hsh) do |traversed_hash, ancestor_name|
|
356
|
+
traversed_hash[ancestor_name] ||= {}
|
357
|
+
traversed_hash[ancestor_name]
|
358
|
+
end
|
359
|
+
|
360
|
+
parent[field_name] = { :_config => field_config.except(:root, :ancestors) }
|
361
|
+
else
|
362
|
+
hsh[field_name] = { :_config => field_config }
|
363
|
+
end
|
364
|
+
|
365
|
+
hsh
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
239
369
|
#
|
240
370
|
# Lists all valid parameters for the current action. Falls back to the
|
241
371
|
# valid parameters for the default context.
|
@@ -246,12 +376,7 @@ module Brainstem
|
|
246
376
|
# descriptions or sub-hashes.
|
247
377
|
#
|
248
378
|
def brainstem_valid_params(requested_context = action_name.to_sym, root_param_name = brainstem_model_name)
|
249
|
-
|
250
|
-
.to_h
|
251
|
-
.select do |k, v|
|
252
|
-
root = v[:root].respond_to?(:call) ? v[:root].call(self.class) : v[:root]
|
253
|
-
root.to_s == root_param_name.to_s
|
254
|
-
end
|
379
|
+
valid_params_tree(requested_context)[root_param_name.to_s]
|
255
380
|
end
|
256
381
|
alias_method :brainstem_valid_params_for, :brainstem_valid_params
|
257
382
|
|
@@ -293,7 +418,6 @@ module Brainstem
|
|
293
418
|
configuration[DEFAULT_BRAINSTEM_PARAMS_CONTEXT][key.to_sym]
|
294
419
|
end
|
295
420
|
end
|
296
|
-
|
297
421
|
private :contextual_key
|
298
422
|
end
|
299
423
|
end
|