oas_rails 0.2.3 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/oas_rails/configuration.rb +8 -0
- data/lib/oas_rails/esquema_builder.rb +37 -0
- data/lib/oas_rails/extractors/render_response_extractor.rb +173 -0
- data/lib/oas_rails/extractors/route_extractor.rb +125 -0
- data/lib/oas_rails/media_type.rb +57 -31
- data/lib/oas_rails/oas_route.rb +1 -94
- data/lib/oas_rails/operation.rb +1 -1
- data/lib/oas_rails/paths.rb +1 -1
- data/lib/oas_rails/specification.rb +2 -2
- data/lib/oas_rails/version.rb +1 -1
- data/lib/oas_rails.rb +6 -10
- metadata +5 -3
- data/lib/oas_rails/route_extractor.rb +0 -119
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c9e40d4f8c810e300bbccaf190fd5143e5be804d2d30f1e86393e0478055965f
|
4
|
+
data.tar.gz: 0b6de20a5280115b3550d950da6e26d8424f4d52cc15d6f46f5272224831904e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 832e56a728da8974e07d2e83970f6cfd69b892b2e3a89694e08783520ae50698982a59119cf8497d0f2827ca0a4a443a04b80ef03838fe27c596c8dce30a0c1b
|
7
|
+
data.tar.gz: 63d37307fc03dd0d832586ce9e9ae4bdbc1f5480b7624991b7d1aa44323bb1f94c86ccd94a523b31e67b0f3f91fb08cfed00793529294c071192b7079bd4a641
|
data/README.md
CHANGED
@@ -6,7 +6,7 @@
|
|
6
6
|
|
7
7
|
OasRails is a Rails engine for generating **automatic interactive documentation for your Rails APIs**. It generates an **OAS 3.1** document and displays it using **[RapiDoc](https://rapidocweb.com)**.
|
8
8
|
|
9
|
-
![Screenshot](https://
|
9
|
+
![Screenshot](https://a-chacon.com/assets/images/oas_rails_ui.png)
|
10
10
|
|
11
11
|
## Related Projects
|
12
12
|
|
@@ -34,6 +34,14 @@ module OasRails
|
|
34
34
|
def tags=(value)
|
35
35
|
@tags = value.map { |t| Tag.new(name: t[:name], description: t[:description]) }
|
36
36
|
end
|
37
|
+
|
38
|
+
def excluded_columns_incoming
|
39
|
+
%i[id created_at updated_at deleted_at]
|
40
|
+
end
|
41
|
+
|
42
|
+
def excluded_columns_outgoing
|
43
|
+
[]
|
44
|
+
end
|
37
45
|
end
|
38
46
|
|
39
47
|
DEFAULT_SECURITY_SCHEMES = {
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module OasRails
|
2
|
+
module EsquemaBuilder
|
3
|
+
class << self
|
4
|
+
# Builds a schema for a class when it is used as incoming API data.
|
5
|
+
#
|
6
|
+
# @param klass [Class] The class for which the schema is built.
|
7
|
+
# @return [Hash] The schema as a JSON-compatible hash.
|
8
|
+
def build_incoming_schema(klass:)
|
9
|
+
configure_common_settings
|
10
|
+
Esquema.configuration.excluded_columns = OasRails.config.excluded_columns_incoming
|
11
|
+
|
12
|
+
Esquema::Builder.new(klass).build_schema.as_json
|
13
|
+
end
|
14
|
+
|
15
|
+
# Builds a schema for a class when it is used as outgoing API data.
|
16
|
+
#
|
17
|
+
# @param klass [Class] The class for which the schema is built.
|
18
|
+
# @return [Hash] The schema as a JSON-compatible hash.
|
19
|
+
def build_outgoing_schema(klass:)
|
20
|
+
configure_common_settings
|
21
|
+
Esquema.configuration.excluded_columns = OasRails.config.excluded_columns_outgoing
|
22
|
+
|
23
|
+
Esquema::Builder.new(klass).build_schema.as_json
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
# Configures common settings for schema building.
|
29
|
+
#
|
30
|
+
# Excludes associations and foreign keys from the schema.
|
31
|
+
def configure_common_settings
|
32
|
+
Esquema.configuration.exclude_associations = true
|
33
|
+
Esquema.configuration.exclude_foreign_keys = true
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,173 @@
|
|
1
|
+
module OasRails
|
2
|
+
module Extractors
|
3
|
+
# Extracts and processes render responses from a given source.
|
4
|
+
module RenderResponseExtractor
|
5
|
+
class << self
|
6
|
+
# Extracts responses from the provided source string.
|
7
|
+
#
|
8
|
+
# @param source [String] The source string containing render calls.
|
9
|
+
# @return [Array<Response>] An array of Response objects extracted from the source.
|
10
|
+
def extract_responses_from_source(source:)
|
11
|
+
render_calls = extract_render_calls(source)
|
12
|
+
|
13
|
+
return [Response.new(code: 204, description: "No Content", content: {})] if render_calls.empty?
|
14
|
+
|
15
|
+
render_calls.map { |render_content, status| process_render_content(render_content.strip, status) }
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
# Extracts render calls from the source string.
|
21
|
+
#
|
22
|
+
# @param source [String] The source string containing render calls.
|
23
|
+
# @return [Array<Array<String, String>>] An array of arrays, each containing render content and status.
|
24
|
+
def extract_render_calls(source)
|
25
|
+
source.scan(/render json: ((?:\{.*?\}|\S+))(?:, status: :(\w+))?(?:,.*?)?$/m)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Processes the render content and status to build a Response object.
|
29
|
+
#
|
30
|
+
# @param content [String] The content extracted from the render call.
|
31
|
+
# @param status [String] The status code associated with the render call.
|
32
|
+
# @return [Response] A Response object based on the processed content and status.
|
33
|
+
def process_render_content(content, status)
|
34
|
+
schema, examples = build_schema_and_examples(content)
|
35
|
+
status_int = status_to_integer(status)
|
36
|
+
Response.new(
|
37
|
+
code: status_int,
|
38
|
+
description: status_code_to_text(status_int),
|
39
|
+
content: { "application/json": MediaType.new(schema:, examples:) }
|
40
|
+
)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Builds schema and examples based on the content type.
|
44
|
+
#
|
45
|
+
# @param content [String] The content extracted from the render call.
|
46
|
+
# @return [Array<Hash, Hash>] An array where the first element is the schema and the second is the examples.
|
47
|
+
def build_schema_and_examples(content)
|
48
|
+
if content.start_with?('{')
|
49
|
+
[Utils.hash_to_json_schema(parse_hash_structure(content)), {}]
|
50
|
+
else
|
51
|
+
process_non_hash_content(content)
|
52
|
+
end
|
53
|
+
rescue StandardError => e
|
54
|
+
Rails.logger.debug("Error building schema: #{e.message}")
|
55
|
+
[{}]
|
56
|
+
end
|
57
|
+
|
58
|
+
# Processes non-hash content (e.g., model or method calls) to build schema and examples.
|
59
|
+
#
|
60
|
+
# @param content [String] The content extracted from the render call.
|
61
|
+
# @return [Array<Hash, Hash>] An array where the first element is the schema and the second is the examples.
|
62
|
+
def process_non_hash_content(content)
|
63
|
+
maybe_a_model, errors = content.gsub('@', "").split(".")
|
64
|
+
klass = maybe_a_model.singularize.camelize(:upper).constantize
|
65
|
+
|
66
|
+
if klass.ancestors.include?(ActiveRecord::Base)
|
67
|
+
schema = EsquemaBuilder.build_outgoing_schema(klass:)
|
68
|
+
if test_singularity(maybe_a_model)
|
69
|
+
build_singular_model_schema_and_examples(maybe_a_model, errors, klass, schema)
|
70
|
+
else
|
71
|
+
build_array_model_schema_and_examples(maybe_a_model, klass, schema)
|
72
|
+
end
|
73
|
+
else
|
74
|
+
[{}]
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# Builds schema and examples for singular models.
|
79
|
+
#
|
80
|
+
# @param maybe_a_model [String] The model name or variable.
|
81
|
+
# @param errors [String, nil] Errors related to the model.
|
82
|
+
# @param klass [Class] The class associated with the model.
|
83
|
+
# @param schema [Hash] The schema for the model.
|
84
|
+
# @return [Array<Hash, Hash>] An array where the first element is the schema and the second is the examples.
|
85
|
+
def build_singular_model_schema_and_examples(_maybe_a_model, errors, klass, schema)
|
86
|
+
if errors.nil?
|
87
|
+
[schema, MediaType.search_for_examples_in_tests(klass:, context: :outgoing)]
|
88
|
+
else
|
89
|
+
[
|
90
|
+
{
|
91
|
+
type: "object",
|
92
|
+
properties: {
|
93
|
+
success: { type: "boolean" },
|
94
|
+
errors: {
|
95
|
+
type: "object",
|
96
|
+
additionalProperties: {
|
97
|
+
type: "array",
|
98
|
+
items: { type: "string" }
|
99
|
+
}
|
100
|
+
}
|
101
|
+
}
|
102
|
+
},
|
103
|
+
{}
|
104
|
+
]
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# Builds schema and examples for array models.
|
109
|
+
#
|
110
|
+
# @param maybe_a_model [String] The model name or variable.
|
111
|
+
# @param klass [Class] The class associated with the model.
|
112
|
+
# @param schema [Hash] The schema for the model.
|
113
|
+
# @return [Array<Hash, Hash>] An array where the first element is the schema and the second is the examples.
|
114
|
+
def build_array_model_schema_and_examples(maybe_a_model, klass, schema)
|
115
|
+
examples = { maybe_a_model => { value: MediaType.search_for_examples_in_tests(klass:, context: :outgoing).values.map { |p| p.dig(:value, maybe_a_model.singularize.to_sym) } } }
|
116
|
+
[{ type: "array", items: schema }, examples]
|
117
|
+
end
|
118
|
+
|
119
|
+
# Determines if a string represents a singular model.
|
120
|
+
#
|
121
|
+
# @param str [String] The string to test.
|
122
|
+
# @return [Boolean] True if the string is a singular model, false otherwise.
|
123
|
+
def test_singularity(str)
|
124
|
+
str.pluralize != str && str.singularize == str
|
125
|
+
end
|
126
|
+
|
127
|
+
# Parses a hash literal to determine its structure.
|
128
|
+
#
|
129
|
+
# @param hash_literal [String] The hash literal string.
|
130
|
+
# @return [Hash<Symbol, String>] A hash representing the structure of the input.
|
131
|
+
def parse_hash_structure(hash_literal)
|
132
|
+
structure = {}
|
133
|
+
|
134
|
+
hash_literal.scan(/(\w+):\s*(\S+)/) do |key, value|
|
135
|
+
structure[key.to_sym] = case value
|
136
|
+
when 'true', 'false'
|
137
|
+
'Boolean'
|
138
|
+
when /^\d+$/
|
139
|
+
'Number'
|
140
|
+
else
|
141
|
+
'Object'
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
structure
|
146
|
+
end
|
147
|
+
|
148
|
+
# Converts a status symbol or string to an integer.
|
149
|
+
#
|
150
|
+
# @param status [String, Symbol, nil] The status to convert.
|
151
|
+
# @return [Integer] The status code as an integer.
|
152
|
+
def status_to_integer(status)
|
153
|
+
return 200 if status.nil?
|
154
|
+
|
155
|
+
if status.to_s =~ /^\d+$/
|
156
|
+
status.to_i
|
157
|
+
else
|
158
|
+
status = "unprocessable_content" if status == "unprocessable_entity"
|
159
|
+
Rack::Utils::SYMBOL_TO_STATUS_CODE[status.to_sym]
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
# Converts a status code to its corresponding text description.
|
164
|
+
#
|
165
|
+
# @param status_code [Integer] The status code.
|
166
|
+
# @return [String] The text description of the status code.
|
167
|
+
def status_code_to_text(status_code)
|
168
|
+
Rack::Utils::HTTP_STATUS_CODES[status_code] || "Unknown Status Code"
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
module OasRails
|
2
|
+
module Extractors
|
3
|
+
class RouteExtractor
|
4
|
+
RAILS_DEFAULT_CONTROLLERS = %w[
|
5
|
+
rails/info
|
6
|
+
rails/mailers
|
7
|
+
active_storage/blobs
|
8
|
+
active_storage/disk
|
9
|
+
active_storage/direct_uploads
|
10
|
+
active_storage/representations
|
11
|
+
rails/conductor/continuous_integration
|
12
|
+
rails/conductor/multiple_databases
|
13
|
+
rails/conductor/action_mailbox
|
14
|
+
rails/conductor/action_text
|
15
|
+
action_cable
|
16
|
+
].freeze
|
17
|
+
|
18
|
+
RAILS_DEFAULT_PATHS = %w[
|
19
|
+
/rails/action_mailbox/
|
20
|
+
].freeze
|
21
|
+
|
22
|
+
class << self
|
23
|
+
def host_routes_by_path(path)
|
24
|
+
@host_routes ||= extract_host_routes
|
25
|
+
@host_routes.select { |r| r.path == path }
|
26
|
+
end
|
27
|
+
|
28
|
+
def host_routes
|
29
|
+
@host_routes ||= extract_host_routes
|
30
|
+
end
|
31
|
+
|
32
|
+
# Clear Class Instance Variable @host_routes
|
33
|
+
#
|
34
|
+
# This method clear the class instance variable @host_routes
|
35
|
+
# to force a extraction of the routes again.
|
36
|
+
def clear_cache
|
37
|
+
@host_routes = nil
|
38
|
+
end
|
39
|
+
|
40
|
+
def host_paths
|
41
|
+
@host_paths ||= host_routes.map(&:path).uniq.sort
|
42
|
+
end
|
43
|
+
|
44
|
+
def clean_route(route)
|
45
|
+
route.gsub('(.:format)', '').gsub(/:\w+/) { |match| "{#{match[1..]}}" }
|
46
|
+
end
|
47
|
+
|
48
|
+
# THIS CODE IS NOT IN USE BUT CAN BE USEFULL WITH GLOBAL TAGS OR AUTH TAGS
|
49
|
+
# def get_controller_comments(controller_path)
|
50
|
+
# YARD.parse_string(File.read(controller_path))
|
51
|
+
# controller_class = YARD::Registry.all(:class).first
|
52
|
+
# if controller_class
|
53
|
+
# class_comment = controller_class.docstring.all
|
54
|
+
# method_comments = controller_class.meths.map do |method|
|
55
|
+
# {
|
56
|
+
# name: method.name,
|
57
|
+
# comment: method.docstring.all
|
58
|
+
# }
|
59
|
+
# end
|
60
|
+
# YARD::Registry.clear
|
61
|
+
# {
|
62
|
+
# class_comment: class_comment,
|
63
|
+
# method_comments: method_comments
|
64
|
+
# }
|
65
|
+
# else
|
66
|
+
# YARD::Registry.clear
|
67
|
+
# nil
|
68
|
+
# end
|
69
|
+
# rescue StandardError
|
70
|
+
# nil
|
71
|
+
# end
|
72
|
+
#
|
73
|
+
# def get_controller_comment(controller_path)
|
74
|
+
# get_controller_comments(controller_path)&.dig(:class_comment) || ''
|
75
|
+
# rescue StandardError
|
76
|
+
# ''
|
77
|
+
# end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
def extract_host_routes
|
82
|
+
valid_routes.map { |r| OasRoute.new_from_rails_route(rails_route: r) }
|
83
|
+
end
|
84
|
+
|
85
|
+
def valid_routes
|
86
|
+
Rails.application.routes.routes.select do |route|
|
87
|
+
valid_api_route?(route)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def valid_api_route?(route)
|
92
|
+
return false unless valid_route_implementation?(route)
|
93
|
+
return false if RAILS_DEFAULT_CONTROLLERS.any? { |default| route.defaults[:controller].start_with?(default) }
|
94
|
+
return false if RAILS_DEFAULT_PATHS.any? { |path| route.path.spec.to_s.include?(path) }
|
95
|
+
return false unless route.path.spec.to_s.start_with?(OasRails.config.api_path)
|
96
|
+
|
97
|
+
true
|
98
|
+
end
|
99
|
+
|
100
|
+
# Checks if a route has a valid implementation.
|
101
|
+
#
|
102
|
+
# This method verifies that both the controller and the action specified
|
103
|
+
# in the route exist. It checks if the controller class is defined and
|
104
|
+
# if the action method is implemented within that controller.
|
105
|
+
#
|
106
|
+
# @param route [ActionDispatch::Journey::Route] The route to check.
|
107
|
+
# @return [Boolean] true if both the controller and action exist, false otherwise.
|
108
|
+
def valid_route_implementation?(route)
|
109
|
+
controller_name = route.defaults[:controller]&.camelize
|
110
|
+
action_name = route.defaults[:action]
|
111
|
+
|
112
|
+
return false if controller_name.blank? || action_name.blank?
|
113
|
+
|
114
|
+
controller_class = "#{controller_name}Controller".safe_constantize
|
115
|
+
|
116
|
+
if controller_class.nil?
|
117
|
+
false
|
118
|
+
else
|
119
|
+
controller_class.instance_methods.include?(action_name.to_sym)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
data/lib/oas_rails/media_type.rb
CHANGED
@@ -2,18 +2,29 @@ module OasRails
|
|
2
2
|
class MediaType < OasBase
|
3
3
|
attr_accessor :schema, :example, :examples, :encoding
|
4
4
|
|
5
|
+
# Initializes a new MediaType object.
|
6
|
+
#
|
7
|
+
# @param schema [Hash] the schema of the media type.
|
8
|
+
# @param kwargs [Hash] additional keyword arguments.
|
5
9
|
def initialize(schema:, **kwargs)
|
6
10
|
super()
|
7
11
|
@schema = schema
|
8
12
|
@example = kwargs[:example] || {}
|
9
|
-
@examples = kwargs[:examples] ||
|
13
|
+
@examples = kwargs[:examples] || {}
|
10
14
|
end
|
11
15
|
|
12
16
|
class << self
|
13
|
-
|
17
|
+
@context = :incoming
|
18
|
+
# Creates a new MediaType object from a model class.
|
19
|
+
#
|
20
|
+
# @param klass [Class] the ActiveRecord model class.
|
21
|
+
# @param examples [Hash] the examples hash.
|
22
|
+
# @return [MediaType, nil] the created MediaType object or nil if the class is not an ActiveRecord model.
|
23
|
+
def from_model_class(klass:, context: :incoming, examples: {})
|
24
|
+
@context = context
|
14
25
|
return unless klass.ancestors.include? ActiveRecord::Base
|
15
26
|
|
16
|
-
model_schema =
|
27
|
+
model_schema = EsquemaBuilder.send("build_#{@context}_schema", klass:)
|
17
28
|
model_schema["required"] = []
|
18
29
|
schema = { type: "object", properties: { klass.to_s.downcase => model_schema } }
|
19
30
|
examples.merge!(search_for_examples_in_tests(klass:))
|
@@ -22,45 +33,25 @@ module OasRails
|
|
22
33
|
|
23
34
|
# Searches for examples in test files based on the provided class and test framework.
|
24
35
|
#
|
25
|
-
# This method handles different test frameworks to fetch examples for the given class.
|
26
|
-
# Currently, it supports FactoryBot and fixtures.
|
27
|
-
#
|
28
36
|
# @param klass [Class] the class to search examples for.
|
29
37
|
# @param utils [Module] a utility module that provides the `detect_test_framework` method. Defaults to `Utils`.
|
30
38
|
# @return [Hash] a hash containing examples data or an empty hash if no examples are found.
|
31
|
-
|
32
|
-
|
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)
|
39
|
+
def search_for_examples_in_tests(klass:, context: :incoming, utils: Utils)
|
40
|
+
@context = context
|
45
41
|
case utils.detect_test_framework
|
46
42
|
when :factory_bot
|
47
|
-
|
48
|
-
# TODO: create examples with FactoryBot
|
43
|
+
fetch_factory_bot_examples(klass:)
|
49
44
|
when :fixtures
|
50
|
-
|
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 } } }
|
45
|
+
fetch_fixture_examples(klass:)
|
59
46
|
else
|
60
47
|
{}
|
61
48
|
end
|
62
49
|
end
|
63
50
|
|
51
|
+
# Transforms tags into examples.
|
52
|
+
#
|
53
|
+
# @param tags [Array] the array of tags.
|
54
|
+
# @return [Hash] the transformed examples hash.
|
64
55
|
def tags_to_examples(tags:)
|
65
56
|
tags.each_with_object({}).with_index(1) do |(example, result), _index|
|
66
57
|
key = example.text.downcase.gsub(' ', '_')
|
@@ -71,6 +62,41 @@ module OasRails
|
|
71
62
|
result[key] = value
|
72
63
|
end
|
73
64
|
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
# Fetches examples from FactoryBot for the provided class.
|
69
|
+
#
|
70
|
+
# @param klass [Class] the class to fetch examples for.
|
71
|
+
# @return [Hash] a hash containing examples data or an empty hash if no examples are found.
|
72
|
+
def fetch_factory_bot_examples(klass:)
|
73
|
+
klass_sym = klass.to_s.downcase.to_sym
|
74
|
+
begin
|
75
|
+
FactoryBot.build_stubbed_list(klass_sym, 3).each_with_index.to_h do |obj, index|
|
76
|
+
["#{klass_sym}#{index + 1}", { value: { klass_sym => clean_example_object(obj: obj.as_json) } }]
|
77
|
+
end
|
78
|
+
rescue KeyError
|
79
|
+
{}
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Fetches examples from fixtures for the provided class.
|
84
|
+
#
|
85
|
+
# @param klass [Class] the class to fetch examples for.
|
86
|
+
# @return [Hash] a hash containing examples data or an empty hash if no examples are found.
|
87
|
+
def fetch_fixture_examples(klass:)
|
88
|
+
fixture_file = Rails.root.join('test', 'fixtures', "#{klass.to_s.pluralize.downcase}.yml")
|
89
|
+
begin
|
90
|
+
fixture_data = YAML.load_file(fixture_file).with_indifferent_access
|
91
|
+
rescue Errno::ENOENT
|
92
|
+
return {}
|
93
|
+
end
|
94
|
+
fixture_data.transform_values { |attributes| { value: { klass.to_s.downcase => clean_example_object(obj: attributes) } } }
|
95
|
+
end
|
96
|
+
|
97
|
+
def clean_example_object(obj:)
|
98
|
+
obj.reject { |key, _| OasRails.config.send("excluded_columns_#{@context}").include?(key.to_sym) }
|
99
|
+
end
|
74
100
|
end
|
75
101
|
end
|
76
102
|
end
|
data/lib/oas_rails/oas_route.rb
CHANGED
@@ -19,7 +19,7 @@ module OasRails
|
|
19
19
|
@controller_path = controller_path_extractor(@rails_route.defaults[:controller])
|
20
20
|
@method = @rails_route.defaults[:action]
|
21
21
|
@verb = @rails_route.verb
|
22
|
-
@path = RouteExtractor.clean_route(@rails_route.path.spec.to_s)
|
22
|
+
@path = Extractors::RouteExtractor.clean_route(@rails_route.path.spec.to_s)
|
23
23
|
@docstring = extract_docstring
|
24
24
|
@source_string = extract_source_string
|
25
25
|
end
|
@@ -46,98 +46,5 @@ module OasRails
|
|
46
46
|
klass = @controller.singularize.camelize.constantize
|
47
47
|
RequestBody.from_model_class(klass:, required: true)
|
48
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
|
-
Utils.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
49
|
end
|
143
50
|
end
|
data/lib/oas_rails/operation.rb
CHANGED
@@ -104,7 +104,7 @@ module OasRails
|
|
104
104
|
responses = Responses.from_tags(tags: oas_route.docstring.tags(:response))
|
105
105
|
|
106
106
|
if OasRails.config.autodiscover_responses
|
107
|
-
new_responses = oas_route.
|
107
|
+
new_responses = Extractors::RenderResponseExtractor.extract_responses_from_source(source: oas_route.source_string)
|
108
108
|
|
109
109
|
new_responses.each do |new_response|
|
110
110
|
responses.responses << new_response unless responses.responses.any? { |r| r.code == new_response.code }
|
data/lib/oas_rails/paths.rb
CHANGED
@@ -8,7 +8,7 @@ module OasRails
|
|
8
8
|
|
9
9
|
def self.from_string_paths(string_paths:)
|
10
10
|
new(path_items: string_paths.map do |s|
|
11
|
-
PathItem.from_oas_routes(path: s, oas_routes: RouteExtractor.host_routes_by_path(s))
|
11
|
+
PathItem.from_oas_routes(path: s, oas_routes: Extractors::RouteExtractor.host_routes_by_path(s))
|
12
12
|
end)
|
13
13
|
end
|
14
14
|
|
@@ -15,7 +15,7 @@ module OasRails
|
|
15
15
|
# @return [void]
|
16
16
|
def clear_cache
|
17
17
|
MethodSource.clear_cache
|
18
|
-
RouteExtractor.clear_cache
|
18
|
+
Extractors::RouteExtractor.clear_cache
|
19
19
|
end
|
20
20
|
|
21
21
|
def to_json(*_args)
|
@@ -51,7 +51,7 @@ module OasRails
|
|
51
51
|
# Create the Paths Object For the Root of the OAS.
|
52
52
|
# @see https://spec.openapis.org/oas/latest.html#paths-object
|
53
53
|
def paths
|
54
|
-
Paths.from_string_paths(string_paths: RouteExtractor.host_paths).to_spec
|
54
|
+
Paths.from_string_paths(string_paths: Extractors::RouteExtractor.host_paths).to_spec
|
55
55
|
end
|
56
56
|
|
57
57
|
# Created the Components Object For the Root of the OAS.
|
data/lib/oas_rails/version.rb
CHANGED
data/lib/oas_rails.rb
CHANGED
@@ -9,7 +9,6 @@ module OasRails
|
|
9
9
|
autoload :OasBase, "oas_rails/oas_base"
|
10
10
|
autoload :Configuration, "oas_rails/configuration"
|
11
11
|
autoload :Specification, "oas_rails/specification"
|
12
|
-
autoload :RouteExtractor, "oas_rails/route_extractor"
|
13
12
|
autoload :OasRoute, "oas_rails/oas_route"
|
14
13
|
autoload :Operation, "oas_rails/operation"
|
15
14
|
autoload :Info, "oas_rails/info"
|
@@ -26,15 +25,20 @@ module OasRails
|
|
26
25
|
autoload :Responses, "oas_rails/responses"
|
27
26
|
|
28
27
|
autoload :Utils, "oas_rails/utils"
|
28
|
+
autoload :EsquemaBuilder, "oas_rails/esquema_builder"
|
29
29
|
|
30
30
|
module YARD
|
31
31
|
autoload :OasYARDFactory, 'oas_rails/yard/oas_yard_factory'
|
32
32
|
end
|
33
33
|
|
34
|
+
module Extractors
|
35
|
+
autoload :RenderResponseExtractor, 'oas_rails/extractors/render_response_extractor'
|
36
|
+
autoload :RouteExtractor, "oas_rails/extractors/route_extractor"
|
37
|
+
end
|
38
|
+
|
34
39
|
class << self
|
35
40
|
# Configurations for make the OasRails engine Work.
|
36
41
|
def configure
|
37
|
-
OasRails.configure_esquema!
|
38
42
|
OasRails.configure_yard!
|
39
43
|
yield config
|
40
44
|
end
|
@@ -59,13 +63,5 @@ module OasRails
|
|
59
63
|
::YARD::Tags::Library.define_tag(tag_name, method_name, handler)
|
60
64
|
end
|
61
65
|
end
|
62
|
-
|
63
|
-
def configure_esquema!
|
64
|
-
Esquema.configure do |config|
|
65
|
-
config.exclude_associations = true
|
66
|
-
config.exclude_foreign_keys = true
|
67
|
-
config.excluded_columns = %i[id created_at updated_at deleted_at]
|
68
|
-
end
|
69
|
-
end
|
70
66
|
end
|
71
67
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: oas_rails
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- a-chacon
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-08-
|
11
|
+
date: 2024-08-02 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: esquema
|
@@ -103,6 +103,9 @@ files:
|
|
103
103
|
- lib/oas_rails/configuration.rb
|
104
104
|
- lib/oas_rails/contact.rb
|
105
105
|
- lib/oas_rails/engine.rb
|
106
|
+
- lib/oas_rails/esquema_builder.rb
|
107
|
+
- lib/oas_rails/extractors/render_response_extractor.rb
|
108
|
+
- lib/oas_rails/extractors/route_extractor.rb
|
106
109
|
- lib/oas_rails/info.rb
|
107
110
|
- lib/oas_rails/license.rb
|
108
111
|
- lib/oas_rails/media_type.rb
|
@@ -115,7 +118,6 @@ files:
|
|
115
118
|
- lib/oas_rails/request_body.rb
|
116
119
|
- lib/oas_rails/response.rb
|
117
120
|
- lib/oas_rails/responses.rb
|
118
|
-
- lib/oas_rails/route_extractor.rb
|
119
121
|
- lib/oas_rails/server.rb
|
120
122
|
- lib/oas_rails/specification.rb
|
121
123
|
- lib/oas_rails/tag.rb
|
@@ -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
|