Skip to content

Commit

Permalink
refactor: redefine the way request body and responses are described. …
Browse files Browse the repository at this point in the history
…BREAKING.

* refactor(yard): separate clases into different files

* refactor: BREAKING! support deeps hashes for describe request body, parameters and responses but changes the syntax used. This fix date and date time support too.

* fix: cops validation
  • Loading branch information
a-chacon authored Aug 23, 2024
1 parent 838695e commit ed5eef5
Show file tree
Hide file tree
Showing 15 changed files with 468 additions and 185 deletions.
32 changes: 21 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ OasRails is a Rails engine for generating **automatic interactive documentation

After experiencing the interactive documentation in Python's fast-api framework, I sought similar functionality in Ruby on Rails. Unable to find a suitable solution, I [asked on Stack Overflow](https://stackoverflow.com/questions/71947018/is-there-a-way-to-generate-an-interactive-documentation-for-rails-apis) years ago. Now, with some free time while freelancing as an API developer, I decided to build my own tool.

**Note**: This is not yet a production-ready solution. The code may be rough and behave unexpectedly, but I am actively working on improving it. If you like the idea, please consider contributing to its development.
**Note: This is not yet a production-ready solution. The code may be rough and behave unexpectedly, but I am actively working on improving it. If you like the idea, please consider contributing to its development.**

