oas_rails 0.2.3 → 0.4.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -1
  3. data/app/controllers/oas_rails/oas_rails_controller.rb +1 -1
  4. data/lib/generators/oas_rails/config/templates/oas_rails_initializer.rb +11 -0
  5. data/lib/oas_rails/builders/content_builder.rb +55 -0
  6. data/lib/oas_rails/builders/operation_builder.rb +32 -0
  7. data/lib/oas_rails/builders/parameter_builder.rb +28 -0
  8. data/lib/oas_rails/builders/parameters_builder.rb +39 -0
  9. data/lib/oas_rails/builders/path_item_builder.rb +22 -0
  10. data/lib/oas_rails/builders/request_body_builder.rb +60 -0
  11. data/lib/oas_rails/builders/response_builder.rb +40 -0
  12. data/lib/oas_rails/builders/responses_builder.rb +58 -0
  13. data/lib/oas_rails/configuration.rb +25 -5
  14. data/lib/oas_rails/esquema_builder.rb +37 -0
  15. data/lib/oas_rails/extractors/oas_route_extractor.rb +66 -0
  16. data/lib/oas_rails/extractors/render_response_extractor.rb +148 -0
  17. data/lib/oas_rails/extractors/route_extractor.rb +125 -0
  18. data/lib/oas_rails/oas_route.rb +1 -99
  19. data/lib/oas_rails/spec/components.rb +85 -0
  20. data/lib/oas_rails/spec/contact.rb +18 -0
  21. data/lib/oas_rails/spec/hashable.rb +39 -0
  22. data/lib/oas_rails/{info.rb → spec/info.rb} +30 -24
  23. data/lib/oas_rails/spec/license.rb +18 -0
  24. data/lib/oas_rails/spec/media_type.rb +84 -0
  25. data/lib/oas_rails/spec/operation.rb +25 -0
  26. data/lib/oas_rails/spec/parameter.rb +34 -0
  27. data/lib/oas_rails/spec/path_item.rb +33 -0
  28. data/lib/oas_rails/spec/paths.rb +26 -0
  29. data/lib/oas_rails/spec/reference.rb +16 -0
  30. data/lib/oas_rails/spec/request_body.rb +21 -0
  31. data/lib/oas_rails/spec/response.rb +20 -0
  32. data/lib/oas_rails/spec/responses.rb +25 -0
  33. data/lib/oas_rails/spec/server.rb +17 -0
  34. data/lib/oas_rails/spec/specable.rb +51 -0
  35. data/lib/oas_rails/spec/specification.rb +50 -0
  36. data/lib/oas_rails/spec/tag.rb +18 -0
  37. data/lib/oas_rails/utils.rb +39 -0
  38. data/lib/oas_rails/version.rb +1 -1
  39. data/lib/oas_rails.rb +47 -26
  40. metadata +32 -18
  41. data/lib/oas_rails/contact.rb +0 -12
  42. data/lib/oas_rails/license.rb +0 -11
  43. data/lib/oas_rails/media_type.rb +0 -76
  44. data/lib/oas_rails/oas_base.rb +0 -30
  45. data/lib/oas_rails/operation.rb +0 -134
  46. data/lib/oas_rails/parameter.rb +0 -47
  47. data/lib/oas_rails/path_item.rb +0 -25
  48. data/lib/oas_rails/paths.rb +0 -19
  49. data/lib/oas_rails/request_body.rb +0 -29
  50. data/lib/oas_rails/response.rb +0 -12
  51. data/lib/oas_rails/responses.rb +0 -20
  52. data/lib/oas_rails/route_extractor.rb +0 -119
  53. data/lib/oas_rails/server.rb +0 -10
  54. data/lib/oas_rails/specification.rb +0 -72
  55. data/lib/oas_rails/tag.rb +0 -17
