oas_rails 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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +147 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/config/oas_rails_manifest.js +1 -0
  6. data/app/assets/stylesheets/oas_rails/application.css +15 -0
  7. data/app/controllers/oas_rails/application_controller.rb +4 -0
  8. data/app/controllers/oas_rails/oas_rails_controller.rb +9 -0
  9. data/app/helpers/oas_rails/application_helper.rb +4 -0
  10. data/app/helpers/oas_rails/oas_rails_helper.rb +4 -0
  11. data/app/helpers/oas_rails/test_helper.rb +4 -0
  12. data/app/jobs/oas_rails/application_job.rb +4 -0
  13. data/app/mailers/oas_rails/application_mailer.rb +6 -0
  14. data/app/models/oas_rails/application_record.rb +5 -0
  15. data/app/views/layouts/oas_rails/application.html.erb +18 -0
  16. data/app/views/oas_rails/oas_rails/index.html.erb +1 -0
  17. data/app/views/oas_rails/test/show.html.erb +1 -0
  18. data/config/routes.rb +4 -0
  19. data/lib/generators/oas_rails/config/config_generator.rb +11 -0
  20. data/lib/generators/oas_rails/config/templates/oas_rails_initializer.rb +49 -0
  21. data/lib/oas_rails/configuration.rb +28 -0
  22. data/lib/oas_rails/contact.rb +12 -0
  23. data/lib/oas_rails/engine.rb +5 -0
  24. data/lib/oas_rails/info.rb +60 -0
  25. data/lib/oas_rails/license.rb +11 -0
  26. data/lib/oas_rails/media_type.rb +57 -0
  27. data/lib/oas_rails/oas_base.rb +29 -0
  28. data/lib/oas_rails/oas_route.rb +143 -0
  29. data/lib/oas_rails/operation.rb +118 -0
  30. data/lib/oas_rails/parameter.rb +47 -0
  31. data/lib/oas_rails/path_item.rb +25 -0
  32. data/lib/oas_rails/paths.rb +19 -0
  33. data/lib/oas_rails/request_body.rb +29 -0
  34. data/lib/oas_rails/response.rb +12 -0
  35. data/lib/oas_rails/responses.rb +20 -0
  36. data/lib/oas_rails/route_extractor.rb +87 -0
  37. data/lib/oas_rails/server.rb +10 -0
  38. data/lib/oas_rails/specification.rb +42 -0
  39. data/lib/oas_rails/tag.rb +17 -0
  40. data/lib/oas_rails/version.rb +3 -0
  41. data/lib/oas_rails/yard/oas_yard_factory.rb +160 -0
  42. data/lib/oas_rails.rb +120 -0
  43. metadata +159 -0
