diff --git a/Gemfile b/Gemfile index 3750ca2..4aa7134 100644 --- a/Gemfile +++ b/Gemfile @@ -12,9 +12,15 @@ gem 'sprockets-rails' gem 'rack-cors' -gem "bcrypt", "~> 3.1.7" +group :development, :test do + gem "bcrypt", "~> 3.1.7" -gem 'jwt' + gem 'factory_bot_rails' + + gem 'jwt' + + gem 'faker' +end # Start debugger with binding.b [https://github.com/ruby/debug] # gem "debug", ">= 1.0.0" diff --git a/Gemfile.lock b/Gemfile.lock index 4ad9791..b03a723 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -96,6 +96,13 @@ GEM erubi (1.13.0) esquema (0.1.2) activerecord (~> 7.1) + factory_bot (6.4.6) + activesupport (>= 5.0.0) + factory_bot_rails (6.4.3) + factory_bot (~> 6.4) + railties (>= 5.0.0) + faker (3.4.2) + i18n (>= 1.8.11, < 2) globalid (1.2.1) activesupport (>= 6.1) i18n (1.14.5) @@ -205,6 +212,8 @@ PLATFORMS DEPENDENCIES bcrypt (~> 3.1.7) + factory_bot_rails + faker jwt oas_rails! puma diff --git a/README.md b/README.md index 7e6ff40..6f59e13 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,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)**. -![Screenshot](https://raw.githubusercontent.com/a-chacon/oas_rails/0cfc9abb5be85e6bb3fc4669e29372be8f80a276/oas_rails_ui.png) +![Screenshot](https://a-chacon.com/assets/images/oas_rails_ui.png) ## Related Projects diff --git a/lib/oas_rails.rb b/lib/oas_rails.rb index 2aa578c..56656cc 100644 --- a/lib/oas_rails.rb +++ b/lib/oas_rails.rb @@ -9,7 +9,6 @@ module OasRails autoload :OasBase, "oas_rails/oas_base" autoload :Configuration, "oas_rails/configuration" autoload :Specification, "oas_rails/specification" - autoload :RouteExtractor, "oas_rails/route_extractor" autoload :OasRoute, "oas_rails/oas_route" autoload :Operation, "oas_rails/operation" autoload :Info, "oas_rails/info" @@ -26,15 +25,20 @@ module OasRails autoload :Responses, "oas_rails/responses" autoload :Utils, "oas_rails/utils" + autoload :EsquemaBuilder, "oas_rails/esquema_builder" module YARD autoload :OasYARDFactory, 'oas_rails/yard/oas_yard_factory' end + module Extractors + autoload :RenderResponseExtractor, 'oas_rails/extractors/render_response_extractor' + autoload :RouteExtractor, "oas_rails/extractors/route_extractor" + end + class << self # Configurations for make the OasRails engine Work. def configure - OasRails.configure_esquema! OasRails.configure_yard! yield config end @@ -59,13 +63,5 @@ def configure_yard! ::YARD::Tags::Library.define_tag(tag_name, method_name, handler) end end - - def configure_esquema! - Esquema.configure do |config| - config.exclude_associations = true - config.exclude_foreign_keys = true - config.excluded_columns = %i[id created_at updated_at deleted_at] - end - end end end diff --git a/lib/oas_rails/configuration.rb b/lib/oas_rails/configuration.rb index 4929ebb..a6795f5 100644 --- a/lib/oas_rails/configuration.rb +++ b/lib/oas_rails/configuration.rb @@ -34,6 +34,14 @@ def servers=(value) def tags=(value) @tags = value.map { |t| Tag.new(name: t[:name], description: t[:description]) } end + + def excluded_columns_incoming + %i[id created_at updated_at deleted_at] + end + + def excluded_columns_outgoing + [] + end end DEFAULT_SECURITY_SCHEMES = { diff --git a/lib/oas_rails/esquema_builder.rb b/lib/oas_rails/esquema_builder.rb new file mode 100644 index 0000000..ff4ba41 --- /dev/null +++ b/lib/oas_rails/esquema_builder.rb @@ -0,0 +1,37 @@ +module OasRails + module EsquemaBuilder + class << self + # Builds a schema for a class when it is used as incoming API data. + # + # @param klass [Class] The class for which the schema is built. + # @return [Hash] The schema as a JSON-compatible hash. + def build_incoming_schema(klass:) + configure_common_settings + Esquema.configuration.excluded_columns = OasRails.config.excluded_columns_incoming + + Esquema::Builder.new(klass).build_schema.as_json + end + + # Builds a schema for a class when it is used as outgoing API data. + # + # @param klass [Class] The class for which the schema is built. + # @return [Hash] The schema as a JSON-compatible hash. + def build_outgoing_schema(klass:) + configure_common_settings + Esquema.configuration.excluded_columns = OasRails.config.excluded_columns_outgoing + + Esquema::Builder.new(klass).build_schema.as_json + end + + private + + # Configures common settings for schema building. + # + # Excludes associations and foreign keys from the schema. + def configure_common_settings + Esquema.configuration.exclude_associations = true + Esquema.configuration.exclude_foreign_keys = true + end + end + end +end diff --git a/lib/oas_rails/extractors/render_response_extractor.rb b/lib/oas_rails/extractors/render_response_extractor.rb new file mode 100644 index 0000000..12f3307 --- /dev/null +++ b/lib/oas_rails/extractors/render_response_extractor.rb @@ -0,0 +1,173 @@ +module OasRails + module Extractors + # Extracts and processes render responses from a given source. + module RenderResponseExtractor + class << self + # Extracts responses from the provided source string. + # + # @param source [String] The source string containing render calls. + # @return [Array] An array of Response objects extracted from the source. + def extract_responses_from_source(source:) + render_calls = extract_render_calls(source) + + return [Response.new(code: 204, description: "No Content", content: {})] if render_calls.empty? + + render_calls.map { |render_content, status| process_render_content(render_content.strip, status) } + end + + private + + # Extracts render calls from the source string. + # + # @param source [String] The source string containing render calls. + # @return [Array>] An array of arrays, each containing render content and status. + def extract_render_calls(source) + source.scan(/render json: ((?:\{.*?\}|\S+))(?:, status: :(\w+))?(?:,.*?)?$/m) + end + + # Processes the render content and status to build a Response object. + # + # @param content [String] The content extracted from the render call. + # @param status [String] The status code associated with the render call. + # @return [Response] A Response object based on the processed content and status. + def process_render_content(content, status) + schema, examples = build_schema_and_examples(content) + status_int = status_to_integer(status) + Response.new( + code: status_int, + description: status_code_to_text(status_int), + content: { "application/json": MediaType.new(schema:, examples:) } + ) + end + + # Builds schema and examples based on the content type. + # + # @param content [String] The content extracted from the render call. + # @return [Array] An array where the first element is the schema and the second is the examples. + def build_schema_and_examples(content) + if content.start_with?('{') + [Utils.hash_to_json_schema(parse_hash_structure(content)), {}] + else + process_non_hash_content(content) + end + rescue StandardError => e + Rails.logger.debug("Error building schema: #{e.message}") + [{}] + end + + # Processes non-hash content (e.g., model or method calls) to build schema and examples. + # + # @param content [String] The content extracted from the render call. + # @return [Array] An array where the first element is the schema and the second is the examples. + def process_non_hash_content(content) + maybe_a_model, errors = content.gsub('@', "").split(".") + klass = maybe_a_model.singularize.camelize(:upper).constantize + + if klass.ancestors.include?(ActiveRecord::Base) + schema = EsquemaBuilder.build_outgoing_schema(klass:) + if test_singularity(maybe_a_model) + build_singular_model_schema_and_examples(maybe_a_model, errors, klass, schema) + else + build_array_model_schema_and_examples(maybe_a_model, klass, schema) + end + else + [{}] + end + end + + # Builds schema and examples for singular models. + # + # @param maybe_a_model [String] The model name or variable. + # @param errors [String, nil] Errors related to the model. + # @param klass [Class] The class associated with the model. + # @param schema [Hash] The schema for the model. + # @return [Array] An array where the first element is the schema and the second is the examples. + def build_singular_model_schema_and_examples(_maybe_a_model, errors, klass, schema) + if errors.nil? + [schema, MediaType.search_for_examples_in_tests(klass:, context: :outgoing)] + else + [ + { + type: "object", + properties: { + success: { type: "boolean" }, + errors: { + type: "object", + additionalProperties: { + type: "array", + items: { type: "string" } + } + } + } + }, + {} + ] + end + end + + # Builds schema and examples for array models. + # + # @param maybe_a_model [String] The model name or variable. + # @param klass [Class] The class associated with the model. + # @param schema [Hash] The schema for the model. + # @return [Array] An array where the first element is the schema and the second is the examples. + def build_array_model_schema_and_examples(maybe_a_model, klass, schema) + 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) } } } + [{ type: "array", items: schema }, examples] + end + + # Determines if a string represents a singular model. + # + # @param str [String] The string to test. + # @return [Boolean] True if the string is a singular model, false otherwise. + def test_singularity(str) + str.pluralize != str && str.singularize == str + end + + # Parses a hash literal to determine its structure. + # + # @param hash_literal [String] The hash literal string. + # @return [Hash] A hash representing the structure of the input. + def parse_hash_structure(hash_literal) + structure = {} + + hash_literal.scan(/(\w+):\s*(\S+)/) do |key, value| + structure[key.to_sym] = case value + when 'true', 'false' + 'Boolean' + when /^\d+$/ + 'Number' + else + 'Object' + end + end + + structure + end + + # Converts a status symbol or string to an integer. + # + # @param status [String, Symbol, nil] The status to convert. + # @return [Integer] The status code as an integer. + def status_to_integer(status) + return 200 if status.nil? + + if status.to_s =~ /^\d+$/ + status.to_i + else + status = "unprocessable_content" if status == "unprocessable_entity" + Rack::Utils::SYMBOL_TO_STATUS_CODE[status.to_sym] + end + end + + # Converts a status code to its corresponding text description. + # + # @param status_code [Integer] The status code. + # @return [String] The text description of the status code. + def status_code_to_text(status_code) + Rack::Utils::HTTP_STATUS_CODES[status_code] || "Unknown Status Code" + end + end + end + end +end diff --git a/lib/oas_rails/extractors/route_extractor.rb b/lib/oas_rails/extractors/route_extractor.rb new file mode 100644 index 0000000..7bea024 --- /dev/null +++ b/lib/oas_rails/extractors/route_extractor.rb @@ -0,0 +1,125 @@ +module OasRails + module Extractors + class RouteExtractor + RAILS_DEFAULT_CONTROLLERS = %w[ + rails/info + rails/mailers + active_storage/blobs + active_storage/disk + active_storage/direct_uploads + active_storage/representations + rails/conductor/continuous_integration + rails/conductor/multiple_databases + rails/conductor/action_mailbox + rails/conductor/action_text + action_cable + ].freeze + + RAILS_DEFAULT_PATHS = %w[ + /rails/action_mailbox/ + ].freeze + + class << self + def host_routes_by_path(path) + @host_routes ||= extract_host_routes + @host_routes.select { |r| r.path == path } + end + + def host_routes + @host_routes ||= extract_host_routes + end + + # Clear Class Instance Variable @host_routes + # + # This method clear the class instance variable @host_routes + # to force a extraction of the routes again. + def clear_cache + @host_routes = nil + end + + def host_paths + @host_paths ||= host_routes.map(&:path).uniq.sort + end + + def clean_route(route) + route.gsub('(.:format)', '').gsub(/:\w+/) { |match| "{#{match[1..]}}" } + end + + # THIS CODE IS NOT IN USE BUT CAN BE USEFULL WITH GLOBAL TAGS OR AUTH TAGS + # def get_controller_comments(controller_path) + # YARD.parse_string(File.read(controller_path)) + # controller_class = YARD::Registry.all(:class).first + # if controller_class + # class_comment = controller_class.docstring.all + # method_comments = controller_class.meths.map do |method| + # { + # name: method.name, + # comment: method.docstring.all + # } + # end + # YARD::Registry.clear + # { + # class_comment: class_comment, + # method_comments: method_comments + # } + # else + # YARD::Registry.clear + # nil + # end + # rescue StandardError + # nil + # end + # + # def get_controller_comment(controller_path) + # get_controller_comments(controller_path)&.dig(:class_comment) || '' + # rescue StandardError + # '' + # end + + private + + def extract_host_routes + valid_routes.map { |r| OasRoute.new_from_rails_route(rails_route: r) } + end + + def valid_routes + Rails.application.routes.routes.select do |route| + valid_api_route?(route) + end + end + + def valid_api_route?(route) + return false unless valid_route_implementation?(route) + return false if RAILS_DEFAULT_CONTROLLERS.any? { |default| route.defaults[:controller].start_with?(default) } + return false if RAILS_DEFAULT_PATHS.any? { |path| route.path.spec.to_s.include?(path) } + return false unless route.path.spec.to_s.start_with?(OasRails.config.api_path) + + true + end + + # Checks if a route has a valid implementation. + # + # This method verifies that both the controller and the action specified + # in the route exist. It checks if the controller class is defined and + # if the action method is implemented within that controller. + # + # @param route [ActionDispatch::Journey::Route] The route to check. + # @return [Boolean] true if both the controller and action exist, false otherwise. + def valid_route_implementation?(route) + controller_name = route.defaults[:controller]&.camelize + action_name = route.defaults[:action] + + return false if controller_name.blank? || action_name.blank? + + controller_class = "#{controller_name}Controller".safe_constantize + + if controller_class.nil? + false + else + controller_class.instance_methods.include?(action_name.to_sym) + end + end + end + end + end +end diff --git a/lib/oas_rails/media_type.rb b/lib/oas_rails/media_type.rb index 531e860..4d8a924 100644 --- a/lib/oas_rails/media_type.rb +++ b/lib/oas_rails/media_type.rb @@ -2,18 +2,29 @@ module OasRails class MediaType < OasBase attr_accessor :schema, :example, :examples, :encoding + # Initializes a new MediaType object. + # + # @param schema [Hash] the schema of the media type. + # @param kwargs [Hash] additional keyword arguments. def initialize(schema:, **kwargs) super() @schema = schema @example = kwargs[:example] || {} - @examples = kwargs[:examples] || [] + @examples = kwargs[:examples] || {} end class << self - def from_model_class(klass:, examples: {}) + @context = :incoming + # Creates a new MediaType object from a model class. + # + # @param klass [Class] the ActiveRecord model class. + # @param examples [Hash] the examples hash. + # @return [MediaType, nil] the created MediaType object or nil if the class is not an ActiveRecord model. + def from_model_class(klass:, context: :incoming, examples: {}) + @context = context return unless klass.ancestors.include? ActiveRecord::Base - model_schema = Esquema::Builder.new(klass).build_schema.as_json + model_schema = EsquemaBuilder.send("build_#{@context}_schema", klass:) model_schema["required"] = [] schema = { type: "object", properties: { klass.to_s.downcase => model_schema } } examples.merge!(search_for_examples_in_tests(klass:)) @@ -22,45 +33,25 @@ def from_model_class(klass:, examples: {}) # Searches for examples in test files based on the provided class and test framework. # - # This method handles different test frameworks to fetch examples for the given class. - # Currently, it supports FactoryBot and fixtures. - # # @param klass [Class] the class to search examples for. # @param utils [Module] a utility module that provides the `detect_test_framework` method. Defaults to `Utils`. # @return [Hash] a hash containing examples data or an empty hash if no examples are found. - # @example Usage with FactoryBot - # search_for_examples_in_tests(klass: User) - # - # @example Usage with fixtures - # search_for_examples_in_tests(klass: Project) - # - # @example Usage with a custom utils module - # custom_utils = Module.new do - # def self.detect_test_framework - # :factory_bot - # end - # end - # search_for_examples_in_tests(klass: User, utils: custom_utils) - def search_for_examples_in_tests(klass:, utils: Utils) + def search_for_examples_in_tests(klass:, context: :incoming, utils: Utils) + @context = context case utils.detect_test_framework when :factory_bot - {} - # TODO: create examples with FactoryBot + fetch_factory_bot_examples(klass:) when :fixtures - fixture_file = Rails.root.join('test', 'fixtures', "#{klass.to_s.pluralize.downcase}.yml") - - begin - fixture_data = YAML.load_file(fixture_file).with_indifferent_access - rescue Errno::ENOENT - return {} - end - - fixture_data.transform_values { |attributes| { value: { klass.to_s.downcase => attributes } } } + fetch_fixture_examples(klass:) else {} end end + # Transforms tags into examples. + # + # @param tags [Array] the array of tags. + # @return [Hash] the transformed examples hash. def tags_to_examples(tags:) tags.each_with_object({}).with_index(1) do |(example, result), _index| key = example.text.downcase.gsub(' ', '_') @@ -71,6 +62,41 @@ def tags_to_examples(tags:) result[key] = value end end + + private + + # Fetches examples from FactoryBot for the provided class. + # + # @param klass [Class] the class to fetch examples for. + # @return [Hash] a hash containing examples data or an empty hash if no examples are found. + def fetch_factory_bot_examples(klass:) + klass_sym = klass.to_s.downcase.to_sym + begin + FactoryBot.build_stubbed_list(klass_sym, 3).each_with_index.to_h do |obj, index| + ["#{klass_sym}#{index + 1}", { value: { klass_sym => clean_example_object(obj: obj.as_json) } }] + end + rescue KeyError + {} + end + end + + # Fetches examples from fixtures for the provided class. + # + # @param klass [Class] the class to fetch examples for. + # @return [Hash] a hash containing examples data or an empty hash if no examples are found. + def fetch_fixture_examples(klass:) + fixture_file = Rails.root.join('test', 'fixtures', "#{klass.to_s.pluralize.downcase}.yml") + begin + fixture_data = YAML.load_file(fixture_file).with_indifferent_access + rescue Errno::ENOENT + return {} + end + fixture_data.transform_values { |attributes| { value: { klass.to_s.downcase => clean_example_object(obj: attributes) } } } + end + + def clean_example_object(obj:) + obj.reject { |key, _| OasRails.config.send("excluded_columns_#{@context}").include?(key.to_sym) } + end end end end diff --git a/lib/oas_rails/oas_route.rb b/lib/oas_rails/oas_route.rb index b394d02..4caec12 100644 --- a/lib/oas_rails/oas_route.rb +++ b/lib/oas_rails/oas_route.rb @@ -19,7 +19,7 @@ def extract_rails_route_data @controller_path = controller_path_extractor(@rails_route.defaults[:controller]) @method = @rails_route.defaults[:action] @verb = @rails_route.verb - @path = RouteExtractor.clean_route(@rails_route.path.spec.to_s) + @path = Extractors::RouteExtractor.clean_route(@rails_route.path.spec.to_s) @docstring = extract_docstring @source_string = extract_source_string end @@ -46,98 +46,5 @@ def detect_request_body klass = @controller.singularize.camelize.constantize RequestBody.from_model_class(klass:, required: true) end - - def extract_responses_from_source - render_calls = @source_string.scan(/render json: ((?:\{.*?\}|\S+))(?:, status: :(\w+))?(?:,.*?)?$/m) - - return [Response.new(code: 204, description: "No Content", content: {})] if render_calls.empty? - - render_calls.map do |render_content, status| - content = render_content.strip - - # TODO: manage when is an array of errors - schema = {} - begin - schema = if content.start_with?('{') - Utils.hash_to_json_schema(parse_hash_structure(content)) - else - # It's likely a variable or method call - maybe_a_model, errors = content.gsub('@', "").split(".") - klass = maybe_a_model.singularize.camelize(:upper).constantize - return {} unless klass.ancestors.include? ActiveRecord::Base - - e = Esquema::Builder.new(klass).build_schema.as_json - if test_singularity(maybe_a_model) - if errors.nil? - e - else - { - type: "object", - properties: { - success: { - type: "boolean" - }, - errors: { - type: "object", - additionalProperties: { - type: "array", - items: { - type: "string" - } - } - } - } - } end - else - { type: "array", items: e } - end - end - rescue StandardError => e - Rails.logger.debug("Error building schema: #{e.message}") - end - - status_int = status_to_integer(status) - Response.new(code: status_int, description: status_code_to_text(status_int), content: { "application/json": MediaType.new(schema:) }) - end - end - - def test_singularity(str) - str.pluralize != str && str.singularize == str - end - - def parse_hash_structure(hash_literal) - structure = {} - - hash_literal.scan(/(\w+):\s*(\S+)/) do |key, value| - structure[key.to_sym] = case value - when 'true', 'false' - 'Boolean' - when /^\d+$/ - 'Number' - when '@user.errors' - 'Object' - else - 'Object' - end - end - - structure - end - - def status_to_integer(status) - return 200 if status.nil? - - if status.to_s =~ /^\d+$/ - status.to_i - else - status = "unprocessable_content" if status == "unprocessable_entity" - Rack::Utils::SYMBOL_TO_STATUS_CODE[status.to_sym] - - end - end - - def status_code_to_text(status_code) - Rack::Utils::HTTP_STATUS_CODES[status_code] || "Unknown Status Code" - end end end diff --git a/lib/oas_rails/operation.rb b/lib/oas_rails/operation.rb index e599c36..12f890b 100644 --- a/lib/oas_rails/operation.rb +++ b/lib/oas_rails/operation.rb @@ -104,7 +104,7 @@ def extract_responses(oas_route:) responses = Responses.from_tags(tags: oas_route.docstring.tags(:response)) if OasRails.config.autodiscover_responses - new_responses = oas_route.extract_responses_from_source + new_responses = Extractors::RenderResponseExtractor.extract_responses_from_source(source: oas_route.source_string) new_responses.each do |new_response| responses.responses << new_response unless responses.responses.any? { |r| r.code == new_response.code } diff --git a/lib/oas_rails/paths.rb b/lib/oas_rails/paths.rb index 60bb7e8..c58e4ab 100644 --- a/lib/oas_rails/paths.rb +++ b/lib/oas_rails/paths.rb @@ -8,7 +8,7 @@ def initialize(path_items:) def self.from_string_paths(string_paths:) new(path_items: string_paths.map do |s| - PathItem.from_oas_routes(path: s, oas_routes: RouteExtractor.host_routes_by_path(s)) + PathItem.from_oas_routes(path: s, oas_routes: Extractors::RouteExtractor.host_routes_by_path(s)) end) end diff --git a/lib/oas_rails/route_extractor.rb b/lib/oas_rails/route_extractor.rb deleted file mode 100644 index 4760d05..0000000 --- a/lib/oas_rails/route_extractor.rb +++ /dev/null @@ -1,119 +0,0 @@ -module OasRails - class RouteExtractor - RAILS_DEFAULT_CONTROLLERS = %w[ - rails/info - rails/mailers - active_storage/blobs - active_storage/disk - active_storage/direct_uploads - active_storage/representations - rails/conductor/continuous_integration - rails/conductor/multiple_databases - rails/conductor/action_mailbox - rails/conductor/action_text - action_cable - ].freeze - - RAILS_DEFAULT_PATHS = %w[ - /rails/action_mailbox/ - ].freeze - - class << self - def host_routes_by_path(path) - @host_routes ||= extract_host_routes - @host_routes.select { |r| r.path == path } - end - - def host_routes - @host_routes ||= extract_host_routes - end - - # Clear Class Instance Variable @host_routes - # - # This method clear the class instance variable @host_routes - # to force a extraction of the routes again. - def clear_cache - @host_routes = nil - end - - def host_paths - @host_paths ||= host_routes.map(&:path).uniq.sort - end - - def clean_route(route) - route.gsub('(.:format)', '').gsub(/:\w+/) { |match| "{#{match[1..]}}" } - end - - # THIS CODE IS NOT IN USE BUT CAN BE USEFULL WITH GLOBAL TAGS OR AUTH TAGS - # def get_controller_comments(controller_path) - # YARD.parse_string(File.read(controller_path)) - # controller_class = YARD::Registry.all(:class).first - # if controller_class - # class_comment = controller_class.docstring.all - # method_comments = controller_class.meths.map do |method| - # { - # name: method.name, - # comment: method.docstring.all - # } - # end - # YARD::Registry.clear - # { - # class_comment: class_comment, - # method_comments: method_comments - # } - # else - # YARD::Registry.clear - # nil - # end - # rescue StandardError - # nil - # end - # - # def get_controller_comment(controller_path) - # get_controller_comments(controller_path)&.dig(:class_comment) || '' - # rescue StandardError - # '' - # end - - private - - def extract_host_routes - Rails.application.routes.routes.select do |route| - valid_api_route?(route) - end.map { |r| OasRoute.new_from_rails_route(rails_route: r) } - end - - def valid_api_route?(route) - return false unless valid_route_implementation?(route) - return false if RAILS_DEFAULT_CONTROLLERS.any? { |default| route.defaults[:controller].start_with?(default) } - return false if RAILS_DEFAULT_PATHS.any? { |path| route.path.spec.to_s.include?(path) } - return false unless route.path.spec.to_s.start_with?(OasRails.config.api_path) - - true - end - - # Checks if a route has a valid implementation. - # - # This method verifies that both the controller and the action specified - # in the route exist. It checks if the controller class is defined and - # if the action method is implemented within that controller. - # - # @param route [ActionDispatch::Journey::Route] The route to check. - # @return [Boolean] true if both the controller and action exist, false otherwise. - def valid_route_implementation?(route) - controller_name = route.defaults[:controller]&.camelize - action_name = route.defaults[:action] - - return false if controller_name.blank? || action_name.blank? - - controller_class = "#{controller_name}Controller".safe_constantize - - if controller_class.nil? - false - else - controller_class.instance_methods.include?(action_name.to_sym) - end - end - end - end -end diff --git a/lib/oas_rails/specification.rb b/lib/oas_rails/specification.rb index dfb2257..36b5a63 100644 --- a/lib/oas_rails/specification.rb +++ b/lib/oas_rails/specification.rb @@ -15,7 +15,7 @@ def initialize # @return [void] def clear_cache MethodSource.clear_cache - RouteExtractor.clear_cache + Extractors::RouteExtractor.clear_cache end def to_json(*_args) @@ -51,7 +51,7 @@ def security # Create the Paths Object For the Root of the OAS. # @see https://spec.openapis.org/oas/latest.html#paths-object def paths - Paths.from_string_paths(string_paths: RouteExtractor.host_paths).to_spec + Paths.from_string_paths(string_paths: Extractors::RouteExtractor.host_paths).to_spec end # Created the Components Object For the Root of the OAS. diff --git a/oas_rails_ui.png b/oas_rails_ui.png deleted file mode 100644 index 15147bc..0000000 Binary files a/oas_rails_ui.png and /dev/null differ diff --git a/test/dummy/Gemfile b/test/dummy/Gemfile index 8da5054..300ca6e 100644 --- a/test/dummy/Gemfile +++ b/test/dummy/Gemfile @@ -38,6 +38,10 @@ gem 'bootsnap', require: false group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem 'debug', platforms: %i[mri windows] + + gem 'factory_bot_rails' + + gem 'faker' end group :development do diff --git a/test/dummy/Gemfile.lock b/test/dummy/Gemfile.lock index e14ab1e..19178d5 100644 --- a/test/dummy/Gemfile.lock +++ b/test/dummy/Gemfile.lock @@ -102,6 +102,13 @@ GEM reline (>= 0.3.8) drb (2.2.1) erubi (1.13.0) + factory_bot (6.4.6) + activesupport (>= 5.0.0) + factory_bot_rails (6.4.3) + factory_bot (~> 6.4) + railties (>= 5.0.0) + faker (3.4.2) + i18n (>= 1.8.11, < 2) globalid (1.2.1) activesupport (>= 6.1) i18n (1.14.5) @@ -256,6 +263,8 @@ DEPENDENCIES bootsnap capybara debug + factory_bot_rails + faker jbuilder jwt puma (>= 5.0) diff --git a/test/dummy/test/factories/projects.rb b/test/dummy/test/factories/projects.rb new file mode 100644 index 0000000..b8c8763 --- /dev/null +++ b/test/dummy/test/factories/projects.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :project do + name { Faker::Name.name } + description { Faker::Lorem.paragraph } + user + end +end diff --git a/test/dummy/test/factories/users.rb b/test/dummy/test/factories/users.rb new file mode 100644 index 0000000..515618f --- /dev/null +++ b/test/dummy/test/factories/users.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :user do + name { Faker::Name.name } + email { Faker::Internet.email } + password { "Test123" } + end +end diff --git a/test/lib/oas_rails/media_type_test.rb b/test/lib/oas_rails/media_type_test.rb new file mode 100644 index 0000000..bf4d394 --- /dev/null +++ b/test/lib/oas_rails/media_type_test.rb @@ -0,0 +1,66 @@ +require "test_helper" + +module OasRails + class MediaTypeTest < ActiveSupport::TestCase + test "create one example for one tag" do + tag = YARD::OasYARDFactory.new.parse_tag_with_request_body_example( + :request_body_example, + 'basic user [Hash] {user: {name: "Luis", email: "luis@gmail.ocom"}}' + ) + + result = MediaType.tags_to_examples(tags: [tag]) + + assert_equal( + { "basic_user" => { "summary" => "basic user", "value" => { user: { name: "Luis", email: "luis@gmail.ocom" } } } }, + result + ) + end + + test "create media type from model class" do + media_type = MediaType.from_model_class(klass: User) + + assert_instance_of MediaType, media_type + assert_equal "object", media_type.schema[:type] + assert_includes media_type.schema[:properties], "user" + assert_kind_of Hash, media_type.schema[:properties]["user"] + assert_empty media_type.schema[:properties]["user"]["required"] + end + + test "search for examples using FactoryBot" do + utils = mock_detect_test_framework(:factory_bot) + examples = MediaType.search_for_examples_in_tests(klass: User, utils:) + + assert_equal 3, examples.size + + utils.verify + end + + test "search for examples using fixtures" do + utils = mock_detect_test_framework(:fixtures) + examples = MediaType.search_for_examples_in_tests(klass: User, utils:) + + assert_equal 2, examples.size + + utils.verify + end + + test "search for examples with unrecognized test framework" do + mock_utils = Minitest::Mock.new + mock_utils.expect :detect_test_framework, :unknown_framework + + examples = MediaType.search_for_examples_in_tests(klass: User, utils: mock_utils) + + assert_empty examples + + mock_utils.verify + end + + private + + def mock_detect_test_framework(should_respond) + mock_utils = Minitest::Mock.new + mock_utils.expect :detect_test_framework, should_respond + mock_utils + end + end +end diff --git a/test/lib/oas_rails/utils_test.rb b/test/lib/oas_rails/utils_test.rb index de9f2e0..82d8133 100644 --- a/test/lib/oas_rails/utils_test.rb +++ b/test/lib/oas_rails/utils_test.rb @@ -4,7 +4,7 @@ module OasRails class UtilsTest < ActiveSupport::TestCase test "detect_test_framework" do result = OasRails::Utils.detect_test_framework - assert_equal :fixtures, result + assert_equal :factory_bot, result end test "type_to_schema when String is passed" do diff --git a/test/test_helper.rb b/test/test_helper.rb index 9d47a1b..8acb1df 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -5,6 +5,7 @@ ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)] ActiveRecord::Migrator.migrations_paths << File.expand_path("../db/migrate", __dir__) require "rails/test_help" +require "minitest/mock" # Load fixtures from the engine if ActiveSupport::TestCase.respond_to?(:fixture_paths=)