The goal is to minimize the effort required to create comprehensive documentation. By following REST principles in Rails, we believe this is achievable. You can enhance the documentation using [Yard](https://yardoc.org/) tags.

Expand Down Expand Up @@ -120,7 +120,7 @@ Then fill it with your data. Below are the available configuration options:
- `config.possible_default_responses`: Array with possible default errors.(Some will be added depending on the endpoint, example: not_found only works with show/update/delete). Default: [:not_found, :unauthorized, :forbidden]. It should be HTTP status code symbols from the list: `[:not_found, :unauthorized, :forbidden, :internal_server_error, :unprocessable_entity]`
- `config.response_body_of_default`: body for use in default responses. It must be a Hash. Default: { message: String }
- `config.response_body_of_default`: body for use in default responses. It must be a String hash like the used in request body tags. Default: "{ message: String }"
## Usage
Expand Down Expand Up @@ -158,12 +158,19 @@ Represents a parameter for the endpoint. The position can be: `header`, `path`,
<details>
<summary style="font-weight: bold; font-size: 1.2em;">@request_body</summary>
**Structure**: `@request_body text [type] structure`
**Structure**: `@request_body text [type<structure>]`
Documents the request body needed by the endpoint. The structure is optional if you provide a valid Active Record class. Use `!` to indicate a required request body.
**Example**:
`# @request_body The user to be created [Hash] {user: {name: String, age: Integer, password: String}}`
`# @request_body The user to be created [!Hash{user: {name: String, age: Integer, password: String}}]`
`# @request_body The user to be created [!User]`
`# @request_body The user to be created [User]`
`# @request_body The user to be created [!Hash{user: {name: String, age: Integer, password: String, surnames: Array<String>, coords: Hash{lat: String, lng: String}}}]`
</details>
Expand All @@ -182,12 +189,15 @@ Adds examples to the provided request body.
<details>
<summary style="font-weight: bold; font-size: 1.2em;">@response</summary>
**Structure**: `@response text(code) [type] structure`
**Structure**: `@response text(code) [type<structure>]`
Documents the responses of the endpoint and overrides the default responses found by the engine.
**Example**:
`# @response User not found by the provided Id(404) [Hash] {success: Boolean, message: String}`
`# @response User not found by the provided Id(404) [Hash{success: Boolean, message: String}]`
`# @response Validation errors(422) [Hash{success: Boolean, erros: Array<Hash{field: String, type: String, detail: Array<String>}>}]`
</details>
Expand Down Expand Up @@ -250,17 +260,17 @@ class UsersController < ApplicationController
# This method show a User by ID. The id must exist of other way it will be returning a **`404`**.
#
# @parameter id(path) [Integer] Used for identify the user.
# @response Requested User(200) [Hash] {user: {name: String, email: String, created_at: DateTime }}
# @response User not found by the provided Id(404) [Hash] {success: Boolean, message: String}
# @response You don't have the right permission for access to this resource(403) [Hash] {success: Boolean, message: String}
# @response Requested User(200) [Hash{user: {name: String, email: String, created_at: DateTime }}]
# @response User not found by the provided Id(404) [Hash{success: Boolean, message: String}]
# @response You don't have the right permission for access to this resource(403) [Hash{success: Boolean, message: String}]
def show
render json: @user
end
# @summary Create a User
# @no_auth
#
# @request_body The user to be created. At least include an `email`. [User!]
# @request_body The user to be created. At least include an `email`. [!User]
# @request_body_example basic user [Hash] {user: {name: "Luis", email: "[email protected]"}}
def create
@user = User.new(user_params)
Expand All @@ -276,7 +286,7 @@ class UsersController < ApplicationController
# - There is no option
# - It must work
# @tags users, update
# @request_body User to be created [Hash] {user: { name: String, email: String, age: Integer}}
# @request_body User to be created [!Hash{user: { name: String, email: !String, age: Integer, available_dates: Array<Date>}}]
# @request_body_example Update user [Hash] {user: {name: "Luis", email: "[email protected]"}}
# @request_body_example Complete User [Hash] {user: {name: "Luis", email: "[email protected]", age: 21}}
def update
Expand Down
9 changes: 7 additions & 2 deletions lib/oas_rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ module OasRails
autoload :Configuration, "oas_rails/configuration"
autoload :OasRoute, "oas_rails/oas_route"
autoload :Utils, "oas_rails/utils"
autoload :JsonSchemaGenerator, "oas_rails/json_schema_generator"

module Builders
autoload :OperationBuilder, "oas_rails/builders/operation_builder"
Expand Down Expand Up @@ -45,7 +46,11 @@ module Spec
end

module YARD
autoload :OasYARDFactory, 'oas_rails/yard/oas_yard_factory'
autoload :RequestBodyTag, 'oas_rails/yard/request_body_tag'
autoload :RequestBodyExampleTag, 'oas_rails/yard/request_body_example_tag'
autoload :ParameterTag, 'oas_rails/yard/parameter_tag'
autoload :ResponseTag, 'oas_rails/yard/response_tag'
autoload :OasRailsFactory, 'oas_rails/yard/oas_rails_factory'
end

module Extractors
Expand Down Expand Up @@ -73,7 +78,7 @@ def config
end

def configure_yard!
::YARD::Tags::Library.default_factory = YARD::OasYARDFactory
::YARD::Tags::Library.default_factory = YARD::OasRailsFactory
yard_tags = {
'Request body' => [:request_body, :with_request_body],
'Request body Example' => [:request_body_example, :with_request_body_example],
Expand Down
2 changes: 1 addition & 1 deletion lib/oas_rails/builders/responses_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def add_autodiscovered_responses(oas_route)
def add_default_responses(oas_route, security)
return self unless OasRails.config.set_default_responses

content = ContentBuilder.new(@specification, :outgoing).with_schema(Utils.hash_to_json_schema(OasRails.config.response_body_of_default)).build
content = ContentBuilder.new(@specification, :outgoing).with_schema(JsonSchemaGenerator.process_string(OasRails.config.response_body_of_default)[:json_schema]).build
common_errors = []
common_errors.push(:unauthorized, :forbidden) if security

Expand Down
2 changes: 1 addition & 1 deletion lib/oas_rails/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def initialize
@security_schemas = {}
@set_default_responses = true
@possible_default_responses = [:not_found, :unauthorized, :forbidden]
@response_body_of_default = { message: String }
@response_body_of_default = "{ message: String }"
end

def security_schema=(value)
Expand Down
127 changes: 127 additions & 0 deletions lib/oas_rails/json_schema_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
require 'json'

module OasRails
# The JsonSchemaGenerator module provides methods to transform string representations
# of data types into JSON schema formats.
module JsonSchemaGenerator
# Processes a string representing a data type and converts it into a JSON schema.
#
# @param str [String] The string representation of a data type.
# @return [Hash] A hash containing the required flag and the JSON schema.
def self.process_string(str)
parsed = parse_type(str)
{
required: parsed[:required],
json_schema: to_json_schema(parsed)
}
end

# Parses a string representing a data type and determines its JSON schema type.
#
# @param str [String] The string representation of a data type.
# @return [Hash] A hash containing the type, whether it's required, and any additional properties.
def self.parse_type(str)
required = str.start_with?('!')
type = str.sub(/^!/, '').strip

case type
when /^Hash\{(.+)\}$/i
{ type: :object, required:, properties: parse_object_properties(::Regexp.last_match(1)) }
when /^Array<(.+)>$/i
{ type: :array, required:, items: parse_type(::Regexp.last_match(1)) }
else
{ type: type.downcase.to_sym, required: }
end
end

# Parses the properties of an object type from a string.
#
# @param str [String] The string representation of the object's properties.
# @return [Hash] A hash where keys are property names and values are their JSON schema types.
def self.parse_object_properties(str)
properties = {}
stack = []
current_key = ''
current_value = ''

str.each_char.with_index do |char, index|
case char
when '{', '<'
stack.push(char)
current_value += char
when '}', '>'
stack.pop
current_value += char
when ','
if stack.empty?
properties[current_key.strip.to_sym] = parse_type(current_value.strip)
current_key = ''
current_value = ''
else
current_value += char
end
when ':'
if stack.empty?
current_key = current_value
current_value = ''
else
current_value += char
end
else
current_value += char
end

properties[current_key.strip.to_sym] = parse_type(current_value.strip) if index == str.length - 1 && !current_key.empty?
end

properties
end

# Converts a parsed data type into a JSON schema format.
#
# @param parsed [Hash] The parsed data type hash.
# @return [Hash] The JSON schema representation of the parsed data type.
def self.to_json_schema(parsed)
case parsed[:type]
when :object
schema = {
type: 'object',
properties: {}
}
required_props = []
parsed[:properties].each do |key, value|
schema[:properties][key] = to_json_schema(value)
required_props << key.to_s if value[:required]
end
schema[:required] = required_props unless required_props.empty?
schema
when :array
{
type: 'array',
items: to_json_schema(parsed[:items])
}
else
ruby_type_to_json_schema_type(parsed[:type])
end
end

# Converts a Ruby data type into its corresponding JSON schema type.
#
# @param type [Symbol, String] The Ruby data type.
# @return [Hash, String] The JSON schema type or a hash with additional format information.
def self.ruby_type_to_json_schema_type(type)
case type.to_s.downcase
when 'string' then { type: "string" }
when 'integer' then { type: "integer" }
when 'float' then { type: "float" }
when 'boolean' then { type: "boolean" }
when 'array' then { type: "array" }
when 'hash' then { type: "hash" }
when 'nil' then { type: "null" }
when 'date' then { type: "string", format: "date" }
when 'datetime' then { type: "string", format: "date-time" }
else type.to_s.downcase
end
end
end
end
Loading

0 comments on commit ed5eef5

Please sign in to comment.