@@ -0,0 +1,143 @@
1
+ module OasRails
2
+ class OasRoute
3
+ attr_accessor(:controller_class, :controller_action, :controller, :controller_path, :method, :verb, :path,
4
+ :rails_route, :docstring, :source_string)
5
+
6
+ def initialize; end
7
+
8
+ def self.new_from_rails_route(rails_route: ActionDispatch::Journey::Route)
9
+ instance = new
10
+ instance.rails_route = rails_route
11
+ instance.extract_rails_route_data
12
+ instance
13
+ end
14
+
15
+ def extract_rails_route_data
16
+ @controller_action = "#{@rails_route.defaults[:controller].camelize}Controller##{@rails_route.defaults[:action]}"
17
+ @controller_class = "#{@rails_route.defaults[:controller].camelize}Controller"
18
+ @controller = @rails_route.defaults[:controller]
19
+ @controller_path = controller_path_extractor(@rails_route.defaults[:controller])
20
+ @method = @rails_route.defaults[:action]
21
+ @verb = @rails_route.verb
22
+ @path = RouteExtractor.clean_route(@rails_route.path.spec.to_s)
23
+ @docstring = extract_docstring
24
+ @source_string = extract_source_string
25
+ end
26
+
27
+ def extract_docstring
28
+ YARD::Docstring.parser.parse(
29
+ controller_class.constantize.instance_method(method).comment.lines.map { |line| line.sub(/^#\s*/, '') }.join
30
+ ).to_docstring
31
+ end
32
+
33
+ def extract_source_string
34
+ @controller_class.constantize.instance_method(method).source
35
+ end
36
+
37
+ def path_params
38
+ @rails_route.path.spec.to_s.scan(/:(\w+)/).flatten.reject! { |e| e == 'format' }
39
+ end
40
+
41
+ def controller_path_extractor(controller)
42
+ Rails.root.join("app/controllers/#{controller}_controller.rb").to_s
43
+ end
44
+
45
+ def detect_request_body
46
+ klass = @controller.singularize.camelize.constantize
47
+ RequestBody.from_model_class(klass:, required: true)
48
+ end
49
+
50
+ def extract_responses_from_source
51
+ render_calls = @source_string.scan(/render json: ((?:\{.*?\}|\S+))(?:, status: :(\w+))?(?:,.*?)?$/m)
52
+
53
+ return [Response.new(code: 204, description: "No Content", content: {})] if render_calls.empty?
54
+
55
+ render_calls.map do |render_content, status|
56
+ content = render_content.strip
57
+
58
+ # TODO: manage when is an array of errors
59
+ schema = {}
60
+ begin
61
+ schema = if content.start_with?('{')
62
+ OasRails.hash_to_json_schema(parse_hash_structure(content))
63
+ else
64
+ # It's likely a variable or method call
65
+ maybe_a_model, errors = content.gsub('@', "").split(".")
66
+ klass = maybe_a_model.singularize.camelize(:upper).constantize
67
+ return {} unless klass.ancestors.include? ActiveRecord::Base
68
+
69
+ e = Esquema::Builder.new(klass).build_schema.as_json
70
+ if test_singularity(maybe_a_model)
71
+ if errors.nil?
72
+ e
73
+ else
74
+ {
75
+ type: "object",
76
+ properties: {
77
+ success: {
78
+ type: "boolean"
79
+ },
80
+ errors: {
81
+ type: "object",
82
+ additionalProperties: {
83
+ type: "array",
84
+ items: {
85
+ type: "string"
86
+ }
87
+ }
88
+ }
89
+ }
90
+ } end
91
+ else
92
+ { type: "array", items: e }
93
+ end
94
+ end
95
+ rescue StandardError => e
96
+ Rails.logger.debug("Error building schema: #{e.message}")
97
+ end
98
+
99
+ status_int = status_to_integer(status)
100
+ Response.new(code: status_int, description: status_code_to_text(status_int), content: { "application/json": MediaType.new(schema:) })
101
+ end
102
+ end
103
+
104
+ def test_singularity(str)
105
+ str.pluralize != str && str.singularize == str
106
+ end
107
+
108
+ def parse_hash_structure(hash_literal)
109
+ structure = {}
110
+
111
+ hash_literal.scan(/(\w+):\s*(\S+)/) do |key, value|
112
+ structure[key.to_sym] = case value
113
+ when 'true', 'false'
114
+ 'Boolean'
115
+ when /^\d+$/
116
+ 'Number'
117
+ when '@user.errors'
118
+ 'Object'
119
+ else
120
+ 'Object'
121
+ end
122
+ end
123
+
124
+ structure
125
+ end
126
+
127
+ def status_to_integer(status)
128
+ return 200 if status.nil?
129
+
130
+ if status.to_s =~ /^\d+$/
131
+ status.to_i
132
+ else
133
+ status = "unprocessable_content" if status == "unprocessable_entity"
134
+ Rack::Utils::SYMBOL_TO_STATUS_CODE[status.to_sym]
135
+
136
+ end
137
+ end
138
+
139
+ def status_code_to_text(status_code)
140
+ Rack::Utils::HTTP_STATUS_CODES[status_code] || "Unknown Status Code"
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,118 @@
1
+ module OasRails
2
+ class Operation < OasBase
3
+ attr_accessor :tags, :summary, :description, :operation_id, :parameters, :method, :docstring, :request_body, :responses
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
+ end
16
+
17
+ class << self
18
+ def from_oas_route(oas_route:)
19
+ summary = extract_summary(oas_route:)
20
+ operation_id = extract_operation_id(oas_route:)
21
+ tags = extract_tags(oas_route:)
22
+ description = oas_route.docstring
23
+ parameters = extract_parameters(oas_route:)
24
+ request_body = extract_request_body(oas_route:)
25
+ responses = extract_responses(oas_route:)
26
+ new(method: oas_route.verb.downcase, summary:, operation_id:, tags:, description:, parameters:, request_body:, responses:)
27
+ end
28
+
29
+ def extract_summary(oas_route:)
30
+ oas_route.docstring.tags(:summary).first.try(:text) || generate_crud_name(oas_route.method, oas_route.controller.downcase) || oas_route.verb + " " + oas_route.path
31
+ end
32
+
33
+ def generate_crud_name(method, controller)
34
+ controller_name = controller.to_s.underscore.humanize.downcase.pluralize
35
+
36
+ case method.to_sym
37
+ when :index
38
+ "List #{controller_name}"
39
+ when :show
40
+ "View #{controller_name.singularize}"
41
+ when :create
42
+ "Create new #{controller_name.singularize}"
43
+ when :update
44
+ "Update #{controller_name.singularize}"
45
+ when :destroy
46
+ "Delete #{controller_name.singularize}"
47
+ end
48
+ end
49
+
50
+ def extract_operation_id(oas_route:)
51
+ "#{oas_route.method}#{oas_route.path.gsub('/', '_').gsub(/[{}]/, '')}"
52
+ end
53
+
54
+ # This method should check tags defined by yard, then extract tag from path namespace or controller name depending on configuration
55
+ def extract_tags(oas_route:)
56
+ tags = oas_route.docstring.tags(:tags).first
57
+ if !tags.nil?
58
+ tags.text.split(",").map(&:strip).map(&:titleize)
59
+ else
60
+ default_tags(oas_route:)
61
+ end
62
+ end
63
+
64
+ def default_tags(oas_route:)
65
+ tags = []
66
+ if OasRails.config.default_tags_from == "namespace"
67
+ tag = oas_route.path.split('/').reject(&:empty?).first.try(:titleize)
68
+ tags << tag unless tag.nil?
69
+ else
70
+ tags << oas_route.controller.titleize
71
+ end
72
+ tags
73
+ end
74
+
75
+ def extract_parameters(oas_route:)
76
+ parameters = []
77
+ parameters.concat(parameters_from_tags(tags: oas_route.docstring.tags(:parameter)))
78
+ oas_route.path_params.try(:map) do |p|
79
+ parameters << Parameter.from_path(path: oas_route.path, param: p) unless parameters.any? { |param| param.name.to_s == p.to_s }
80
+ end
81
+ parameters
82
+ end
83
+
84
+ def parameters_from_tags(tags:)
85
+ tags.map do |t|
86
+ Parameter.new(name: t.name, location: t.location, required: t.required, schema: t.schema, description: t.text)
87
+ end
88
+ end
89
+
90
+ def extract_request_body(oas_route:)
91
+ tag_request_body = oas_route.docstring.tags(:request_body).first
92
+ if tag_request_body.nil? && OasRails.config.request_body_automatically
93
+ oas_route.detect_request_body if %w[create update].include? oas_route.method
94
+ elsif !tag_request_body.nil?
95
+ RequestBody.from_tags(tag: tag_request_body, examples_tags: oas_route.docstring.tags(:request_body_example))
96
+ else
97
+ {}
98
+ end
99
+ end
100
+
101
+ def extract_responses(oas_route:)
102
+ responses = Responses.from_tags(tags: oas_route.docstring.tags(:response))
103
+
104
+ if OasRails.config.autodiscover_responses
105
+ new_responses = oas_route.extract_responses_from_source
106
+
107
+ new_responses.each do |new_response|
108
+ responses.responses << new_response unless responses.responses.any? { |r| r.code == new_response.code }
109
+ end
110
+ end
111
+
112
+ responses
113
+ end
114
+
115
+ def external_docs; end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,29 @@
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
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,20 @@
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
@@ -0,0 +1,87 @@
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
+ def host_paths
32
+ @host_paths ||= host_routes.map(&:path).uniq.sort
33
+ end
34
+
35
+ def clean_route(route)
36
+ route.gsub('(.:format)', '').gsub(/:\w+/) { |match| "{#{match[1..]}}" }
37
+ end
38
+
39
+ # THIS CODE IS NOT IN USE BUT CAN BE USEFULL WITH GLOBAL TAGS OR AUTH TAGS
40
+ # def get_controller_comments(controller_path)
41
+ # YARD.parse_string(File.read(controller_path))
42
+ # controller_class = YARD::Registry.all(:class).first
43
+ # if controller_class
44
+ # class_comment = controller_class.docstring.all
45
+ # method_comments = controller_class.meths.map do |method|
46
+ # {
47
+ # name: method.name,
48
+ # comment: method.docstring.all
49
+ # }
50
+ # end
51
+ # YARD::Registry.clear
52
+ # {
53
+ # class_comment: class_comment,
54
+ # method_comments: method_comments
55
+ # }
56
+ # else
57
+ # YARD::Registry.clear
58
+ # nil
59
+ # end
60
+ # rescue StandardError
61
+ # nil
62
+ # end
63
+ #
64
+ # def get_controller_comment(controller_path)
65
+ # get_controller_comments(controller_path)&.dig(:class_comment) || ''
66
+ # rescue StandardError
67
+ # ''
68
+ # end
69
+
70
+ private
71
+
72
+ def extract_host_routes
73
+ Rails.application.routes.routes.select do |route|
74
+ valid_api_route?(route)
75
+ end.map { |r| OasRoute.new_from_rails_route(rails_route: r) }
76
+ end
77
+
78
+ def valid_api_route?(route)
79
+ return false if route.defaults[:controller].nil?
80
+ return false if RAILS_DEFAULT_CONTROLLERS.any? { |default| route.defaults[:controller].start_with?(default) }
81
+ return false if RAILS_DEFAULT_PATHS.any? { |path| route.path.spec.to_s.include?(path) }
82
+
83
+ true
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,10 @@
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
@@ -0,0 +1,42 @@
1
+ require 'json'
2
+
3
+ module OasRails
4
+ class Specification
5
+ def initialize
6
+ OasRails.configure_esquema!
7
+ OasRails.configure_yard!
8
+ @specification = base_spec
9
+ end
10
+
11
+ def to_json(*_args)
12
+ @specification.to_json
13
+ rescue StandardError => e
14
+ Rails.logger.error("Error Generating OAS: #{e.message}")
15
+ {}
16
+ end
17
+
18
+ def base_spec
19
+ {
20
+ openapi: '3.1.0',
21
+ info: OasRails.config.info.to_spec,
22
+ servers: OasRails.config.servers.map(&:to_spec),
23
+ paths: paths_spec,
24
+ components: components_spec,
25
+ security: [],
26
+ tags: OasRails.config.tags.map(&:to_spec),
27
+ externalDocs: {}
28
+ }
29
+ end
30
+
31
+ def paths_spec
32
+ Paths.from_string_paths(string_paths: RouteExtractor.host_paths).to_spec
33
+ end
34
+
35
+ def components_spec
36
+ {
37
+ schemas: {}, parameters: {}, securitySchemas: {}, requestBodies: {}, responses: {},
38
+ headers: {}, examples: {}, links: {}, callbacks: {}
39
+ }
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,3 @@
1
+ module OasRails
2
+ VERSION = "0.1.0"
3
+ end