@@ -1,11 +0,0 @@
1
- module OasRails
2
- class License < OasBase
3
- attr_accessor :name, :url
4
-
5
- def initialize(**kwargs)
6
- super()
7
- @name = kwargs[:name] || 'GPL 3.0'
8
- @url = kwargs[:url] || 'https://www.gnu.org/licenses/gpl-3.0.html#license-text'
9
- end
10
- end
11
- end
@@ -1,76 +0,0 @@
1
- module OasRails
2
- class MediaType < OasBase
3
- attr_accessor :schema, :example, :examples, :encoding
4
-
5
- def initialize(schema:, **kwargs)
6
- super()
7
- @schema = schema
8
- @example = kwargs[:example] || {}
9
- @examples = kwargs[:examples] || []
10
- end
11
-
12
- class << self
13
- def from_model_class(klass:, examples: {})
14
- return unless klass.ancestors.include? ActiveRecord::Base
15
-
16
- model_schema = Esquema::Builder.new(klass).build_schema.as_json
17
- model_schema["required"] = []
18
- schema = { type: "object", properties: { klass.to_s.downcase => model_schema } }
19
- examples.merge!(search_for_examples_in_tests(klass:))
20
- new(media_type: "", schema:, examples:)
21
- end
22
-
23
- # Searches for examples in test files based on the provided class and test framework.
24
- #
25
- # This method handles different test frameworks to fetch examples for the given class.
26
- # Currently, it supports FactoryBot and fixtures.
27
- #
28
- # @param klass [Class] the class to search examples for.
29
- # @param utils [Module] a utility module that provides the `detect_test_framework` method. Defaults to `Utils`.
30
- # @return [Hash] a hash containing examples data or an empty hash if no examples are found.
31
- # @example Usage with FactoryBot
32
- # search_for_examples_in_tests(klass: User)
33
- #
34
- # @example Usage with fixtures
35
- # search_for_examples_in_tests(klass: Project)
36
- #
37
- # @example Usage with a custom utils module
38
- # custom_utils = Module.new do
39
- # def self.detect_test_framework
40
- # :factory_bot
41
- # end
42
- # end
43
- # search_for_examples_in_tests(klass: User, utils: custom_utils)
44
- def search_for_examples_in_tests(klass:, utils: Utils)
45
- case utils.detect_test_framework
46
- when :factory_bot
47
- {}
48
- # TODO: create examples with FactoryBot
49
- when :fixtures
50
- fixture_file = Rails.root.join('test', 'fixtures', "#{klass.to_s.pluralize.downcase}.yml")
51
-
52
- begin
53
- fixture_data = YAML.load_file(fixture_file).with_indifferent_access
54
- rescue Errno::ENOENT
55
- return {}
56
- end
57
-
58
- fixture_data.transform_values { |attributes| { value: { klass.to_s.downcase => attributes } } }
59
- else
60
- {}
61
- end
62
- end
63
-
64
- def tags_to_examples(tags:)
65
- tags.each_with_object({}).with_index(1) do |(example, result), _index|
66
- key = example.text.downcase.gsub(' ', '_')
67
- value = {
68
- "summary" => example.text,
69
- "value" => example.content
70
- }
71
- result[key] = value
72
- end
73
- end
74
- end
75
- end
76
- end
@@ -1,30 +0,0 @@
1
- module OasRails
2
- class OasBase
3
- def to_spec
4
- hash = {}
5
- instance_variables.each do |var|
6
- key = var.to_s.delete('@')
7
- camel_case_key = key.camelize(:lower).to_sym
8
- value = instance_variable_get(var)
9
-
10
- processed_value = if value.respond_to?(:to_spec)
11
- value.to_spec
12
- else
13
- value
14
- end
15
-
16
- # hash[camel_case_key] = processed_value unless (processed_value.is_a?(Hash) || processed_value.is_a?(Array)) && processed_value.empty?
17
- hash[camel_case_key] = processed_value
18
- end
19
- hash
20
- end
21
-
22
- private
23
-
24
- def snake_to_camel(snake_str)
25
- words = snake_str.to_s.split('_')
26
- words[1..].map!(&:capitalize)
27
- (words[0] + words[1..].join).to_sym
28
- end
29
- end
30
- end
@@ -1,134 +0,0 @@
1
- module OasRails
2
- class Operation < OasBase
3
- attr_accessor :tags, :summary, :description, :operation_id, :parameters, :method, :docstring, :request_body, :responses, :security
4
-
5
- def initialize(method:, summary:, operation_id:, **kwargs)
6
- super()
7
- @method = method
8
- @summary = summary
9
- @operation_id = operation_id
10
- @tags = kwargs[:tags] || []
11
- @description = kwargs[:description] || @summary
12
- @parameters = kwargs[:parameters] || []
13
- @request_body = kwargs[:request_body] || {}
14
- @responses = kwargs[:responses] || {}
15
- @security = kwargs[:security] || []
16
- end
17
-
18
- class << self
19
- def from_oas_route(oas_route:)
20
- summary = extract_summary(oas_route:)
21
- operation_id = extract_operation_id(oas_route:)
22
- tags = extract_tags(oas_route:)
23
- description = oas_route.docstring
24
- parameters = extract_parameters(oas_route:)
25
- request_body = extract_request_body(oas_route:)
26
- responses = extract_responses(oas_route:)
27
- security = extract_security(oas_route:)
28
- new(method: oas_route.verb.downcase, summary:, operation_id:, tags:, description:, parameters:, request_body:, responses:, security:)
29
- end
30
-
31
- def extract_summary(oas_route:)
32
- oas_route.docstring.tags(:summary).first.try(:text) || generate_crud_name(oas_route.method, oas_route.controller.downcase) || oas_route.verb + " " + oas_route.path
33
- end
34
-
35
- def generate_crud_name(method, controller)
36
- controller_name = controller.to_s.underscore.humanize.downcase.pluralize
37
-
38
- case method.to_sym
39
- when :index
40
- "List #{controller_name}"
41
- when :show
42
- "View #{controller_name.singularize}"
43
- when :create
44
- "Create new #{controller_name.singularize}"
45
- when :update
46
- "Update #{controller_name.singularize}"
47
- when :destroy
48
- "Delete #{controller_name.singularize}"
49
- end
50
- end
51
-
52
- def extract_operation_id(oas_route:)
53
- "#{oas_route.method}#{oas_route.path.gsub('/', '_').gsub(/[{}]/, '')}"
54
- end
55
-
56
- # This method should check tags defined by yard, then extract tag from path namespace or controller name depending on configuration
57
- def extract_tags(oas_route:)
58
- tags = oas_route.docstring.tags(:tags).first
59
- if !tags.nil?
60
- tags.text.split(",").map(&:strip).map(&:titleize)
61
- else
62
- default_tags(oas_route:)
63
- end
64
- end
65
-
66
- def default_tags(oas_route:)
67
- tags = []
68
- if OasRails.config.default_tags_from == "namespace"
69
- tag = oas_route.path.split('/').reject(&:empty?).first.try(:titleize)
70
- tags << tag unless tag.nil?
71
- else
72
- tags << oas_route.controller.titleize
73
- end
74
- tags
75
- end
76
-
77
- def extract_parameters(oas_route:)
78
- parameters = []
79
- parameters.concat(parameters_from_tags(tags: oas_route.docstring.tags(:parameter)))
80
- oas_route.path_params.try(:map) do |p|
81
- parameters << Parameter.from_path(path: oas_route.path, param: p) unless parameters.any? { |param| param.name.to_s == p.to_s }
82
- end
83
- parameters
84
- end
85
-
86
- def parameters_from_tags(tags:)
87
- tags.map do |t|
88
- Parameter.new(name: t.name, location: t.location, required: t.required, schema: t.schema, description: t.text)
89
- end
90
- end
91
-
92
- def extract_request_body(oas_route:)
93
- tag_request_body = oas_route.docstring.tags(:request_body).first
94
- if tag_request_body.nil? && OasRails.config.autodiscover_request_body
95
- oas_route.detect_request_body if %w[create update].include? oas_route.method
96
- elsif !tag_request_body.nil?
97
- RequestBody.from_tags(tag: tag_request_body, examples_tags: oas_route.docstring.tags(:request_body_example))
98
- else
99
- {}
100
- end
101
- end
102
-
103
- def extract_responses(oas_route:)
104
- responses = Responses.from_tags(tags: oas_route.docstring.tags(:response))
105
-
106
- if OasRails.config.autodiscover_responses
107
- new_responses = oas_route.extract_responses_from_source
108
-
109
- new_responses.each do |new_response|
110
- responses.responses << new_response unless responses.responses.any? { |r| r.code == new_response.code }
111
- end
112
- end
113
-
114
- responses
115
- end
116
-
117
- def extract_security(oas_route:)
118
- return [] if oas_route.docstring.tags(:no_auth).any?
119
-
120
- if (methods = oas_route.docstring.tags(:auth).first)
121
- OasRails.config.security_schemas.keys.map { |key| { key => [] } }.select do |schema|
122
- methods.types.include?(schema.keys.first.to_s)
123
- end
124
- elsif OasRails.config.authenticate_all_routes_by_default
125
- OasRails.config.security_schemas.keys.map { |key| { key => [] } }
126
- else
127
- []
128
- end
129
- end
130
-
131
- def external_docs; end
132
- end
133
- end
134
- end
@@ -1,47 +0,0 @@
1
- module OasRails
2
- class Parameter
3
- STYLE_DEFAULTS = { query: 'form', path: 'simple', header: 'simple', cookie: 'form' }.freeze
4
-
5
- attr_accessor :name, :in, :style, :description, :required, :schema
6
-
7
- def initialize(name:, location:, description:, **kwargs)
8
- @name = name
9
- @in = location
10
- @description = description
11
-
12
- @required = kwargs[:required] || required?
13
- @style = kwargs[:style] || default_from_in
14
- @schema = kwargs[:schema] || { "type": 'string' }
15
- end
16
-
17
- def self.from_path(path:, param:)
18
- new(name: param, location: 'path',
19
- description: "#{param.split('_')[-1].titleize} of existing #{extract_word_before(path, param).singularize}.")
20
- end
21
-
22
- def self.extract_word_before(string, param)
23
- regex = %r{/(\w+)/\{#{param}\}}
24
- match = string.match(regex)
25
- match ? match[1] : nil
26
- end
27
-
28
- def default_from_in
29
- STYLE_DEFAULTS[@in.to_sym]
30
- end
31
-
32
- def required?
33
- @in == 'path'
34
- end
35
-
36
- def to_spec
37
- {
38
- "name": @name,
39
- "in": @in,
40
- "description": @description,
41
- "required": @required,
42
- "schema": @schema,
43
- "style": @style
44
- }
45
- end
46
- end
47
- end
@@ -1,25 +0,0 @@
1
- module OasRails
2
- class PathItem
3
- attr_reader :path, :operations, :parameters
4
-
5
- def initialize(path:, operations:, parameters:)
6
- @path = path
7
- @operations = operations
8
- @parameters = parameters
9
- end
10
-
11
- def self.from_oas_routes(path:, oas_routes:)
12
- new(path: path, operations: oas_routes.map do |oas_route|
13
- Operation.from_oas_route(oas_route: oas_route)
14
- end, parameters: [])
15
- end
16
-
17
- def to_spec
18
- spec = {}
19
- @operations.each do |o|
20
- spec[o.method] = o.to_spec
21
- end
22
- spec
23
- end
24
- end
25
- end
@@ -1,19 +0,0 @@
1
- module OasRails
2
- class Paths
3
- attr_accessor :path_items
4
-
5
- def initialize(path_items:)
6
- @path_items = path_items
7
- end
8
-
9
- def self.from_string_paths(string_paths:)
10
- new(path_items: string_paths.map do |s|
11
- PathItem.from_oas_routes(path: s, oas_routes: RouteExtractor.host_routes_by_path(s))
12
- end)
13
- end
14
-
15
- def to_spec
16
- @path_items.each_with_object({}) { |p, object| object[p.path] = p.to_spec }
17
- end
18
- end
19
- end
@@ -1,29 +0,0 @@
1
- module OasRails
2
- class RequestBody < OasBase
3
- attr_accessor :description, :content, :required
4
-
5
- def initialize(description:, content:, required: false)
6
- super()
7
- @description = description
8
- @content = content # Should be an array of media type object
9
- @required = required
10
- end
11
-
12
- class << self
13
- def from_tags(tag:, examples_tags: [])
14
- if tag.klass.ancestors.include? ActiveRecord::Base
15
- from_model_class(klass: tag.klass, description: tag.text, required: tag.required, examples_tags:)
16
- else
17
- # hash content to schema
18
- content = { "application/json": MediaType.new(schema: tag.schema, examples: MediaType.tags_to_examples(tags: examples_tags)) }
19
- new(description: tag.text, content:, required: tag.required)
20
- end
21
- end
22
-
23
- def from_model_class(klass:, **kwargs)
24
- content = { "application/json": MediaType.from_model_class(klass:, examples: MediaType.tags_to_examples(tags: kwargs[:examples_tags] || {})) }
25
- new(description: kwargs[:description] || klass.to_s, content:, required: kwargs[:required])
26
- end
27
- end
28
- end
29
- end
@@ -1,12 +0,0 @@
1
- module OasRails
2
- class Response < OasBase
3
- attr_accessor :code, :description, :content
4
-
5
- def initialize(code:, description:, content:)
6
- super()
7
- @code = code
8
- @description = description
9
- @content = content # Should be an array of media type object
10
- end
11
- end
12
- end
@@ -1,20 +0,0 @@
1
- module OasRails
2
- class Responses < OasBase
3
- attr_accessor :responses
4
-
5
- def initialize(responses)
6
- super()
7
- @responses = responses
8
- end
9
-
10
- def to_spec
11
- @responses.each_with_object({}) { |r, object| object[r.code] = r.to_spec }
12
- end
13
-
14
- class << self
15
- def from_tags(tags:)
16
- new(tags.map { |t| Response.new(code: t.name.to_i, description: t.text, content: { "application/json": MediaType.new(schema: t.schema) }) })
17
- end
18
- end
19
- end
20
- end
@@ -1,119 +0,0 @@
1
- module OasRails
2
- class RouteExtractor
3
- RAILS_DEFAULT_CONTROLLERS = %w[
4
- rails/info
5
- rails/mailers
6
- active_storage/blobs
7
- active_storage/disk
8
- active_storage/direct_uploads
9
- active_storage/representations
10
- rails/conductor/continuous_integration
11
- rails/conductor/multiple_databases
12
- rails/conductor/action_mailbox
13
- rails/conductor/action_text
14
- action_cable
15
- ].freeze
16
-
17
- RAILS_DEFAULT_PATHS = %w[
18
- /rails/action_mailbox/
19
- ].freeze
20
-
21
- class << self
22
- def host_routes_by_path(path)
23
- @host_routes ||= extract_host_routes
24
- @host_routes.select { |r| r.path == path }
25
- end
26
-
27
- def host_routes
28
- @host_routes ||= extract_host_routes
29
- end
30
-
31
- # Clear Class Instance Variable @host_routes
32
- #
33
- # This method clear the class instance variable @host_routes
34
- # to force a extraction of the routes again.
35
- def clear_cache
36
- @host_routes = nil
37
- end
38
-
39
- def host_paths
40
- @host_paths ||= host_routes.map(&:path).uniq.sort
41
- end
42
-
43
- def clean_route(route)
44
- route.gsub('(.:format)', '').gsub(/:\w+/) { |match| "{#{match[1..]}}" }
45
- end
46
-
47
- # THIS CODE IS NOT IN USE BUT CAN BE USEFULL WITH GLOBAL TAGS OR AUTH TAGS
48
- # def get_controller_comments(controller_path)
49
- # YARD.parse_string(File.read(controller_path))
50
- # controller_class = YARD::Registry.all(:class).first
51
- # if controller_class
52
- # class_comment = controller_class.docstring.all
53
- # method_comments = controller_class.meths.map do |method|
54
- # {
55
- # name: method.name,
56
- # comment: method.docstring.all
57
- # }
58
- # end
59
- # YARD::Registry.clear
60
- # {
61
- # class_comment: class_comment,
62
- # method_comments: method_comments
63
- # }
64
- # else
65
- # YARD::Registry.clear
66
- # nil
67
- # end
68
- # rescue StandardError
69
- # nil
70
- # end
71
- #
72
- # def get_controller_comment(controller_path)
73
- # get_controller_comments(controller_path)&.dig(:class_comment) || ''
74
- # rescue StandardError
75
- # ''
76
- # end
77
-
78
- private
79
-
80
- def extract_host_routes
81
- Rails.application.routes.routes.select do |route|
82
- valid_api_route?(route)
83
- end.map { |r| OasRoute.new_from_rails_route(rails_route: r) }
84
- end
85
-
86
- def valid_api_route?(route)
87
- return false unless valid_route_implementation?(route)
88
- return false if RAILS_DEFAULT_CONTROLLERS.any? { |default| route.defaults[:controller].start_with?(default) }
89
- return false if RAILS_DEFAULT_PATHS.any? { |path| route.path.spec.to_s.include?(path) }
90
- return false unless route.path.spec.to_s.start_with?(OasRails.config.api_path)
91
-
92
- true
93
- end
94
-
95
- # Checks if a route has a valid implementation.
96
- #
97
- # This method verifies that both the controller and the action specified
98
- # in the route exist. It checks if the controller class is defined and
99
- # if the action method is implemented within that controller.
100
- #
101
- # @param route [ActionDispatch::Journey::Route] The route to check.
102
- # @return [Boolean] true if both the controller and action exist, false otherwise.
103
- def valid_route_implementation?(route)
104
- controller_name = route.defaults[:controller]&.camelize
105
- action_name = route.defaults[:action]
106
-
107
- return false if controller_name.blank? || action_name.blank?
108
-
109
- controller_class = "#{controller_name}Controller".safe_constantize
110
-
111
- if controller_class.nil?
112
- false
113
- else
114
- controller_class.instance_methods.include?(action_name.to_sym)
115
- end
116
- end
117
- end
118
- end
119
- end
@@ -1,10 +0,0 @@
1
- module OasRails
2
- class Server < OasBase
3
- attr_accessor :url, :description
4
-
5
- def initialize(url:, description:)
6
- @url = url
7
- @description = description
8
- end
9
- end
10
- end
@@ -1,72 +0,0 @@
1
- require 'json'
2
-
3
- module OasRails
4
- class Specification
5
- # Initializes a new Specification object.
6
- # Clears the cache if running in the development environment.
7
- def initialize
8
- clear_cache unless Rails.env.production?
9
-
10
- @specification = base_spec
11
- end
12
-
13
- # Clears the cache for MethodSource and RouteExtractor.
14
- #
15
- # @return [void]
16
- def clear_cache
17
- MethodSource.clear_cache
18
- RouteExtractor.clear_cache
19
- end
20
-
21
- def to_json(*_args)
22
- @specification.to_json
23
- rescue StandardError => e
24
- Rails.logger.error("Error Generating OAS: #{e.message}")
25
- {}
26
- end
27
-
28
- # Create the Base of the OAS hash.
29
- # @see https://spec.openapis.org/oas/latest.html#schema
30
- def base_spec
31
- {
32
- openapi: '3.1.0',
33
- info: OasRails.config.info.to_spec,
34
- servers: OasRails.config.servers.map(&:to_spec),
35
- paths:,
36
- components:,
37
- security:,
38
- tags: OasRails.config.tags.map(&:to_spec),
39
- externalDocs: {}
40
- }
41
- end
42
-
43
- # Create the Security Requirement Object.
44
- # @see https://spec.openapis.org/oas/latest.html#security-requirement-object
45
- def security
46
- return [] unless OasRails.config.authenticate_all_routes_by_default
47
-
48
- OasRails.config.security_schemas.map { |key, _| { key => [] } }
49
- end
50
-
51
- # Create the Paths Object For the Root of the OAS.
52
- # @see https://spec.openapis.org/oas/latest.html#paths-object
53
- def paths
54
- Paths.from_string_paths(string_paths: RouteExtractor.host_paths).to_spec
55
- end
56
-
57
- # Created the Components Object For the Root of the OAS.
58
- # @see https://spec.openapis.org/oas/latest.html#components-object
59
- def components
60
- {
61
- schemas: {}, parameters: {}, securitySchemes: security_schemas, requestBodies: {}, responses: {},
62
- headers: {}, examples: {}, links: {}, callbacks: {}
63
- }
64
- end
65
-
66
- # Create the Security Schemas Array inside components field of the OAS.
67
- # @see https://spec.openapis.org/oas/latest.html#security-scheme-object
68
- def security_schemas
69
- OasRails.config.security_schemas
70
- end
71
- end
72
- end
data/lib/oas_rails/tag.rb DELETED
@@ -1,17 +0,0 @@
1
- module OasRails
2
- class Tag
3
- attr_accessor :name, :description
4
-
5
- def initialize(name:, description:)
6
- @name = name.titleize
7
- @description = description
8
- end
9
-
10
- def to_spec
11
- {
12
- name: @name,
13
- description: @description
14
- }
15
- end
16
- end
17
- end