oas_rails 0.2.2 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dff52d1f5445bbcd000de4c14988e919905c7e5ea1bd179b7ac7c458fde9570e
4
- data.tar.gz: adba7ada7bc1658bf3b65f85731eef40c5f72ce930b3b087da5be23b4abd45a8
3
+ metadata.gz: c9e40d4f8c810e300bbccaf190fd5143e5be804d2d30f1e86393e0478055965f
4
+ data.tar.gz: 0b6de20a5280115b3550d950da6e26d8424f4d52cc15d6f46f5272224831904e
5
5
  SHA512:
6
- metadata.gz: 331be1bcbe64381095ab5f62b94154d394340740fe61a43c6463a544c8bc302ae2a89e30b30feedc698673ae4c7eca268b10d59b68e1f607b8eecfdb64917e9b
7
- data.tar.gz: edfefc2472d3419045df61ce6ca9a37c48e87d61055b6225af54d7633c8cf08c0471ce15392967c564a866f1e24e322e10defed30ac05736d2b77bd1770fa629
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://raw.githubusercontent.com/a-chacon/oas_rails/0cfc9abb5be85e6bb3fc4669e29372be8f80a276/oas_rails_ui.png)
9
+ ![Screenshot](https://a-chacon.com/assets/images/oas_rails_ui.png)
10
10
 
11
11
  ## Related Projects
12
12
 
@@ -30,6 +30,24 @@ After experiencing the interactive documentation in Python's fast-api framework,
30
30
 
31
31
  The goal is to minimize the effort required to create comprehensive documentation. By following REST principles in Rails, we believe this is achievable. You can enhance the documentation using [Yard](https://yardoc.org/) tags.
32
32
 
33
+ ## Table of Contents
34
+
35
+ - [Installation](#installation)
36
+ - [Configuration](#configuration)
37
+ - [Basic Information about the API](#basic-information-about-the-api)
38
+ - [Servers Information](#servers-information)
39
+ - [Tag Information](#tag-information)
40
+ - [Optional Settings](#optional-settings)
41
+ - [Authentication Settings](#authentication-settings)
42
+ - [Usage](#usage)
43
+ - [Documenting Your Endpoints](#documenting-your-endpoints)
44
+ - [Example](#example-of-documented-endpoints)
45
+ - [Securing the OasRails Engine](#securing-the-oasrails-engine)
46
+ - [Customizing the View](#customizing-the-view)
47
+ - [Contributing](#contributing)
48
+ - [Roadmap and Ideas for Improvement](#roadmap-and-ideas-for-improvement)
49
+ - [License](#license)
50
+
33
51
  ## Installation
34
52
 
35
53
  1. Add this line to your Rails application's Gemfile:
@@ -52,23 +70,15 @@ The goal is to minimize the effort required to create comprehensive documentatio
52
70
 
53
71
  You'll now have **basic documentation** based on your routes and automatically gathered information at `localhost:3000/docs`. To enhance it, create an initializer file and add [Yard](https://yardoc.org/) tags to your controller methods.
54
72
 
55
- ## Usage
56
-
57
- ### Initializer File
73
+ ## Configuration
58
74
 
59
- You can easy create the initializer file with:
75
+ To configure OasRails, you MUST create an initializer file including all your settings. The first step is to create your initializer file, which you can easily do with:
60
76
 
61
77
  ```bash
62
78
  rails generate oas_rails:config
63
79
  ```
64
80
 
65
- Then complete the created file with your data.
66
-
67
- **Almost every description in a OAS file support simple markdown**
68
-
69
- ## Configuration
70
-
71
- To configure OasRails, edit the `config/initializers/oas_rails.rb` file. Below are the available configuration options:
81
+ Then fill it with your data. Below are the available configuration options:
72
82
 
73
83
  ### Basic Information about the API
74
84
 
@@ -100,56 +110,11 @@ To configure OasRails, edit the `config/initializers/oas_rails.rb` file. Below a
100
110
  - `config.security_schema`: The default security schema used for authentication. Choose a predefined security schema from `[:api_key_cookie, :api_key_header, :api_key_query, :basic, :bearer, :bearer_jwt, :mutual_tls]`.
101
111
  - `config.security_schemas`: Custom security schemas. Follow the [OpenAPI Specification](https://spec.openapis.org/oas/latest.html#security-scheme-object) for defining these schemas.
102
112
 
103
- ## Securing the OasRails Engine
104
-
105
- To secure the OasRails engine, which exposes an endpoint for showing the OAS definition, you can configure authentication to ensure that only authorized users have access. Here are a few methods to achieve this:
106
-
107
- ### 1. Using Basic Authentication
108
-
109
- Use basic authentication to protect the OasRails endpoint. You can set this up in an initializer:
110
-
111
- ```ruby
112
- # config/initializers/oas_rails.rb
113
- OasRails::Engine.middleware.use(Rack::Auth::Basic) do |username, password|
114
- ActiveSupport::SecurityUtils.secure_compare(Rails.application.credentials.oas_rails_username, username) &
115
- ActiveSupport::SecurityUtils.secure_compare(Rails.application.credentials.oas_rails_password, password)
116
- end
117
- ```
118
-
119
- ### 2. Using Devise's `authenticate` Helper
120
-
121
- You can use Devise's `authenticate` helper to restrict access to the OasRails endpoint. For example, you can allow only admin users to access the endpoint:
122
-
123
- ```ruby
124
- # config/routes.rb
125
- # ...
126
- authenticate :user, ->(user) { user.admin? } do
127
- mount OasRails::Engine, at: '/docs'
128
- end
129
- ```
130
-
131
- ### 3. Custom Authentication
132
-
133
- To support custom authentication, you can extend the OasRails' ApplicationController using a hook. This allows you to add custom before actions to check for specific user permissions:
134
-
135
- ```ruby
136
- # config/initializers/oas_rails.rb
137
-
138
- ActiveSupport.on_load(:oas_rails_application_controller) do
139
- # context here is OasRails::ApplicationController
140
-
141
- before_action do
142
- raise ActionController::RoutingError.new('Not Found') unless current_user&.admin?
143
- end
113
+ ## Usage
144
114
 
145
- def current_user
146
- # Load the current user
147
- User.find(session[:user_id]) # Adjust according to your authentication logic
148
- end
149
- end
150
- ```
115
+ In addition to the information provided in the initializer file and the data that can be extracted from the routes and methods automatically, it is essential to document your API in the following way. The documentation is created **with the help of YARD**, so the methods are documented with **comment tags**.
151
116
 
152
- ## Documenting Your Endpoints
117
+ ### Documenting Your Endpoints
153
118
 
154
119
  Almost every description in an OAS file supports simple markdown. The following tags are available for documenting your endpoints:
155
120
 
@@ -331,9 +296,74 @@ class UsersController < ApplicationController
331
296
  end
332
297
  ```
333
298
 
299
+ ## Securing the OasRails Engine
300
+
301
+ To secure the OasRails engine, which exposes an endpoint for showing the OAS definition, you can configure authentication to ensure that only authorized users have access. Here are a few methods to achieve this:
302
+
303
+ ### 1. Using Basic Authentication
304
+
305
+ Use basic authentication to protect the OasRails endpoint. You can set this up in an initializer:
306
+
307
+ ```ruby
308
+ # config/initializers/oas_rails.rb
309
+ OasRails::Engine.middleware.use(Rack::Auth::Basic) do |username, password|
310
+ ActiveSupport::SecurityUtils.secure_compare(Rails.application.credentials.oas_rails_username, username) &
311
+ ActiveSupport::SecurityUtils.secure_compare(Rails.application.credentials.oas_rails_password, password)
312
+ end
313
+ ```
314
+
315
+ ### 2. Using Devise's `authenticate` Helper
316
+
317
+ You can use Devise's `authenticate` helper to restrict access to the OasRails endpoint. For example, you can allow only admin users to access the endpoint:
318
+
319
+ ```ruby
320
+ # config/routes.rb
321
+ # ...
322
+ authenticate :user, ->(user) { user.admin? } do
323
+ mount OasRails::Engine, at: '/docs'
324
+ end
325
+ ```
326
+
327
+ ### 3. Custom Authentication
328
+
329
+ To support custom authentication, you can extend the OasRails' ApplicationController using a hook. This allows you to add custom before actions to check for specific user permissions:
330
+
331
+ ```ruby
332
+ # config/initializers/oas_rails.rb
333
+
334
+ ActiveSupport.on_load(:oas_rails_application_controller) do
335
+ # context here is OasRails::ApplicationController
336
+
337
+ before_action do
338
+ raise ActionController::RoutingError.new('Not Found') unless current_user&.admin?
339
+ end
340
+
341
+ def current_user
342
+ # Load the current user
343
+ User.find(session[:user_id]) # Adjust according to your authentication logic
344
+ end
345
+ end
346
+ ```
347
+
348
+ ## Customizing the View
349
+
350
+ The OasRails engine provides an easy way to display your OpenAPI Specification (OAS) within your Rails application. By default, it includes an `index` view in the `OasRailsController` that displays [RapiDoc](https://rapidocweb.com/) through a CDN with default configurations. You can easily override this view to replace RapiDoc entirely or configure it differently.
351
+
352
+ #### Overriding the `index` View
353
+
354
+ To override the `index` view provided by the OasRails engine, follow these steps:
355
+
356
+ 1. **Create the Override View File**: In your host application, create a new file at the path `app/views/oas_rails/oas_rails/index.html.erb`. If the directories do not exist, you will need to create them.
357
+
358
+ 2. **Customize the View**: Open the newly created `index.html.erb` file and add your custom HTML and ERB code to display the OAS as desired. You can refer to the source code of this project for guidance.
359
+
360
+ #### Using the Custom View
361
+
362
+ Once the custom view file is in place, Rails will automatically use it instead of the view provided by the OasRails engine. This allows you to fully customize the presentation of the OAS without modifying the engine's code.
363
+
334
364
  ## Contributing
335
365
 
336
- Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". Don't forget to give the project a star! Thanks again!
366
+ Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". Don't forget to give the project a star⭐! Thanks again!
337
367
 
338
368
  1. Fork the Project
339
369
  2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
@@ -1,5 +1,7 @@
1
1
  module OasRails
2
2
  class OasRailsController < ApplicationController
3
+ layout false
4
+
3
5
  def index
4
6
  respond_to do |format|
5
7
  format.html
@@ -1,16 +0,0 @@
1
- <!DOCTYPE html>
2
- <html>
3
- <head>
4
- <title>Oas rails</title>
5
- <%= csrf_meta_tags %>
6
- <%= csp_meta_tag %>
7
- <meta charset="utf-8">
8
- <!-- Important: rapi-doc uses utf8 characters -->
9
- <script type="module" src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"></script>
10
- </head>
11
- <body>
12
-
13
- <%= yield %>
14
-
15
- </body>
16
- </html>
@@ -1 +1,27 @@
1
- <rapi-doc spec-url = "<%= main_app.oas_rails_path %>.json" theme = "dark" text-color="#f9f9f9" show-header = 'false'> </rapi-doc>
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Oas rails</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+ <meta charset="utf-8">
8
+ <!-- Important: rapi-doc uses utf8 characters -->
9
+ <script type="module" src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"></script>
10
+ </head>
11
+ <body>
12
+ <rapi-doc
13
+ spec-url = "<%= main_app.oas_rails_path %>.json"
14
+ theme = "dark"
15
+ bg-color="#0F172A"
16
+ text-color= "#f7f7f7"
17
+ show-header = 'false'
18
+ primary-color = "#2de410"
19
+ font-size="largest"
20
+ show-method-in-nav-bar="as-colored-text"
21
+ nav-text-color="#f7f7f7"
22
+ nav-item-spacing="relaxed"
23
+ allow-spec-file-download="true"
24
+ >
25
+ </rapi-doc>
26
+ </body>
27
+ </html>
@@ -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 = {
@@ -1,5 +1,10 @@
1
1
  module OasRails
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace OasRails
4
+ config.to_prepare do
5
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
6
+ inflect.acronym 'YARD'
7
+ end
8
+ end
4
9
  end
5
10
  end
@@ -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
@@ -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
- def from_model_class(klass:, examples: {})
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 = Esquema::Builder.new(klass).build_schema.as_json
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
- # @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)
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
- 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 } } }
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
@@ -19,13 +19,13 @@ 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
26
26
 
27
27
  def extract_docstring
28
- YARD::Docstring.parser.parse(
28
+ ::YARD::Docstring.parser.parse(
29
29
  controller_class.constantize.instance_method(method).comment.lines.map { |line| line.sub(/^#\s*/, '') }.join
30
30
  ).to_docstring
31
31
  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
@@ -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.extract_responses_from_source
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 }
@@ -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
 
@@ -2,12 +2,22 @@ require 'json'
2
2
 
3
3
  module OasRails
4
4
  class Specification
5
+ # Initializes a new Specification object.
6
+ # Clears the cache if running in the development environment.
5
7
  def initialize
6
- OasRails.configure_esquema!
7
- OasRails.configure_yard!
8
+ clear_cache unless Rails.env.production?
9
+
8
10
  @specification = base_spec
9
11
  end
10
12
 
13
+ # Clears the cache for MethodSource and RouteExtractor.
14
+ #
15
+ # @return [void]
16
+ def clear_cache
17
+ MethodSource.clear_cache
18
+ Extractors::RouteExtractor.clear_cache
19
+ end
20
+
11
21
  def to_json(*_args)
12
22
  @specification.to_json
13
23
  rescue StandardError => e
@@ -41,7 +51,7 @@ module OasRails
41
51
  # Create the Paths Object For the Root of the OAS.
42
52
  # @see https://spec.openapis.org/oas/latest.html#paths-object
43
53
  def paths
44
- Paths.from_string_paths(string_paths: RouteExtractor.host_paths).to_spec
54
+ Paths.from_string_paths(string_paths: Extractors::RouteExtractor.host_paths).to_spec
45
55
  end
46
56
 
47
57
  # Created the Components Object For the Root of the OAS.
@@ -1,3 +1,3 @@
1
1
  module OasRails
2
- VERSION = "0.2.2"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -1,5 +1,5 @@
1
1
  module OasRails
2
- module Yard
2
+ module YARD
3
3
  class RequestBodyTag < ::YARD::Tags::Tag
4
4
  attr_accessor :klass, :schema, :required
5
5
 
@@ -41,7 +41,7 @@ module OasRails
41
41
  end
42
42
  end
43
43
 
44
- class OasYardFactory < ::YARD::Tags::DefaultFactory
44
+ class OasYARDFactory < ::YARD::Tags::DefaultFactory
45
45
  ## parse_tag is a prefix used by YARD
46
46
 
47
47
  def parse_tag_with_request_body(tag_name, text)
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,13 +25,21 @@ 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
- module Yard
31
- autoload :OasYardFactory, 'oas_rails/yard/oas_yard_factory'
30
+ module YARD
31
+ autoload :OasYARDFactory, 'oas_rails/yard/oas_yard_factory'
32
+ end
33
+
34
+ module Extractors
35
+ autoload :RenderResponseExtractor, 'oas_rails/extractors/render_response_extractor'
36
+ autoload :RouteExtractor, "oas_rails/extractors/route_extractor"
32
37
  end
33
38
 
34
39
  class << self
40
+ # Configurations for make the OasRails engine Work.
35
41
  def configure
42
+ OasRails.configure_yard!
36
43
  yield config
37
44
  end
38
45
 
@@ -41,7 +48,7 @@ module OasRails
41
48
  end
42
49
 
43
50
  def configure_yard!
44
- ::YARD::Tags::Library.default_factory = Yard::OasYardFactory
51
+ ::YARD::Tags::Library.default_factory = YARD::OasYARDFactory
45
52
  yard_tags = {
46
53
  'Request body' => [:request_body, :with_request_body],
47
54
  'Request body Example' => [:request_body_example, :with_request_body_example],
@@ -56,13 +63,5 @@ module OasRails
56
63
  ::YARD::Tags::Library.define_tag(tag_name, method_name, handler)
57
64
  end
58
65
  end
59
-
60
- def configure_esquema!
61
- Esquema.configure do |config|
62
- config.exclude_associations = true
63
- config.exclude_foreign_keys = true
64
- config.excluded_columns = %i[id created_at updated_at deleted_at]
65
- end
66
- end
67
66
  end
68
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.2.2
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-07-31 00:00:00.000000000 Z
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,111 +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
- 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 unless valid_route_implementation?(route)
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
- return false unless route.path.spec.to_s.start_with?(OasRails.config.api_path)
83
-
84
- true
85
- end
86
-
87
- # Checks if a route has a valid implementation.
88
- #
89
- # This method verifies that both the controller and the action specified
90
- # in the route exist. It checks if the controller class is defined and
91
- # if the action method is implemented within that controller.
92
- #
93
- # @param route [ActionDispatch::Journey::Route] The route to check.
94
- # @return [Boolean] true if both the controller and action exist, false otherwise.
95
- def valid_route_implementation?(route)
96
- controller_name = route.defaults[:controller]&.camelize
97
- action_name = route.defaults[:action]
98
-
99
- return false if controller_name.blank? || action_name.blank?
100
-
101
- controller_class = "#{controller_name}Controller".safe_constantize
102
-
103
- if controller_class.nil?
104
- false
105
- else
106
- controller_class.instance_methods.include?(action_name.to_sym)
107
- end
108
- end
109
- end
110
- end
111
- end