Skip to content

Commit

Permalink
Merge pull request #109 from rage-rb/openapi
Browse files Browse the repository at this point in the history
Implement OpenAPI parser
  • Loading branch information
rsamoilov authored Dec 18, 2024
2 parents 0f493e8 + c5b1b66 commit 583535d
Show file tree
Hide file tree
Showing 60 changed files with 5,418 additions and 17 deletions.
2 changes: 1 addition & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -913,7 +913,7 @@ Style/RedundantFreeze:
Enabled: true

Style/RedundantHeredocDelimiterQuotes:
Enabled: true
Enabled: false

Style/RedundantInterpolation:
Enabled: true
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ group :test do
gem "rbnacl"
gem "domain_name"
gem "websocket-client-simple"
gem "prism"
end
5 changes: 5 additions & 0 deletions lib/rage-rb.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ def self.cable
Rage::Cable
end

def self.openapi
Rage::OpenAPI
end

def self.routes
Rage::Router::DSL.new(__router)
end
Expand Down Expand Up @@ -121,6 +125,7 @@ module ActiveRecord
autoload :Cookies, "rage/cookies"
autoload :Session, "rage/session"
autoload :Cable, "rage/cable/cable"
autoload :OpenAPI, "rage/openapi/openapi"
end

module RageController
Expand Down
8 changes: 8 additions & 0 deletions lib/rage/code_loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ def reload
unless Rage.autoload?(:Cable) # the `Cable` component is loaded
Rage::Cable.__router.reset
end

unless Rage.autoload?(:OpenAPI) # the `OpenAPI` component is loaded
Rage::OpenAPI.__reset_data_cache
end
end

# in Rails mode - reset the routes; everything else will be done by Rails
Expand All @@ -49,6 +53,10 @@ def rails_mode_reload
unless Rage.autoload?(:Cable) # the `Cable` component is loaded
Rage::Cable.__router.reset
end

unless Rage.autoload?(:OpenAPI) # the `OpenAPI` component is loaded
Rage::OpenAPI.__reset_data_cache
end
end

def reloading?
Expand Down
23 changes: 23 additions & 0 deletions lib/rage/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,17 @@
#
# > Allows requests from any origin.
#
# # OpenAPI Configuration
# • _config.openapi.tag_resolver_
#
# > Specifies the proc to build tags for API operations. The proc accepts the controller class, the symbol name of the action, and the default tag built by Rage.
#
# > ```ruby
# config.openapi.tag_resolver = proc do |controller, action, default_tag|
# # ...
# end
# > ```
#
# # Transient Settings
#
# The settings described in this section should be configured using **environment variables** and are either temporary or will become the default in the future.
Expand Down Expand Up @@ -179,6 +190,10 @@ def public_file_server
@public_file_server ||= PublicFileServer.new
end

def openapi
@openapi ||= OpenAPI.new
end

def internal
@internal ||= Internal.new
end
Expand Down Expand Up @@ -218,6 +233,10 @@ def insert_after(existing_middleware, new_middleware, *args, &block)
@middlewares = (@middlewares[0..index] + [[new_middleware, args, block]] + @middlewares[index + 1..]).uniq(&:first)
end

def include?(middleware)
!!find_middleware_index(middleware) rescue false
end

private

def find_middleware_index(middleware)
Expand Down Expand Up @@ -264,6 +283,10 @@ class PublicFileServer
attr_accessor :enabled
end

class OpenAPI
attr_accessor :tag_resolver
end

# @private
class Internal
attr_accessor :rails_mode
Expand Down
39 changes: 27 additions & 12 deletions lib/rage/controller/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,7 @@ def __register_action(action)
raise Rage::Errors::RouterError, "The action '#{action}' could not be found for #{self}" unless method_defined?(action)

before_actions_chunk = if @__before_actions
filtered_before_actions = @__before_actions.select do |h|
(!h[:only] || h[:only].include?(action)) &&
(!h[:except] || !h[:except].include?(action))
end

lines = filtered_before_actions.map do |h|
lines = __before_actions_for(action).map do |h|
condition = if h[:if] && h[:unless]
"if #{h[:if]} && !#{h[:unless]}"
elsif h[:if]
Expand All @@ -38,12 +33,7 @@ def __register_action(action)
end

after_actions_chunk = if @__after_actions
filtered_after_actions = @__after_actions.select do |h|
(!h[:only] || h[:only].include?(action)) &&
(!h[:except] || !h[:except].include?(action))
end

