From b5bdeab2ee28b2c0d6099121559f35339cae48d5 Mon Sep 17 00:00:00 2001 From: a-chacon Date: Mon, 5 Aug 2024 18:06:36 -0400 Subject: [PATCH 1/3] refactor: put all the clases that represent an OAS object inside the namespace/module Spec. Rename OasBase to Base. --- .../oas_rails/oas_rails_controller.rb | 2 +- lib/oas_rails.rb | 38 ++--- lib/oas_rails/configuration.rb | 8 +- lib/oas_rails/contact.rb | 12 -- .../extractors/render_response_extractor.rb | 6 +- lib/oas_rails/license.rb | 11 -- lib/oas_rails/media_type.rb | 102 ------------- lib/oas_rails/oas_base.rb | 30 ---- lib/oas_rails/oas_route.rb | 2 +- lib/oas_rails/operation.rb | 134 ----------------- lib/oas_rails/parameter.rb | 47 ------ lib/oas_rails/path_item.rb | 25 ---- lib/oas_rails/paths.rb | 19 --- lib/oas_rails/request_body.rb | 29 ---- lib/oas_rails/response.rb | 12 -- lib/oas_rails/responses.rb | 20 --- lib/oas_rails/server.rb | 10 -- lib/oas_rails/spec/base.rb | 32 +++++ lib/oas_rails/spec/contact.rb | 14 ++ lib/oas_rails/{ => spec}/info.rb | 50 +++---- lib/oas_rails/spec/license.rb | 13 ++ lib/oas_rails/spec/media_type.rb | 104 ++++++++++++++ lib/oas_rails/spec/operation.rb | 136 ++++++++++++++++++ lib/oas_rails/spec/parameter.rb | 49 +++++++ lib/oas_rails/spec/path_item.rb | 27 ++++ lib/oas_rails/spec/paths.rb | 21 +++ lib/oas_rails/spec/request_body.rb | 31 ++++ lib/oas_rails/spec/response.rb | 14 ++ lib/oas_rails/spec/responses.rb | 22 +++ lib/oas_rails/spec/server.rb | 12 ++ lib/oas_rails/spec/specification.rb | 74 ++++++++++ lib/oas_rails/spec/tag.rb | 19 +++ lib/oas_rails/specification.rb | 72 ---------- lib/oas_rails/tag.rb | 17 --- test/lib/oas_rails/media_type_test.rb | 66 --------- test/lib/oas_rails/spec/media_type_test.rb | 68 +++++++++ 36 files changed, 693 insertions(+), 655 deletions(-) delete mode 100644 lib/oas_rails/contact.rb delete mode 100644 lib/oas_rails/license.rb delete mode 100644 lib/oas_rails/media_type.rb delete mode 100644 lib/oas_rails/oas_base.rb delete mode 100644 lib/oas_rails/operation.rb delete mode 100644 lib/oas_rails/parameter.rb delete mode 100644 lib/oas_rails/path_item.rb delete mode 100644 lib/oas_rails/paths.rb delete mode 100644 lib/oas_rails/request_body.rb delete mode 100644 lib/oas_rails/response.rb delete mode 100644 lib/oas_rails/responses.rb delete mode 100644 lib/oas_rails/server.rb create mode 100644 lib/oas_rails/spec/base.rb create mode 100644 lib/oas_rails/spec/contact.rb rename lib/oas_rails/{ => spec}/info.rb (59%) create mode 100644 lib/oas_rails/spec/license.rb create mode 100644 lib/oas_rails/spec/media_type.rb create mode 100644 lib/oas_rails/spec/operation.rb create mode 100644 lib/oas_rails/spec/parameter.rb create mode 100644 lib/oas_rails/spec/path_item.rb create mode 100644 lib/oas_rails/spec/paths.rb create mode 100644 lib/oas_rails/spec/request_body.rb create mode 100644 lib/oas_rails/spec/response.rb create mode 100644 lib/oas_rails/spec/responses.rb create mode 100644 lib/oas_rails/spec/server.rb create mode 100644 lib/oas_rails/spec/specification.rb create mode 100644 lib/oas_rails/spec/tag.rb delete mode 100644 lib/oas_rails/specification.rb delete mode 100644 lib/oas_rails/tag.rb delete mode 100644 test/lib/oas_rails/media_type_test.rb create mode 100644 test/lib/oas_rails/spec/media_type_test.rb diff --git a/app/controllers/oas_rails/oas_rails_controller.rb b/app/controllers/oas_rails/oas_rails_controller.rb index 97c62a1..04d3705 100644 --- a/app/controllers/oas_rails/oas_rails_controller.rb +++ b/app/controllers/oas_rails/oas_rails_controller.rb @@ -6,7 +6,7 @@ def index respond_to do |format| format.html format.json do - render json: Specification.new.to_json, status: :ok + render json: OasRails.build.to_json, status: :ok end end end diff --git a/lib/oas_rails.rb b/lib/oas_rails.rb index 56656cc..e7d1826 100644 --- a/lib/oas_rails.rb +++ b/lib/oas_rails.rb @@ -6,27 +6,29 @@ module OasRails require "oas_rails/version" require "oas_rails/engine" - autoload :OasBase, "oas_rails/oas_base" autoload :Configuration, "oas_rails/configuration" - autoload :Specification, "oas_rails/specification" autoload :OasRoute, "oas_rails/oas_route" - autoload :Operation, "oas_rails/operation" - autoload :Info, "oas_rails/info" - autoload :Contact, "oas_rails/contact" - autoload :Paths, "oas_rails/paths" - autoload :PathItem, "oas_rails/path_item" - autoload :Parameter, "oas_rails/parameter" - autoload :Tag, "oas_rails/tag" - autoload :License, "oas_rails/license" - autoload :Server, "oas_rails/server" - autoload :RequestBody, "oas_rails/request_body" - autoload :MediaType, "oas_rails/media_type" - autoload :Response, "oas_rails/response" - autoload :Responses, "oas_rails/responses" - autoload :Utils, "oas_rails/utils" autoload :EsquemaBuilder, "oas_rails/esquema_builder" + module Spec + autoload :Base, "oas_rails/spec/base" + autoload :Parameter, "oas_rails/spec/parameter" + autoload :License, "oas_rails/spec/license" + autoload :Response, "oas_rails/spec/response" + autoload :PathItem, "oas_rails/spec/path_item" + autoload :Operation, "oas_rails/spec/operation" + autoload :RequestBody, "oas_rails/spec/request_body" + autoload :Responses, "oas_rails/spec/responses" + autoload :MediaType, "oas_rails/spec/media_type" + autoload :Paths, "oas_rails/spec/paths" + autoload :Contact, "oas_rails/spec/contact" + autoload :Info, "oas_rails/spec/info" + autoload :Server, "oas_rails/spec/server" + autoload :Tag, "oas_rails/spec/tag" + autoload :Specification, "oas_rails/spec/specification" + end + module YARD autoload :OasYARDFactory, 'oas_rails/yard/oas_yard_factory' end @@ -37,6 +39,10 @@ module Extractors end class << self + def build + Spec::Specification.new + end + # Configurations for make the OasRails engine Work. def configure OasRails.configure_yard! diff --git a/lib/oas_rails/configuration.rb b/lib/oas_rails/configuration.rb index a6795f5..834530a 100644 --- a/lib/oas_rails/configuration.rb +++ b/lib/oas_rails/configuration.rb @@ -4,7 +4,7 @@ class Configuration attr_reader :servers, :tags, :security_schema def initialize - @info = Info.new + @info = Spec::Info.new @servers = default_servers @tags = [] @swagger_version = '3.1.0' @@ -24,15 +24,15 @@ def security_schema=(value) end def default_servers - [Server.new(url: "http://localhost:3000", description: "Rails Default Development Server")] + [Spec::Server.new(url: "http://localhost:3000", description: "Rails Default Development Server")] end def servers=(value) - @servers = value.map { |s| Server.new(url: s[:url], description: s[:description]) } + @servers = value.map { |s| Spec::Server.new(url: s[:url], description: s[:description]) } end def tags=(value) - @tags = value.map { |t| Tag.new(name: t[:name], description: t[:description]) } + @tags = value.map { |t| Spec::Tag.new(name: t[:name], description: t[:description]) } end def excluded_columns_incoming diff --git a/lib/oas_rails/contact.rb b/lib/oas_rails/contact.rb deleted file mode 100644 index 21e99ac..0000000 --- a/lib/oas_rails/contact.rb +++ /dev/null @@ -1,12 +0,0 @@ -module OasRails - class Contact < OasBase - attr_accessor :name, :url, :email - - def initialize(**kwargs) - super() - @name = kwargs[:name] || '' - @url = kwargs[:url] || '' - @email = kwargs[:email] || '' - end - end -end diff --git a/lib/oas_rails/extractors/render_response_extractor.rb b/lib/oas_rails/extractors/render_response_extractor.rb index 12f3307..5db0bf2 100644 --- a/lib/oas_rails/extractors/render_response_extractor.rb +++ b/lib/oas_rails/extractors/render_response_extractor.rb @@ -10,7 +10,7 @@ class << self 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? + return [Spec::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 @@ -33,10 +33,10 @@ def extract_render_calls(source) def process_render_content(content, status) schema, examples = build_schema_and_examples(content) status_int = status_to_integer(status) - Response.new( + Spec::Response.new( code: status_int, description: status_code_to_text(status_int), - content: { "application/json": MediaType.new(schema:, examples:) } + content: { "application/json": Spec::MediaType.new(schema:, examples:) } ) end diff --git a/lib/oas_rails/license.rb b/lib/oas_rails/license.rb deleted file mode 100644 index c918449..0000000 --- a/lib/oas_rails/license.rb +++ /dev/null @@ -1,11 +0,0 @@ -module OasRails - class License < OasBase - attr_accessor :name, :url - - def initialize(**kwargs) - super() - @name = kwargs[:name] || 'GPL 3.0' - @url = kwargs[:url] || 'https://www.gnu.org/licenses/gpl-3.0.html#license-text' - end - end -end diff --git a/lib/oas_rails/media_type.rb b/lib/oas_rails/media_type.rb deleted file mode 100644 index 4d8a924..0000000 --- a/lib/oas_rails/media_type.rb +++ /dev/null @@ -1,102 +0,0 @@ -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] || {} - end - - class << self - @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 = 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:)) - new(media_type: "", schema:, examples:) - end - - # Searches for examples in test files based on the provided class and test framework. - # - # @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. - def search_for_examples_in_tests(klass:, context: :incoming, utils: Utils) - @context = context - case utils.detect_test_framework - when :factory_bot - fetch_factory_bot_examples(klass:) - when :fixtures - 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(' ', '_') - value = { - "summary" => example.text, - "value" => example.content - } - 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_base.rb b/lib/oas_rails/oas_base.rb deleted file mode 100644 index 7505447..0000000 --- a/lib/oas_rails/oas_base.rb +++ /dev/null @@ -1,30 +0,0 @@ -module OasRails - class OasBase - def to_spec - hash = {} - instance_variables.each do |var| - key = var.to_s.delete('@') - camel_case_key = key.camelize(:lower).to_sym - value = instance_variable_get(var) - - processed_value = if value.respond_to?(:to_spec) - value.to_spec - else - value - end - - # hash[camel_case_key] = processed_value unless (processed_value.is_a?(Hash) || processed_value.is_a?(Array)) && processed_value.empty? - hash[camel_case_key] = processed_value - end - hash - end - - private - - def snake_to_camel(snake_str) - words = snake_str.to_s.split('_') - words[1..].map!(&:capitalize) - (words[0] + words[1..].join).to_sym - end - end -end diff --git a/lib/oas_rails/oas_route.rb b/lib/oas_rails/oas_route.rb index 4caec12..10c73c6 100644 --- a/lib/oas_rails/oas_route.rb +++ b/lib/oas_rails/oas_route.rb @@ -44,7 +44,7 @@ def controller_path_extractor(controller) def detect_request_body klass = @controller.singularize.camelize.constantize - RequestBody.from_model_class(klass:, required: true) + Spec::RequestBody.from_model_class(klass:, required: true) end end end diff --git a/lib/oas_rails/operation.rb b/lib/oas_rails/operation.rb deleted file mode 100644 index 12f890b..0000000 --- a/lib/oas_rails/operation.rb +++ /dev/null @@ -1,134 +0,0 @@ -module OasRails - class Operation < OasBase - attr_accessor :tags, :summary, :description, :operation_id, :parameters, :method, :docstring, :request_body, :responses, :security - - def initialize(method:, summary:, operation_id:, **kwargs) - super() - @method = method - @summary = summary - @operation_id = operation_id - @tags = kwargs[:tags] || [] - @description = kwargs[:description] || @summary - @parameters = kwargs[:parameters] || [] - @request_body = kwargs[:request_body] || {} - @responses = kwargs[:responses] || {} - @security = kwargs[:security] || [] - end - - class << self - def from_oas_route(oas_route:) - summary = extract_summary(oas_route:) - operation_id = extract_operation_id(oas_route:) - tags = extract_tags(oas_route:) - description = oas_route.docstring - parameters = extract_parameters(oas_route:) - request_body = extract_request_body(oas_route:) - responses = extract_responses(oas_route:) - security = extract_security(oas_route:) - new(method: oas_route.verb.downcase, summary:, operation_id:, tags:, description:, parameters:, request_body:, responses:, security:) - end - - def extract_summary(oas_route:) - oas_route.docstring.tags(:summary).first.try(:text) || generate_crud_name(oas_route.method, oas_route.controller.downcase) || oas_route.verb + " " + oas_route.path - end - - def generate_crud_name(method, controller) - controller_name = controller.to_s.underscore.humanize.downcase.pluralize - - case method.to_sym - when :index - "List #{controller_name}" - when :show - "View #{controller_name.singularize}" - when :create - "Create new #{controller_name.singularize}" - when :update - "Update #{controller_name.singularize}" - when :destroy - "Delete #{controller_name.singularize}" - end - end - - def extract_operation_id(oas_route:) - "#{oas_route.method}#{oas_route.path.gsub('/', '_').gsub(/[{}]/, '')}" - end - - # This method should check tags defined by yard, then extract tag from path namespace or controller name depending on configuration - def extract_tags(oas_route:) - tags = oas_route.docstring.tags(:tags).first - if !tags.nil? - tags.text.split(",").map(&:strip).map(&:titleize) - else - default_tags(oas_route:) - end - end - - def default_tags(oas_route:) - tags = [] - if OasRails.config.default_tags_from == "namespace" - tag = oas_route.path.split('/').reject(&:empty?).first.try(:titleize) - tags << tag unless tag.nil? - else - tags << oas_route.controller.titleize - end - tags - end - - def extract_parameters(oas_route:) - parameters = [] - parameters.concat(parameters_from_tags(tags: oas_route.docstring.tags(:parameter))) - oas_route.path_params.try(:map) do |p| - parameters << Parameter.from_path(path: oas_route.path, param: p) unless parameters.any? { |param| param.name.to_s == p.to_s } - end - parameters - end - - def parameters_from_tags(tags:) - tags.map do |t| - Parameter.new(name: t.name, location: t.location, required: t.required, schema: t.schema, description: t.text) - end - end - - def extract_request_body(oas_route:) - tag_request_body = oas_route.docstring.tags(:request_body).first - if tag_request_body.nil? && OasRails.config.autodiscover_request_body - oas_route.detect_request_body if %w[create update].include? oas_route.method - elsif !tag_request_body.nil? - RequestBody.from_tags(tag: tag_request_body, examples_tags: oas_route.docstring.tags(:request_body_example)) - else - {} - end - end - - def extract_responses(oas_route:) - responses = Responses.from_tags(tags: oas_route.docstring.tags(:response)) - - if OasRails.config.autodiscover_responses - 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 } - end - end - - responses - end - - def extract_security(oas_route:) - return [] if oas_route.docstring.tags(:no_auth).any? - - if (methods = oas_route.docstring.tags(:auth).first) - OasRails.config.security_schemas.keys.map { |key| { key => [] } }.select do |schema| - methods.types.include?(schema.keys.first.to_s) - end - elsif OasRails.config.authenticate_all_routes_by_default - OasRails.config.security_schemas.keys.map { |key| { key => [] } } - else - [] - end - end - - def external_docs; end - end - end -end diff --git a/lib/oas_rails/parameter.rb b/lib/oas_rails/parameter.rb deleted file mode 100644 index fc3dcfe..0000000 --- a/lib/oas_rails/parameter.rb +++ /dev/null @@ -1,47 +0,0 @@ -module OasRails - class Parameter - STYLE_DEFAULTS = { query: 'form', path: 'simple', header: 'simple', cookie: 'form' }.freeze - - attr_accessor :name, :in, :style, :description, :required, :schema - - def initialize(name:, location:, description:, **kwargs) - @name = name - @in = location - @description = description - - @required = kwargs[:required] || required? - @style = kwargs[:style] || default_from_in - @schema = kwargs[:schema] || { "type": 'string' } - end - - def self.from_path(path:, param:) - new(name: param, location: 'path', - description: "#{param.split('_')[-1].titleize} of existing #{extract_word_before(path, param).singularize}.") - end - - def self.extract_word_before(string, param) - regex = %r{/(\w+)/\{#{param}\}} - match = string.match(regex) - match ? match[1] : nil - end - - def default_from_in - STYLE_DEFAULTS[@in.to_sym] - end - - def required? - @in == 'path' - end - - def to_spec - { - "name": @name, - "in": @in, - "description": @description, - "required": @required, - "schema": @schema, - "style": @style - } - end - end -end diff --git a/lib/oas_rails/path_item.rb b/lib/oas_rails/path_item.rb deleted file mode 100644 index 1151c6d..0000000 --- a/lib/oas_rails/path_item.rb +++ /dev/null @@ -1,25 +0,0 @@ -module OasRails - class PathItem - attr_reader :path, :operations, :parameters - - def initialize(path:, operations:, parameters:) - @path = path - @operations = operations - @parameters = parameters - end - - def self.from_oas_routes(path:, oas_routes:) - new(path: path, operations: oas_routes.map do |oas_route| - Operation.from_oas_route(oas_route: oas_route) - end, parameters: []) - end - - def to_spec - spec = {} - @operations.each do |o| - spec[o.method] = o.to_spec - end - spec - end - end -end diff --git a/lib/oas_rails/paths.rb b/lib/oas_rails/paths.rb deleted file mode 100644 index c58e4ab..0000000 --- a/lib/oas_rails/paths.rb +++ /dev/null @@ -1,19 +0,0 @@ -module OasRails - class Paths - attr_accessor :path_items - - def initialize(path_items:) - @path_items = path_items - end - - def self.from_string_paths(string_paths:) - new(path_items: string_paths.map do |s| - PathItem.from_oas_routes(path: s, oas_routes: Extractors::RouteExtractor.host_routes_by_path(s)) - end) - end - - def to_spec - @path_items.each_with_object({}) { |p, object| object[p.path] = p.to_spec } - end - end -end diff --git a/lib/oas_rails/request_body.rb b/lib/oas_rails/request_body.rb deleted file mode 100644 index 0a2b3ac..0000000 --- a/lib/oas_rails/request_body.rb +++ /dev/null @@ -1,29 +0,0 @@ -module OasRails - class RequestBody < OasBase - attr_accessor :description, :content, :required - - def initialize(description:, content:, required: false) - super() - @description = description - @content = content # Should be an array of media type object - @required = required - end - - class << self - def from_tags(tag:, examples_tags: []) - if tag.klass.ancestors.include? ActiveRecord::Base - from_model_class(klass: tag.klass, description: tag.text, required: tag.required, examples_tags:) - else - # hash content to schema - content = { "application/json": MediaType.new(schema: tag.schema, examples: MediaType.tags_to_examples(tags: examples_tags)) } - new(description: tag.text, content:, required: tag.required) - end - end - - def from_model_class(klass:, **kwargs) - content = { "application/json": MediaType.from_model_class(klass:, examples: MediaType.tags_to_examples(tags: kwargs[:examples_tags] || {})) } - new(description: kwargs[:description] || klass.to_s, content:, required: kwargs[:required]) - end - end - end -end diff --git a/lib/oas_rails/response.rb b/lib/oas_rails/response.rb deleted file mode 100644 index 4769e91..0000000 --- a/lib/oas_rails/response.rb +++ /dev/null @@ -1,12 +0,0 @@ -module OasRails - class Response < OasBase - attr_accessor :code, :description, :content - - def initialize(code:, description:, content:) - super() - @code = code - @description = description - @content = content # Should be an array of media type object - end - end -end diff --git a/lib/oas_rails/responses.rb b/lib/oas_rails/responses.rb deleted file mode 100644 index 8881ab8..0000000 --- a/lib/oas_rails/responses.rb +++ /dev/null @@ -1,20 +0,0 @@ -module OasRails - class Responses < OasBase - attr_accessor :responses - - def initialize(responses) - super() - @responses = responses - end - - def to_spec - @responses.each_with_object({}) { |r, object| object[r.code] = r.to_spec } - end - - class << self - def from_tags(tags:) - new(tags.map { |t| Response.new(code: t.name.to_i, description: t.text, content: { "application/json": MediaType.new(schema: t.schema) }) }) - end - end - end -end diff --git a/lib/oas_rails/server.rb b/lib/oas_rails/server.rb deleted file mode 100644 index b46bf98..0000000 --- a/lib/oas_rails/server.rb +++ /dev/null @@ -1,10 +0,0 @@ -module OasRails - class Server < OasBase - attr_accessor :url, :description - - def initialize(url:, description:) - @url = url - @description = description - end - end -end diff --git a/lib/oas_rails/spec/base.rb b/lib/oas_rails/spec/base.rb new file mode 100644 index 0000000..5f152c7 --- /dev/null +++ b/lib/oas_rails/spec/base.rb @@ -0,0 +1,32 @@ +module OasRails + module Spec + class Base + def to_spec + hash = {} + instance_variables.each do |var| + key = var.to_s.delete('@') + camel_case_key = key.camelize(:lower).to_sym + value = instance_variable_get(var) + + processed_value = if value.respond_to?(:to_spec) + value.to_spec + else + value + end + + # hash[camel_case_key] = processed_value unless (processed_value.is_a?(Hash) || processed_value.is_a?(Array)) && processed_value.empty? + hash[camel_case_key] = processed_value + end + hash + end + + private + + def snake_to_camel(snake_str) + words = snake_str.to_s.split('_') + words[1..].map!(&:capitalize) + (words[0] + words[1..].join).to_sym + end + end + end +end diff --git a/lib/oas_rails/spec/contact.rb b/lib/oas_rails/spec/contact.rb new file mode 100644 index 0000000..6b3b654 --- /dev/null +++ b/lib/oas_rails/spec/contact.rb @@ -0,0 +1,14 @@ +module OasRails + module Spec + class Contact < Spec::Base + attr_accessor :name, :url, :email + + def initialize(**kwargs) + super() + @name = kwargs[:name] || '' + @url = kwargs[:url] || '' + @email = kwargs[:email] || '' + end + end + end +end diff --git a/lib/oas_rails/info.rb b/lib/oas_rails/spec/info.rb similarity index 59% rename from lib/oas_rails/info.rb rename to lib/oas_rails/spec/info.rb index 9169577..7bd5037 100644 --- a/lib/oas_rails/info.rb +++ b/lib/oas_rails/spec/info.rb @@ -1,28 +1,29 @@ module OasRails - class Info < OasBase - attr_accessor :title, :summary, :description, :terms_of_service, :contact, :license, :version - - def initialize(**kwargs) - super() - @title = kwargs[:title] || default_title - @summary = kwargs[:summary] || default_summary - @description = kwargs[:description] || default_description - @terms_of_service = kwargs[:terms_of_service] || '' - @contact = Contact.new - @license = License.new - @version = kwargs[:version] || '0.0.1' - end - - def default_title - "OasRails #{VERSION}" - end - - def default_summary - "OasRails: Automatic Interactive API Documentation for Rails" - end - - def default_description - "# Welcome to OasRails + module Spec + class Info < Spec::Base + attr_accessor :title, :summary, :description, :terms_of_service, :contact, :license, :version + + def initialize(**kwargs) + super() + @title = kwargs[:title] || default_title + @summary = kwargs[:summary] || default_summary + @description = kwargs[:description] || default_description + @terms_of_service = kwargs[:terms_of_service] || '' + @contact = Spec::Contact.new + @license = Spec::License.new + @version = kwargs[:version] || '0.0.1' + end + + def default_title + "OasRails #{VERSION}" + end + + def default_summary + "OasRails: Automatic Interactive API Documentation for Rails" + end + + def default_description + "# Welcome to OasRails OasRails automatically generates interactive documentation for your Rails APIs using the OpenAPI Specification 3.1 (OAS 3.1) and displays it with a nice UI. @@ -55,6 +56,7 @@ def default_description For more information and advanced usage, visit the [OasRails GitHub repository](https://github.com/a-chacon/oas_rails). " + end end end end diff --git a/lib/oas_rails/spec/license.rb b/lib/oas_rails/spec/license.rb new file mode 100644 index 0000000..0c155bb --- /dev/null +++ b/lib/oas_rails/spec/license.rb @@ -0,0 +1,13 @@ +module OasRails + module Spec + class License < Spec::Base + attr_accessor :name, :url + + def initialize(**kwargs) + super() + @name = kwargs[:name] || 'GPL 3.0' + @url = kwargs[:url] || 'https://www.gnu.org/licenses/gpl-3.0.html#license-text' + end + end + end +end diff --git a/lib/oas_rails/spec/media_type.rb b/lib/oas_rails/spec/media_type.rb new file mode 100644 index 0000000..9bdc588 --- /dev/null +++ b/lib/oas_rails/spec/media_type.rb @@ -0,0 +1,104 @@ +module OasRails + module Spec + class MediaType < Spec::Base + 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] || {} + end + + class << self + @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 = 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:)) + new(media_type: "", schema:, examples:) + end + + # Searches for examples in test files based on the provided class and test framework. + # + # @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. + def search_for_examples_in_tests(klass:, context: :incoming, utils: Utils) + @context = context + case utils.detect_test_framework + when :factory_bot + fetch_factory_bot_examples(klass:) + when :fixtures + 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(' ', '_') + value = { + "summary" => example.text, + "value" => example.content + } + 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 +end diff --git a/lib/oas_rails/spec/operation.rb b/lib/oas_rails/spec/operation.rb new file mode 100644 index 0000000..ea2783e --- /dev/null +++ b/lib/oas_rails/spec/operation.rb @@ -0,0 +1,136 @@ +module OasRails + module Spec + class Operation < Spec::Base + attr_accessor :tags, :summary, :description, :operation_id, :parameters, :method, :docstring, :request_body, :responses, :security + + def initialize(method:, summary:, operation_id:, **kwargs) + super() + @method = method + @summary = summary + @operation_id = operation_id + @tags = kwargs[:tags] || [] + @description = kwargs[:description] || @summary + @parameters = kwargs[:parameters] || [] + @request_body = kwargs[:request_body] || {} + @responses = kwargs[:responses] || {} + @security = kwargs[:security] || [] + end + + class << self + def from_oas_route(oas_route:) + summary = extract_summary(oas_route:) + operation_id = extract_operation_id(oas_route:) + tags = extract_tags(oas_route:) + description = oas_route.docstring + parameters = extract_parameters(oas_route:) + request_body = extract_request_body(oas_route:) + responses = extract_responses(oas_route:) + security = extract_security(oas_route:) + new(method: oas_route.verb.downcase, summary:, operation_id:, tags:, description:, parameters:, request_body:, responses:, security:) + end + + def extract_summary(oas_route:) + oas_route.docstring.tags(:summary).first.try(:text) || generate_crud_name(oas_route.method, oas_route.controller.downcase) || (oas_route.verb + " " + oas_route.path) + end + + def generate_crud_name(method, controller) + controller_name = controller.to_s.underscore.humanize.downcase.pluralize + + case method.to_sym + when :index + "List #{controller_name}" + when :show + "View #{controller_name.singularize}" + when :create + "Create new #{controller_name.singularize}" + when :update + "Update #{controller_name.singularize}" + when :destroy + "Delete #{controller_name.singularize}" + end + end + + def extract_operation_id(oas_route:) + "#{oas_route.method}#{oas_route.path.gsub('/', '_').gsub(/[{}]/, '')}" + end + + # This method should check tags defined by yard, then extract tag from path namespace or controller name depending on configuration + def extract_tags(oas_route:) + tags = oas_route.docstring.tags(:tags).first + if tags.nil? + default_tags(oas_route:) + else + tags.text.split(",").map(&:strip).map(&:titleize) + end + end + + def default_tags(oas_route:) + tags = [] + if OasRails.config.default_tags_from == "namespace" + tag = oas_route.path.split('/').reject(&:empty?).first.try(:titleize) + tags << tag unless tag.nil? + else + tags << oas_route.controller.titleize + end + tags + end + + def extract_parameters(oas_route:) + parameters = [] + parameters.concat(parameters_from_tags(tags: oas_route.docstring.tags(:parameter))) + oas_route.path_params.try(:map) do |p| + parameters << Spec::Parameter.from_path(path: oas_route.path, param: p) unless parameters.any? { |param| param.name.to_s == p.to_s } + end + parameters + end + + def parameters_from_tags(tags:) + tags.map do |t| + Spec::Parameter.new(name: t.name, location: t.location, required: t.required, schema: t.schema, description: t.text) + end + end + + def extract_request_body(oas_route:) + tag_request_body = oas_route.docstring.tags(:request_body).first + if tag_request_body.nil? && OasRails.config.autodiscover_request_body + oas_route.detect_request_body if %w[create update].include? oas_route.method + elsif !tag_request_body.nil? + Spec::RequestBody.from_tags(tag: tag_request_body, examples_tags: oas_route.docstring.tags(:request_body_example)) + else + {} + end + end + + def extract_responses(oas_route:) + responses = Spec::Responses.from_tags(tags: oas_route.docstring.tags(:response)) + + if OasRails.config.autodiscover_responses + 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 } + end + end + + responses + end + + def extract_security(oas_route:) + return [] if oas_route.docstring.tags(:no_auth).any? + + if (methods = oas_route.docstring.tags(:auth).first) + OasRails.config.security_schemas.keys.map { |key| { key => [] } }.select do |schema| + methods.types.include?(schema.keys.first.to_s) + end + elsif OasRails.config.authenticate_all_routes_by_default + OasRails.config.security_schemas.keys.map { |key| { key => [] } } + else + [] + end + end + + def external_docs; end + end + end + end +end diff --git a/lib/oas_rails/spec/parameter.rb b/lib/oas_rails/spec/parameter.rb new file mode 100644 index 0000000..f1b43d9 --- /dev/null +++ b/lib/oas_rails/spec/parameter.rb @@ -0,0 +1,49 @@ +module OasRails + module Spec + class Parameter + STYLE_DEFAULTS = { query: 'form', path: 'simple', header: 'simple', cookie: 'form' }.freeze + + attr_accessor :name, :in, :style, :description, :required, :schema + + def initialize(name:, location:, description:, **kwargs) + @name = name + @in = location + @description = description + + @required = kwargs[:required] || required? + @style = kwargs[:style] || default_from_in + @schema = kwargs[:schema] || { type: 'string' } + end + + def self.from_path(path:, param:) + new(name: param, location: 'path', + description: "#{param.split('_')[-1].titleize} of existing #{extract_word_before(path, param).singularize}.") + end + + def self.extract_word_before(string, param) + regex = %r{/(\w+)/\{#{param}\}} + match = string.match(regex) + match ? match[1] : nil + end + + def default_from_in + STYLE_DEFAULTS[@in.to_sym] + end + + def required? + @in == 'path' + end + + def to_spec + { + name: @name, + in: @in, + description: @description, + required: @required, + schema: @schema, + style: @style + } + end + end + end +end diff --git a/lib/oas_rails/spec/path_item.rb b/lib/oas_rails/spec/path_item.rb new file mode 100644 index 0000000..361dbd8 --- /dev/null +++ b/lib/oas_rails/spec/path_item.rb @@ -0,0 +1,27 @@ +module OasRails + module Spec + class PathItem + attr_reader :path, :operations, :parameters + + def initialize(path:, operations:, parameters:) + @path = path + @operations = operations + @parameters = parameters + end + + def self.from_oas_routes(path:, oas_routes:) + new(path: path, operations: oas_routes.map do |oas_route| + Spec::Operation.from_oas_route(oas_route: oas_route) + end, parameters: []) + end + + def to_spec + spec = {} + @operations.each do |o| + spec[o.method] = o.to_spec + end + spec + end + end + end +end diff --git a/lib/oas_rails/spec/paths.rb b/lib/oas_rails/spec/paths.rb new file mode 100644 index 0000000..3d39120 --- /dev/null +++ b/lib/oas_rails/spec/paths.rb @@ -0,0 +1,21 @@ +module OasRails + module Spec + class Paths + attr_accessor :path_items + + def initialize(path_items:) + @path_items = path_items + end + + def self.from_string_paths(string_paths:) + new(path_items: string_paths.map do |s| + Spec::PathItem.from_oas_routes(path: s, oas_routes: Extractors::RouteExtractor.host_routes_by_path(s)) + end) + end + + def to_spec + @path_items.each_with_object({}) { |p, object| object[p.path] = p.to_spec } + end + end + end +end diff --git a/lib/oas_rails/spec/request_body.rb b/lib/oas_rails/spec/request_body.rb new file mode 100644 index 0000000..2a7ee23 --- /dev/null +++ b/lib/oas_rails/spec/request_body.rb @@ -0,0 +1,31 @@ +module OasRails + module Spec + class RequestBody < Spec::Base + attr_accessor :description, :content, :required + + def initialize(description:, content:, required: false) + super() + @description = description + @content = content # Should be an array of media type object + @required = required + end + + class << self + def from_tags(tag:, examples_tags: []) + if tag.klass.ancestors.include? ActiveRecord::Base + from_model_class(klass: tag.klass, description: tag.text, required: tag.required, examples_tags:) + else + # hash content to schema + content = { "application/json": Spec::MediaType.new(schema: tag.schema, examples: Spec::MediaType.tags_to_examples(tags: examples_tags)) } + new(description: tag.text, content:, required: tag.required) + end + end + + def from_model_class(klass:, **kwargs) + content = { "application/json": Spec::MediaType.from_model_class(klass:, examples: Spec::MediaType.tags_to_examples(tags: kwargs[:examples_tags] || {})) } + new(description: kwargs[:description] || klass.to_s, content:, required: kwargs[:required]) + end + end + end + end +end diff --git a/lib/oas_rails/spec/response.rb b/lib/oas_rails/spec/response.rb new file mode 100644 index 0000000..8aacb25 --- /dev/null +++ b/lib/oas_rails/spec/response.rb @@ -0,0 +1,14 @@ +module OasRails + module Spec + class Response < Spec::Base + attr_accessor :code, :description, :content + + def initialize(code:, description:, content:) + super() + @code = code + @description = description + @content = content # Should be an array of media type object + end + end + end +end diff --git a/lib/oas_rails/spec/responses.rb b/lib/oas_rails/spec/responses.rb new file mode 100644 index 0000000..ba90dd8 --- /dev/null +++ b/lib/oas_rails/spec/responses.rb @@ -0,0 +1,22 @@ +module OasRails + module Spec + class Responses < Spec::Base + attr_accessor :responses + + def initialize(responses) + super() + @responses = responses + end + + def to_spec + @responses.each_with_object({}) { |r, object| object[r.code] = r.to_spec } + end + + class << self + def from_tags(tags:) + new(tags.map { |t| Response.new(code: t.name.to_i, description: t.text, content: { "application/json": Spec::MediaType.new(schema: t.schema) }) }) + end + end + end + end +end diff --git a/lib/oas_rails/spec/server.rb b/lib/oas_rails/spec/server.rb new file mode 100644 index 0000000..685413c --- /dev/null +++ b/lib/oas_rails/spec/server.rb @@ -0,0 +1,12 @@ +module OasRails + module Spec + class Server < Spec::Base + attr_accessor :url, :description + + def initialize(url:, description:) + @url = url + @description = description + end + end + end +end diff --git a/lib/oas_rails/spec/specification.rb b/lib/oas_rails/spec/specification.rb new file mode 100644 index 0000000..d4d32b8 --- /dev/null +++ b/lib/oas_rails/spec/specification.rb @@ -0,0 +1,74 @@ +require 'json' + +module OasRails + module Spec + class Specification + # Initializes a new Specification object. + # Clears the cache if running in the development environment. + def initialize + clear_cache unless Rails.env.production? + + @specification = base_spec + end + + # Clears the cache for MethodSource and RouteExtractor. + # + # @return [void] + def clear_cache + MethodSource.clear_cache + Extractors::RouteExtractor.clear_cache + end + + def to_json(*_args) + @specification.to_json + rescue StandardError => e + Rails.logger.error("Error Generating OAS: #{e.message}") + {} + end + + # Create the Base of the OAS hash. + # @see https://spec.openapis.org/oas/latest.html#schema + def base_spec + { + openapi: '3.1.0', + info: OasRails.config.info.to_spec, + servers: OasRails.config.servers.map(&:to_spec), + paths:, + components:, + security:, + tags: OasRails.config.tags.map(&:to_spec), + externalDocs: {} + } + end + + # Create the Security Requirement Object. + # @see https://spec.openapis.org/oas/latest.html#security-requirement-object + def security + return [] unless OasRails.config.authenticate_all_routes_by_default + + OasRails.config.security_schemas.map { |key, _| { key => [] } } + end + + # Create the Paths Object For the Root of the OAS. + # @see https://spec.openapis.org/oas/latest.html#paths-object + def paths + Spec::Paths.from_string_paths(string_paths: Extractors::RouteExtractor.host_paths).to_spec + end + + # Created the Components Object For the Root of the OAS. + # @see https://spec.openapis.org/oas/latest.html#components-object + def components + { + schemas: {}, parameters: {}, securitySchemes: security_schemas, requestBodies: {}, responses: {}, + headers: {}, examples: {}, links: {}, callbacks: {} + } + end + + # Create the Security Schemas Array inside components field of the OAS. + # @see https://spec.openapis.org/oas/latest.html#security-scheme-object + def security_schemas + OasRails.config.security_schemas + end + end + end +end diff --git a/lib/oas_rails/spec/tag.rb b/lib/oas_rails/spec/tag.rb new file mode 100644 index 0000000..0a3ed09 --- /dev/null +++ b/lib/oas_rails/spec/tag.rb @@ -0,0 +1,19 @@ +module OasRails + module Spec + class Tag + attr_accessor :name, :description + + def initialize(name:, description:) + @name = name.titleize + @description = description + end + + def to_spec + { + name: @name, + description: @description + } + end + end + end +end diff --git a/lib/oas_rails/specification.rb b/lib/oas_rails/specification.rb deleted file mode 100644 index 36b5a63..0000000 --- a/lib/oas_rails/specification.rb +++ /dev/null @@ -1,72 +0,0 @@ -require 'json' - -module OasRails - class Specification - # Initializes a new Specification object. - # Clears the cache if running in the development environment. - def initialize - clear_cache unless Rails.env.production? - - @specification = base_spec - end - - # Clears the cache for MethodSource and RouteExtractor. - # - # @return [void] - def clear_cache - MethodSource.clear_cache - Extractors::RouteExtractor.clear_cache - end - - def to_json(*_args) - @specification.to_json - rescue StandardError => e - Rails.logger.error("Error Generating OAS: #{e.message}") - {} - end - - # Create the Base of the OAS hash. - # @see https://spec.openapis.org/oas/latest.html#schema - def base_spec - { - openapi: '3.1.0', - info: OasRails.config.info.to_spec, - servers: OasRails.config.servers.map(&:to_spec), - paths:, - components:, - security:, - tags: OasRails.config.tags.map(&:to_spec), - externalDocs: {} - } - end - - # Create the Security Requirement Object. - # @see https://spec.openapis.org/oas/latest.html#security-requirement-object - def security - return [] unless OasRails.config.authenticate_all_routes_by_default - - OasRails.config.security_schemas.map { |key, _| { key => [] } } - end - - # 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: Extractors::RouteExtractor.host_paths).to_spec - end - - # Created the Components Object For the Root of the OAS. - # @see https://spec.openapis.org/oas/latest.html#components-object - def components - { - schemas: {}, parameters: {}, securitySchemes: security_schemas, requestBodies: {}, responses: {}, - headers: {}, examples: {}, links: {}, callbacks: {} - } - end - - # Create the Security Schemas Array inside components field of the OAS. - # @see https://spec.openapis.org/oas/latest.html#security-scheme-object - def security_schemas - OasRails.config.security_schemas - end - end -end diff --git a/lib/oas_rails/tag.rb b/lib/oas_rails/tag.rb deleted file mode 100644 index 88c13ba..0000000 --- a/lib/oas_rails/tag.rb +++ /dev/null @@ -1,17 +0,0 @@ -module OasRails - class Tag - attr_accessor :name, :description - - def initialize(name:, description:) - @name = name.titleize - @description = description - end - - def to_spec - { - name: @name, - description: @description - } - end - end -end diff --git a/test/lib/oas_rails/media_type_test.rb b/test/lib/oas_rails/media_type_test.rb deleted file mode 100644 index bf4d394..0000000 --- a/test/lib/oas_rails/media_type_test.rb +++ /dev/null @@ -1,66 +0,0 @@ -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/spec/media_type_test.rb b/test/lib/oas_rails/spec/media_type_test.rb new file mode 100644 index 0000000..eb9f979 --- /dev/null +++ b/test/lib/oas_rails/spec/media_type_test.rb @@ -0,0 +1,68 @@ +require "test_helper" + +module OasRails + module Spec + 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 = Spec::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 = Spec::MediaType.from_model_class(klass: User) + + assert_instance_of Spec::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 = Spec::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 = Spec::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 = Spec::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 +end From 717adeda14ff34dcfbd962ae6dda9f1a0f533e6d Mon Sep 17 00:00:00 2001 From: a-chacon Date: Tue, 6 Aug 2024 12:03:49 -0400 Subject: [PATCH 2/3] refactor: move classes that represent OAS into Spec module. This commit has a lot of changes but all should works normally. --- Gemfile | 2 +- Gemfile.lock | 4 ++ lib/oas_rails.rb | 5 ++- .../extractors/render_response_extractor.rb | 4 +- lib/oas_rails/spec/contact.rb | 8 +++- lib/oas_rails/spec/info.rb | 8 +++- lib/oas_rails/spec/license.rb | 9 +++- lib/oas_rails/spec/media_type.rb | 8 +++- lib/oas_rails/spec/operation.rb | 15 ++++--- lib/oas_rails/spec/parameter.rb | 12 ++---- lib/oas_rails/spec/path_item.rb | 18 ++++---- lib/oas_rails/spec/paths.rb | 16 +++++-- lib/oas_rails/spec/request_body.rb | 8 +++- lib/oas_rails/spec/response.rb | 10 +++-- lib/oas_rails/spec/responses.rb | 22 +++++++--- lib/oas_rails/spec/server.rb | 7 ++- lib/oas_rails/spec/{base.rb => specable.rb} | 27 ++++++++++-- lib/oas_rails/spec/specification.rb | 43 ++++++++++--------- lib/oas_rails/spec/tag.rb | 9 ++-- 19 files changed, 155 insertions(+), 80 deletions(-) rename lib/oas_rails/spec/{base.rb => specable.rb} (51%) diff --git a/Gemfile b/Gemfile index 4aa7134..ed61e11 100644 --- a/Gemfile +++ b/Gemfile @@ -23,4 +23,4 @@ group :development, :test do end # Start debugger with binding.b [https://github.com/ruby/debug] -# gem "debug", ">= 1.0.0" +gem "debug", ">= 1.0.0" diff --git a/Gemfile.lock b/Gemfile.lock index ec97ff3..875d3cf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,6 +92,9 @@ GEM connection_pool (2.4.1) crass (1.0.6) date (3.3.4) + debug (1.9.2) + irb (~> 1.10) + reline (>= 0.3.8) drb (2.2.1) erubi (1.13.0) esquema (0.1.2) @@ -212,6 +215,7 @@ PLATFORMS DEPENDENCIES bcrypt (~> 3.1.7) + debug (>= 1.0.0) factory_bot_rails faker jwt diff --git a/lib/oas_rails.rb b/lib/oas_rails.rb index e7d1826..3a1452c 100644 --- a/lib/oas_rails.rb +++ b/lib/oas_rails.rb @@ -11,8 +11,9 @@ module OasRails autoload :Utils, "oas_rails/utils" autoload :EsquemaBuilder, "oas_rails/esquema_builder" + # This module contains all the clases that represent a part of the OAS file. module Spec - autoload :Base, "oas_rails/spec/base" + autoload :Specable, "oas_rails/spec/specable" autoload :Parameter, "oas_rails/spec/parameter" autoload :License, "oas_rails/spec/license" autoload :Response, "oas_rails/spec/response" @@ -40,7 +41,7 @@ module Extractors class << self def build - Spec::Specification.new + Spec::Specification.new.to_spec end # Configurations for make the OasRails engine Work. diff --git a/lib/oas_rails/extractors/render_response_extractor.rb b/lib/oas_rails/extractors/render_response_extractor.rb index 5db0bf2..08768da 100644 --- a/lib/oas_rails/extractors/render_response_extractor.rb +++ b/lib/oas_rails/extractors/render_response_extractor.rb @@ -84,7 +84,7 @@ def process_non_hash_content(content) # @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)] + [schema, Spec::MediaType.search_for_examples_in_tests(klass:, context: :outgoing)] else [ { @@ -112,7 +112,7 @@ def build_singular_model_schema_and_examples(_maybe_a_model, errors, klass, sche # @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) } } } + examples = { maybe_a_model => { value: Spec::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 diff --git a/lib/oas_rails/spec/contact.rb b/lib/oas_rails/spec/contact.rb index 6b3b654..258ed2d 100644 --- a/lib/oas_rails/spec/contact.rb +++ b/lib/oas_rails/spec/contact.rb @@ -1,14 +1,18 @@ module OasRails module Spec - class Contact < Spec::Base + class Contact + include Specable attr_accessor :name, :url, :email def initialize(**kwargs) - super() @name = kwargs[:name] || '' @url = kwargs[:url] || '' @email = kwargs[:email] || '' end + + def oas_fields + [:name, :url, :email] + end end end end diff --git a/lib/oas_rails/spec/info.rb b/lib/oas_rails/spec/info.rb index 7bd5037..2c6e0cd 100644 --- a/lib/oas_rails/spec/info.rb +++ b/lib/oas_rails/spec/info.rb @@ -1,10 +1,10 @@ module OasRails module Spec - class Info < Spec::Base + class Info + include Specable attr_accessor :title, :summary, :description, :terms_of_service, :contact, :license, :version def initialize(**kwargs) - super() @title = kwargs[:title] || default_title @summary = kwargs[:summary] || default_summary @description = kwargs[:description] || default_description @@ -14,6 +14,10 @@ def initialize(**kwargs) @version = kwargs[:version] || '0.0.1' end + def oas_fields + [:title, :summary, :description, :terms_of_service, :contact, :license, :version] + end + def default_title "OasRails #{VERSION}" end diff --git a/lib/oas_rails/spec/license.rb b/lib/oas_rails/spec/license.rb index 0c155bb..609bd38 100644 --- a/lib/oas_rails/spec/license.rb +++ b/lib/oas_rails/spec/license.rb @@ -1,13 +1,18 @@ module OasRails module Spec - class License < Spec::Base + class License + include Specable + attr_accessor :name, :url def initialize(**kwargs) - super() @name = kwargs[:name] || 'GPL 3.0' @url = kwargs[:url] || 'https://www.gnu.org/licenses/gpl-3.0.html#license-text' end + + def oas_fields + [:name, :url] + end end end end diff --git a/lib/oas_rails/spec/media_type.rb b/lib/oas_rails/spec/media_type.rb index 9bdc588..e16b32a 100644 --- a/lib/oas_rails/spec/media_type.rb +++ b/lib/oas_rails/spec/media_type.rb @@ -1,6 +1,7 @@ module OasRails module Spec - class MediaType < Spec::Base + class MediaType + include Specable attr_accessor :schema, :example, :examples, :encoding # Initializes a new MediaType object. @@ -8,12 +9,15 @@ class MediaType < Spec::Base # @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] || {} end + def oas_fields + [:schema, :example, :examples, :encoding] + end + class << self @context = :incoming # Creates a new MediaType object from a model class. diff --git a/lib/oas_rails/spec/operation.rb b/lib/oas_rails/spec/operation.rb index ea2783e..875c2d0 100644 --- a/lib/oas_rails/spec/operation.rb +++ b/lib/oas_rails/spec/operation.rb @@ -1,11 +1,12 @@ module OasRails module Spec - class Operation < Spec::Base - attr_accessor :tags, :summary, :description, :operation_id, :parameters, :method, :docstring, :request_body, :responses, :security + class Operation + include Specable + + attr_accessor :tags, :summary, :description, :operation_id, :parameters, :meth, :docstring, :request_body, :responses, :security def initialize(method:, summary:, operation_id:, **kwargs) - super() - @method = method + @meth = method @summary = summary @operation_id = operation_id @tags = kwargs[:tags] || [] @@ -16,6 +17,10 @@ def initialize(method:, summary:, operation_id:, **kwargs) @security = kwargs[:security] || [] end + def oas_fields + [:tags, :summary, :description, :operation_id, :parameters, :request_body, :responses, :security] + end + class << self def from_oas_route(oas_route:) summary = extract_summary(oas_route:) @@ -108,7 +113,7 @@ def extract_responses(oas_route:) 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 } + responses.responses[new_response.code] = new_response if responses.responses[new_response.code].blank? end end diff --git a/lib/oas_rails/spec/parameter.rb b/lib/oas_rails/spec/parameter.rb index f1b43d9..47dfd55 100644 --- a/lib/oas_rails/spec/parameter.rb +++ b/lib/oas_rails/spec/parameter.rb @@ -1,6 +1,7 @@ module OasRails module Spec class Parameter + include Specable STYLE_DEFAULTS = { query: 'form', path: 'simple', header: 'simple', cookie: 'form' }.freeze attr_accessor :name, :in, :style, :description, :required, :schema @@ -34,15 +35,8 @@ def required? @in == 'path' end - def to_spec - { - name: @name, - in: @in, - description: @description, - required: @required, - schema: @schema, - style: @style - } + def oas_fields + [:name, :in, :description, :required, :schema, :style] end end end diff --git a/lib/oas_rails/spec/path_item.rb b/lib/oas_rails/spec/path_item.rb index 361dbd8..35e8c8b 100644 --- a/lib/oas_rails/spec/path_item.rb +++ b/lib/oas_rails/spec/path_item.rb @@ -1,24 +1,26 @@ module OasRails module Spec class PathItem + include Specable attr_reader :path, :operations, :parameters - def initialize(path:, operations:, parameters:) - @path = path + def initialize(operations:, parameters:) @operations = operations @parameters = parameters end - def self.from_oas_routes(path:, oas_routes:) - new(path: path, operations: oas_routes.map do |oas_route| - Spec::Operation.from_oas_route(oas_route: oas_route) - end, parameters: []) + def self.from_oas_routes(oas_routes:) + operations = oas_routes.each_with_object({}) do |oas_route, object| + object[oas_route.verb.downcase] = Spec::Operation.from_oas_route(oas_route:) + end + + new(operations:, parameters: []) end def to_spec spec = {} - @operations.each do |o| - spec[o.method] = o.to_spec + @operations.each do |key, value| + spec[key] = value.to_spec end spec end diff --git a/lib/oas_rails/spec/paths.rb b/lib/oas_rails/spec/paths.rb index 3d39120..f5457e3 100644 --- a/lib/oas_rails/spec/paths.rb +++ b/lib/oas_rails/spec/paths.rb @@ -1,6 +1,8 @@ module OasRails module Spec class Paths + include Specable + attr_accessor :path_items def initialize(path_items:) @@ -8,13 +10,19 @@ def initialize(path_items:) end def self.from_string_paths(string_paths:) - new(path_items: string_paths.map do |s| - Spec::PathItem.from_oas_routes(path: s, oas_routes: Extractors::RouteExtractor.host_routes_by_path(s)) - end) + path_items = string_paths.each_with_object({}) do |s, object| + object[s] = Spec::PathItem.from_oas_routes(oas_routes: Extractors::RouteExtractor.host_routes_by_path(s)) + end + + new(path_items:) end def to_spec - @path_items.each_with_object({}) { |p, object| object[p.path] = p.to_spec } + paths_hash = {} + @path_items.each do |path, path_object| + paths_hash[path] = path_object.to_spec + end + paths_hash end end end diff --git a/lib/oas_rails/spec/request_body.rb b/lib/oas_rails/spec/request_body.rb index 2a7ee23..f246efd 100644 --- a/lib/oas_rails/spec/request_body.rb +++ b/lib/oas_rails/spec/request_body.rb @@ -1,15 +1,19 @@ module OasRails module Spec - class RequestBody < Spec::Base + class RequestBody + include Specable attr_accessor :description, :content, :required def initialize(description:, content:, required: false) - super() @description = description @content = content # Should be an array of media type object @required = required end + def oas_fields + [:description, :content, :required] + end + class << self def from_tags(tag:, examples_tags: []) if tag.klass.ancestors.include? ActiveRecord::Base diff --git a/lib/oas_rails/spec/response.rb b/lib/oas_rails/spec/response.rb index 8aacb25..dd6c755 100644 --- a/lib/oas_rails/spec/response.rb +++ b/lib/oas_rails/spec/response.rb @@ -1,13 +1,17 @@ module OasRails module Spec - class Response < Spec::Base + class Response + include Specable attr_accessor :code, :description, :content def initialize(code:, description:, content:) - super() @code = code @description = description - @content = content # Should be an array of media type object + @content = content # Hash with {content: MediaType} + end + + def oas_fields + [:code, :description, :content] end end end diff --git a/lib/oas_rails/spec/responses.rb b/lib/oas_rails/spec/responses.rb index ba90dd8..67e74fa 100644 --- a/lib/oas_rails/spec/responses.rb +++ b/lib/oas_rails/spec/responses.rb @@ -1,20 +1,32 @@ module OasRails module Spec - class Responses < Spec::Base + class Responses + include Specable attr_accessor :responses - def initialize(responses) - super() + def initialize(responses:) @responses = responses end def to_spec - @responses.each_with_object({}) { |r, object| object[r.code] = r.to_spec } + spec = {} + @responses.each do |key, value| + spec[key] = value.to_spec + end + spec end class << self def from_tags(tags:) - new(tags.map { |t| Response.new(code: t.name.to_i, description: t.text, content: { "application/json": Spec::MediaType.new(schema: t.schema) }) }) + responses = tags.each_with_object({}) do |t, object| + object[t.name.to_i] = Spec::Response.new( + code: t.name.to_i, + description: t.text, + content: { "application/json": Spec::MediaType.new(schema: t.schema) } + ) + end + + new(responses:) end end end diff --git a/lib/oas_rails/spec/server.rb b/lib/oas_rails/spec/server.rb index 685413c..c5e8640 100644 --- a/lib/oas_rails/spec/server.rb +++ b/lib/oas_rails/spec/server.rb @@ -1,12 +1,17 @@ module OasRails module Spec - class Server < Spec::Base + class Server + include Specable attr_accessor :url, :description def initialize(url:, description:) @url = url @description = description end + + def oas_fields + [:url, :description] + end end end end diff --git a/lib/oas_rails/spec/base.rb b/lib/oas_rails/spec/specable.rb similarity index 51% rename from lib/oas_rails/spec/base.rb rename to lib/oas_rails/spec/specable.rb index 5f152c7..e972213 100644 --- a/lib/oas_rails/spec/base.rb +++ b/lib/oas_rails/spec/specable.rb @@ -1,15 +1,30 @@ module OasRails module Spec - class Base + module Specable + def oas_fields + [] + end + def to_spec hash = {} - instance_variables.each do |var| - key = var.to_s.delete('@') + oas_fields.each do |var| + key = var.to_s + camel_case_key = key.camelize(:lower).to_sym - value = instance_variable_get(var) + value = send(var) processed_value = if value.respond_to?(:to_spec) value.to_spec + elsif value.is_a?(Array) && value.all? { |elem| elem.respond_to?(:to_spec) } + value.map(&:to_spec) + # elsif value.is_a?(Hash) + # p "Here" + # p value + # hash = {} + # value.each do |key, object| + # hash[key] = object.to_spec + # end + # hash else value end @@ -20,6 +35,10 @@ def to_spec hash end + def as_json + to_spec + end + private def snake_to_camel(snake_str) diff --git a/lib/oas_rails/spec/specification.rb b/lib/oas_rails/spec/specification.rb index d4d32b8..11501ad 100644 --- a/lib/oas_rails/spec/specification.rb +++ b/lib/oas_rails/spec/specification.rb @@ -3,12 +3,11 @@ module OasRails module Spec class Specification + include Specable # Initializes a new Specification object. # Clears the cache if running in the development environment. def initialize clear_cache unless Rails.env.production? - - @specification = base_spec end # Clears the cache for MethodSource and RouteExtractor. @@ -19,26 +18,28 @@ def clear_cache Extractors::RouteExtractor.clear_cache end - def to_json(*_args) - @specification.to_json - rescue StandardError => e - Rails.logger.error("Error Generating OAS: #{e.message}") - {} + def oas_fields + [:openapi, :info, :servers, :paths, :components, :security, :tags, :external_docs] end - # Create the Base of the OAS hash. - # @see https://spec.openapis.org/oas/latest.html#schema - def base_spec - { - openapi: '3.1.0', - info: OasRails.config.info.to_spec, - servers: OasRails.config.servers.map(&:to_spec), - paths:, - components:, - security:, - tags: OasRails.config.tags.map(&:to_spec), - externalDocs: {} - } + def openapi + '3.1.0' + end + + def info + OasRails.config.info + end + + def servers + OasRails.config.servers + end + + def tags + OasRails.config.tags + end + + def external_docs + {} end # Create the Security Requirement Object. @@ -52,7 +53,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 - Spec::Paths.from_string_paths(string_paths: Extractors::RouteExtractor.host_paths).to_spec + Spec::Paths.from_string_paths(string_paths: Extractors::RouteExtractor.host_paths) end # Created the Components Object For the Root of the OAS. diff --git a/lib/oas_rails/spec/tag.rb b/lib/oas_rails/spec/tag.rb index 0a3ed09..2fdf79f 100644 --- a/lib/oas_rails/spec/tag.rb +++ b/lib/oas_rails/spec/tag.rb @@ -1,6 +1,8 @@ module OasRails module Spec class Tag + include Specable + attr_accessor :name, :description def initialize(name:, description:) @@ -8,11 +10,8 @@ def initialize(name:, description:) @description = description end - def to_spec - { - name: @name, - description: @description - } + def oas_fields + [:name, :description] end end end From f52a836ba3d2406fe533ef3769fd4ed444dbbefb Mon Sep 17 00:00:00 2001 From: a-chacon Date: Tue, 6 Aug 2024 12:30:08 -0400 Subject: [PATCH 3/3] fix: rubocop offense --- lib/oas_rails/spec/operation.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/oas_rails/spec/operation.rb b/lib/oas_rails/spec/operation.rb index 875c2d0..0055e9b 100644 --- a/lib/oas_rails/spec/operation.rb +++ b/lib/oas_rails/spec/operation.rb @@ -35,7 +35,7 @@ def from_oas_route(oas_route:) end def extract_summary(oas_route:) - oas_route.docstring.tags(:summary).first.try(:text) || generate_crud_name(oas_route.method, oas_route.controller.downcase) || (oas_route.verb + " " + oas_route.path) + oas_route.docstring.tags(:summary).first.try(:text) || generate_crud_name(oas_route.method, oas_route.controller.downcase) || "#{oas_route.verb} #{oas_route.path}" end def generate_crud_name(method, controller)