Skip to content

Commit

Permalink
Merge pull request #11 from a-chacon/generate-request-body-examples-f…
Browse files Browse the repository at this point in the history
…rom-factory-bot

feat(media_type): generate examples with factory bot if it is availab…
  • Loading branch information
a-chacon authored Aug 2, 2024
2 parents 7d42b8a + aa7f4aa commit caee6b6
Show file tree
Hide file tree
Showing 22 changed files with 524 additions and 262 deletions.
10 changes: 8 additions & 2 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
9 changes: 9 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -205,6 +212,8 @@ PLATFORMS

DEPENDENCIES
bcrypt (~> 3.1.7)
factory_bot_rails
faker
jwt
oas_rails!
puma
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 6 additions & 10 deletions lib/oas_rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
8 changes: 8 additions & 0 deletions lib/oas_rails/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
37 changes: 37 additions & 0 deletions lib/oas_rails/esquema_builder.rb
Original file line number Diff line number Diff line change
@@ -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
173 changes: 173 additions & 0 deletions lib/oas_rails/extractors/render_response_extractor.rb
Original file line number Diff line number Diff line change
@@ -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<Response>] 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<Array<String, String>>] 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<Hash, Hash>] 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<Hash, Hash>] 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<Hash, Hash>] 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<Hash, Hash>] 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<Symbol, String>] 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
Loading

0 comments on commit caee6b6

Please sign in to comment.