lines = filtered_after_actions.map! do |h|
lines = __after_actions_for(action).map do |h|
condition = if h[:if] && h[:unless]
"if #{h[:if]} && !#{h[:unless]}"
elsif h[:if]
Expand Down Expand Up @@ -319,6 +309,31 @@ def wrap_parameters(key, include: [], exclude: [])
@__wrap_parameters_options = { include:, exclude: }
end
# @private
def __before_action_exists?(name)
@__before_actions.any? { |h| h[:name] == name && !h[:around] }
end
# @private
def __before_actions_for(action_name)
return [] unless @__before_actions
@__before_actions.select do |h|
(!h[:only] || h[:only].include?(action_name)) &&
(!h[:except] || !h[:except].include?(action_name))
end
end
# @private
def __after_actions_for(action_name)
return [] unless @__after_actions
@__after_actions.select do |h|
(!h[:only] || h[:only].include?(action_name)) &&
(!h[:except] || !h[:except].include?(action_name))
end
end
private
# used by `before_action` and `after_action`
Expand Down
84 changes: 84 additions & 0 deletions lib/rage/openapi/builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# frozen_string_literal: true

##
# Build OpenAPI specification for the app. Consists of three steps:
#
# * `Rage::OpenAPI::Builder` - build a tree of action nodes;
# * `Rage::OpenAPI::Parser` - parse OpenAPI tags and save the result into the nodes;
# * `Rage::OpenAPI::Converter` - convert the tree into an OpenAPI spec;
#
class Rage::OpenAPI::Builder
class ParsingError < StandardError
end

def initialize(namespace: nil)
@namespace = namespace.to_s if namespace

@collectors_cache = {}
@nodes = Rage::OpenAPI::Nodes::Root.new
@routes = Rage.__router.routes.group_by { |route| route[:meta][:controller_class] }
end

def run
parser = Rage::OpenAPI::Parser.new

@routes.each do |controller, routes|
next if skip_controller?(controller)

parent_nodes = fetch_ancestors(controller).map do |klass|
@nodes.new_parent_node(klass) { |node| parser.parse_dangling_comments(node, parse_class(klass).dangling_comments) }
end

routes.each do |route|
action = route[:meta][:action]

method_comments = fetch_ancestors(controller).filter_map { |klass|
parse_class(klass).method_comments(action)
}.first

method_node = @nodes.new_method_node(controller, action, parent_nodes)
method_node.http_method, method_node.http_path = route[:method], route[:path]

parser.parse_method_comments(method_node, method_comments)
end

rescue ParsingError
Rage::OpenAPI.__log_warn "skipping #{controller.name} because of parsing error"
next
end

Rage::OpenAPI::Converter.new(@nodes).run
end

private

def skip_controller?(controller)
should_skip_controller = controller.nil? || !controller.ancestors.include?(RageController::API)
should_skip_controller ||= !controller.name.start_with?(@namespace) if @namespace

should_skip_controller
end

def fetch_ancestors(controller)
controller.ancestors.take_while { |klass| klass != RageController::API }
end

def parse_class(klass)
@collectors_cache[klass] ||= begin
source_path, _ = Object.const_source_location(klass.name)
ast = Prism.parse_file(source_path)

raise ParsingError if ast.errors.any?

# save the "comment => file" association
ast.comments.each do |comment|
comment.location.define_singleton_method(:__source_path) { source_path }
end

collector = Rage::OpenAPI::Collector.new(ast.comments)
ast.value.accept(collector)

collector
end
end
end
43 changes: 43 additions & 0 deletions lib/rage/openapi/collector.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

##
# Collect all global comments or comments attached to methods in a class.
# At this point we don't care whether these are Rage OpenAPI comments or not.
#
class Rage::OpenAPI::Collector < Prism::Visitor
def initialize(comments)
@comments = comments.dup
@method_comments = {}
end

def dangling_comments
@comments
end

def method_comments(method_name)
@method_comments[method_name.to_s]
end

def visit_def_node(node)
method_comments = []
start_line = node.location.start_line - 1

loop do
comment_i = @comments.find_index { |comment| comment.location.start_line == start_line }
if comment_i
comment = @comments.delete_at(comment_i)
method_comments << comment
start_line -= 1
end

break unless comment
end

@method_comments[node.name.to_s] = method_comments.reverse

# reject comments inside methods
@comments.reject! do |comment|
comment.location.start_line >= node.location.start_line && comment.location.start_line <= node.location.end_line
end
end
end
Loading

0 comments on commit 583535d

Please sign in to comment.