From 489dfc1685841d3800133da50cf294970c79a4e8 Mon Sep 17 00:00:00 2001
From: Roman Samoilov <2270393+rsamoilov@users.noreply.github.com>
Date: Sun, 8 Dec 2024 20:27:33 +0000
Subject: [PATCH] Implement OpenAPI parser
---
.rubocop.yml | 2 +-
Gemfile | 1 +
lib/rage-rb.rb | 5 +
lib/rage/code_loader.rb | 8 +
lib/rage/configuration.rb | 23 +
lib/rage/controller/api.rb | 39 +-
lib/rage/openapi/builder.rb | 84 ++
lib/rage/openapi/collector.rb | 43 +
lib/rage/openapi/converter.rb | 138 ++
lib/rage/openapi/index.html.erb | 22 +
lib/rage/openapi/nodes/method.rb | 24 +
lib/rage/openapi/nodes/parent.rb | 13 +
lib/rage/openapi/nodes/root.rb | 49 +
lib/rage/openapi/openapi.rb | 146 ++
lib/rage/openapi/parser.rb | 193 +++
lib/rage/openapi/parsers/ext/active_record.rb | 63 +
lib/rage/openapi/parsers/ext/alba.rb | 281 ++++
lib/rage/openapi/parsers/request.rb | 18 +
lib/rage/openapi/parsers/response.rb | 19 +
lib/rage/openapi/parsers/shared_reference.rb | 26 +
lib/rage/openapi/parsers/yaml.rb | 55 +
lib/rage/router/backend.rb | 1 +
spec/integration/integration_spec.rb | 17 +
spec/integration/test_app/Gemfile | 5 +-
.../app/controllers/api/base_controller.rb | 14 +
.../controllers/api/v1/users_controller.rb | 27 +
.../controllers/api/v2/users_controller.rb | 24 +
.../controllers/api/v3/users_controller.rb | 11 +
.../app/resources/api/v1/avatar_resource.rb | 8 +
.../app/resources/api/v1/user_resource.rb | 14 +
.../app/resources/base_user_resource.rb | 5 +
.../app/resources/comment_resource.rb | 4 +
.../test_app/app/resources/user_resource.rb | 6 +
spec/integration/test_app/config.ru | 4 +
.../test_app/config/initializers/alba.rb | 26 +
.../test_app/config/openapi_components.yml | 21 +
spec/integration/test_app/config/routes.rb | 14 +
spec/openapi/builder/auth_spec.rb | 460 ++++++
spec/openapi/builder/base_spec.rb | 268 ++++
spec/openapi/builder/deprecated_spec.rb | 311 ++++
spec/openapi/builder/description_spec.rb | 68 +
spec/openapi/builder/internal_spec.rb | 71 +
spec/openapi/builder/private_spec.rb | 142 ++
spec/openapi/builder/request_spec.rb | 153 ++
spec/openapi/builder/response_spec.rb | 254 ++++
spec/openapi/builder/summary_spec.rb | 165 +++
spec/openapi/builder/tag_resolver_spec.rb | 101 ++
spec/openapi/builder/title_spec.rb | 54 +
spec/openapi/builder/version_spec.rb | 54 +
spec/openapi/openapi_spec.rb | 102 ++
.../openapi/parsers/ext/active_record_spec.rb | 80 +
spec/openapi/parsers/ext/alba_spec.rb | 1312 +++++++++++++++++
spec/openapi/parsers/request_spec.rb | 36 +
spec/openapi/parsers/response_spec.rb | 36 +
spec/openapi/parsers/shared_reference_spec.rb | 88 ++
spec/openapi/parsers/yaml_spec.rb | 155 ++
spec/spec_helper.rb | 5 +
spec/support/contexts/mocked_classes.rb | 37 +
spec/support/contexts/mocked_rage_routes.rb | 18 +
59 files changed, 5407 insertions(+), 16 deletions(-)
create mode 100644 lib/rage/openapi/builder.rb
create mode 100644 lib/rage/openapi/collector.rb
create mode 100644 lib/rage/openapi/converter.rb
create mode 100644 lib/rage/openapi/index.html.erb
create mode 100644 lib/rage/openapi/nodes/method.rb
create mode 100644 lib/rage/openapi/nodes/parent.rb
create mode 100644 lib/rage/openapi/nodes/root.rb
create mode 100644 lib/rage/openapi/openapi.rb
create mode 100644 lib/rage/openapi/parser.rb
create mode 100644 lib/rage/openapi/parsers/ext/active_record.rb
create mode 100644 lib/rage/openapi/parsers/ext/alba.rb
create mode 100644 lib/rage/openapi/parsers/request.rb
create mode 100644 lib/rage/openapi/parsers/response.rb
create mode 100644 lib/rage/openapi/parsers/shared_reference.rb
create mode 100644 lib/rage/openapi/parsers/yaml.rb
create mode 100644 spec/integration/test_app/app/controllers/api/base_controller.rb
create mode 100644 spec/integration/test_app/app/controllers/api/v1/users_controller.rb
create mode 100644 spec/integration/test_app/app/controllers/api/v2/users_controller.rb
create mode 100644 spec/integration/test_app/app/controllers/api/v3/users_controller.rb
create mode 100644 spec/integration/test_app/app/resources/api/v1/avatar_resource.rb
create mode 100644 spec/integration/test_app/app/resources/api/v1/user_resource.rb
create mode 100644 spec/integration/test_app/app/resources/base_user_resource.rb
create mode 100644 spec/integration/test_app/app/resources/comment_resource.rb
create mode 100644 spec/integration/test_app/app/resources/user_resource.rb
create mode 100644 spec/integration/test_app/config/initializers/alba.rb
create mode 100644 spec/integration/test_app/config/openapi_components.yml
create mode 100644 spec/openapi/builder/auth_spec.rb
create mode 100644 spec/openapi/builder/base_spec.rb
create mode 100644 spec/openapi/builder/deprecated_spec.rb
create mode 100644 spec/openapi/builder/description_spec.rb
create mode 100644 spec/openapi/builder/internal_spec.rb
create mode 100644 spec/openapi/builder/private_spec.rb
create mode 100644 spec/openapi/builder/request_spec.rb
create mode 100644 spec/openapi/builder/response_spec.rb
create mode 100644 spec/openapi/builder/summary_spec.rb
create mode 100644 spec/openapi/builder/tag_resolver_spec.rb
create mode 100644 spec/openapi/builder/title_spec.rb
create mode 100644 spec/openapi/builder/version_spec.rb
create mode 100644 spec/openapi/openapi_spec.rb
create mode 100644 spec/openapi/parsers/ext/active_record_spec.rb
create mode 100644 spec/openapi/parsers/ext/alba_spec.rb
create mode 100644 spec/openapi/parsers/request_spec.rb
create mode 100644 spec/openapi/parsers/response_spec.rb
create mode 100644 spec/openapi/parsers/shared_reference_spec.rb
create mode 100644 spec/openapi/parsers/yaml_spec.rb
create mode 100644 spec/support/contexts/mocked_classes.rb
create mode 100644 spec/support/contexts/mocked_rage_routes.rb
diff --git a/.rubocop.yml b/.rubocop.yml
index 76ac30e1..f4a45e2d 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -913,7 +913,7 @@ Style/RedundantFreeze:
Enabled: true
Style/RedundantHeredocDelimiterQuotes:
- Enabled: true
+ Enabled: false
Style/RedundantInterpolation:
Enabled: true
diff --git a/Gemfile b/Gemfile
index 35cc89c2..7949eab9 100644
--- a/Gemfile
+++ b/Gemfile
@@ -19,4 +19,5 @@ group :test do
gem "rbnacl"
gem "domain_name"
gem "websocket-client-simple"
+ gem "prism"
end
diff --git a/lib/rage-rb.rb b/lib/rage-rb.rb
index 825104e4..13f86aba 100644
--- a/lib/rage-rb.rb
+++ b/lib/rage-rb.rb
@@ -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
@@ -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
diff --git a/lib/rage/code_loader.rb b/lib/rage/code_loader.rb
index c89a5127..1d7104fe 100644
--- a/lib/rage/code_loader.rb
+++ b/lib/rage/code_loader.rb
@@ -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
@@ -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?
diff --git a/lib/rage/configuration.rb b/lib/rage/configuration.rb
index c6f6b09c..e24e600b 100644
--- a/lib/rage/configuration.rb
+++ b/lib/rage/configuration.rb
@@ -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.
@@ -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
@@ -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)
@@ -264,6 +283,10 @@ class PublicFileServer
attr_accessor :enabled
end
+ class OpenAPI
+ attr_accessor :tag_resolver
+ end
+
# @private
class Internal
attr_accessor :rails_mode
diff --git a/lib/rage/controller/api.rb b/lib/rage/controller/api.rb
index 31385793..f9f70142 100644
--- a/lib/rage/controller/api.rb
+++ b/lib/rage/controller/api.rb
@@ -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]
@@ -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]
@@ -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`
diff --git a/lib/rage/openapi/builder.rb b/lib/rage/openapi/builder.rb
new file mode 100644
index 00000000..6f21337a
--- /dev/null
+++ b/lib/rage/openapi/builder.rb
@@ -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
diff --git a/lib/rage/openapi/collector.rb b/lib/rage/openapi/collector.rb
new file mode 100644
index 00000000..713a65cf
--- /dev/null
+++ b/lib/rage/openapi/collector.rb
@@ -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
diff --git a/lib/rage/openapi/converter.rb b/lib/rage/openapi/converter.rb
new file mode 100644
index 00000000..c72d480f
--- /dev/null
+++ b/lib/rage/openapi/converter.rb
@@ -0,0 +1,138 @@
+# frozen_string_literal: true
+
+class Rage::OpenAPI::Converter
+ def initialize(nodes)
+ @nodes = nodes
+ @used_tags = Set.new
+ @used_security_schemes = Set.new
+
+ @spec = {
+ "openapi" => "3.0.0",
+ "info" => {},
+ "components" => {},
+ "tags" => [],
+ "paths" => {}
+ }
+ end
+
+ def run
+ @spec["info"] = {
+ "version" => @nodes.version || "1.0.0",
+ "title" => @nodes.title || build_app_name
+ }
+
+ @spec["paths"] = @nodes.leaves.each_with_object({}) do |node, memo|
+ next if node.private || node.parents.any?(&:private)
+
+ path_parameters = []
+ path = node.http_path.gsub(/:(\w+)/) do
+ path_parameters << $1
+ "{#{$1}}"
+ end
+
+ unless memo.key?(path)
+ memo[path] = {}
+ path_parameters.each do |parameter|
+ (memo[path]["parameters"] ||= []) << {
+ "in" => "path",
+ "name" => parameter,
+ "required" => true,
+ "schema" => { "type" => parameter.end_with?("id") ? "integer" : "string" }
+ }
+ end
+ end
+
+ method = node.http_method.downcase
+ memo[path][method] = {
+ "summary" => node.summary || "",
+ "description" => node.description&.join(" ") || "",
+ "deprecated" => !!(node.deprecated || node.parents.any?(&:deprecated)),
+ "security" => build_security(node),
+ "tags" => build_tags(node)
+ }
+
+ memo[path][method]["responses"] = if node.responses.any?
+ node.responses.each_with_object({}) do |(status, response), memo|
+ memo[status] = if response.nil?
+ { "description" => "" }
+ elsif response.key?("$ref") && response["$ref"].start_with?("#/components/responses")
+ response
+ else
+ { "description" => "", "content" => { "application/json" => { "schema" => response } } }
+ end
+ end
+ else
+ { "200" => { "description" => "" } }
+ end
+
+ if node.request
+ if node.request.key?("$ref") && node.request["$ref"].start_with?("#/components/requestBodies")
+ memo[path][method]["requestBody"] = node.request
+ else
+ memo[path][method]["requestBody"] = { "content" => { "application/json" => { "schema" => node.request } } }
+ end
+ end
+ end
+
+ if @used_security_schemes.any?
+ @spec["components"]["securitySchemes"] = @used_security_schemes.each_with_object({}) do |auth_entry, memo|
+ memo[auth_entry[:name]] = auth_entry[:definition]
+ end
+ end
+
+ if (shared_components = Rage::OpenAPI.__shared_components["components"])
+ shared_components.each do |definition_type, definitions|
+ (@spec["components"][definition_type] ||= {}).merge!(definitions)
+ end
+ end
+
+ @spec["tags"] = @used_tags.sort.map { |tag| { "name" => tag } }
+
+ @spec
+ end
+
+ private
+
+ def build_app_name
+ basename = Rage.root.basename.to_s
+ basename.capitalize.gsub(/[\s\-_]([a-zA-Z0-9]+)/) { " #{$1.capitalize}" }
+ end
+
+ def build_security(node)
+ available_before_actions = node.controller.__before_actions_for(node.action.to_sym)
+
+ node.auth.filter_map do |auth_entry|
+ if available_before_actions.any? { |action_entry| action_entry[:name] == auth_entry[:method].to_sym }
+ auth_name = auth_entry[:name].gsub(/[^A-Za-z0-9\-._]/, "")
+ @used_security_schemes << auth_entry.merge(name: auth_name)
+
+ { auth_name => [] }
+ end
+ end
+ end
+
+ def build_tags(node)
+ controller_name = node.controller.name.sub(/Controller$/, "")
+ namespace_i = controller_name.rindex("::")
+
+ if namespace_i
+ module_name, class_name = controller_name[0...namespace_i], controller_name[namespace_i + 2..]
+ else
+ module_name, class_name = "", controller_name
+ end
+
+ tag = if module_name =~ /::(V\d+)/
+ "#{$1.downcase}/#{class_name}"
+ else
+ class_name
+ end
+
+ if (custom_tag_resolver = Rage.config.openapi.tag_resolver)
+ tag = custom_tag_resolver.call(node.controller, node.action.to_sym, tag)
+ end
+
+ Array(tag).tap do |node_tags|
+ @used_tags += node_tags
+ end
+ end
+end
diff --git a/lib/rage/openapi/index.html.erb b/lib/rage/openapi/index.html.erb
new file mode 100644
index 00000000..13b8c4b6
--- /dev/null
+++ b/lib/rage/openapi/index.html.erb
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+ SwaggerUI
+
+
+
+
+
+
+
+
diff --git a/lib/rage/openapi/nodes/method.rb b/lib/rage/openapi/nodes/method.rb
new file mode 100644
index 00000000..3af7aa32
--- /dev/null
+++ b/lib/rage/openapi/nodes/method.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class Rage::OpenAPI::Nodes::Method
+ attr_reader :controller, :action, :parents
+ attr_accessor :http_method, :http_path, :summary, :tag, :deprecated, :private, :description,
+ :request, :responses, :parameters
+
+ def initialize(controller, action, parents)
+ @controller = controller
+ @action = action
+ @parents = parents
+
+ @responses = {}
+ @parameters = []
+ end
+
+ def root
+ @parents[0].root
+ end
+
+ def auth
+ @parents.flat_map(&:auth)
+ end
+end
diff --git a/lib/rage/openapi/nodes/parent.rb b/lib/rage/openapi/nodes/parent.rb
new file mode 100644
index 00000000..53909fef
--- /dev/null
+++ b/lib/rage/openapi/nodes/parent.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class Rage::OpenAPI::Nodes::Parent
+ attr_reader :root, :controller
+ attr_accessor :deprecated, :private, :auth
+
+ def initialize(root, controller)
+ @root = root
+ @controller = controller
+
+ @auth = []
+ end
+end
diff --git a/lib/rage/openapi/nodes/root.rb b/lib/rage/openapi/nodes/root.rb
new file mode 100644
index 00000000..f6961636
--- /dev/null
+++ b/lib/rage/openapi/nodes/root.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+##
+# Represents a tree of method nodes. The tree consists of:
+#
+# * a root node;
+# * method nodes, each of which represents an action in a controller;
+# * parent nodes attached to one or several method nodes;
+#
+# A method node together with its parent nodes represent a complete inheritance chain.
+#
+# Nodes::Root
+# |
+# Nodes::Parent
+# |
+# Nodes::Parent
+# / \
+# Nodes::Parent Nodes::Parent
+# / \ |
+# Nodes::Method Nodes::Method Nodes::Method
+#
+class Rage::OpenAPI::Nodes::Root
+ attr_reader :leaves
+ attr_accessor :version, :title
+
+ def initialize
+ @parent_nodes_cache = {}
+ @leaves = []
+ end
+
+ def parent_nodes
+ @parent_nodes_cache.values
+ end
+
+ def new_method_node(controller, action, parent_nodes)
+ node = Rage::OpenAPI::Nodes::Method.new(controller, action, parent_nodes)
+ @leaves << node
+
+ node
+ end
+
+ def new_parent_node(controller)
+ @parent_nodes_cache[controller] ||= begin
+ node = Rage::OpenAPI::Nodes::Parent.new(self, controller)
+ yield(node)
+ node
+ end
+ end
+end
diff --git a/lib/rage/openapi/openapi.rb b/lib/rage/openapi/openapi.rb
new file mode 100644
index 00000000..353ddbdf
--- /dev/null
+++ b/lib/rage/openapi/openapi.rb
@@ -0,0 +1,146 @@
+# frozen_string_literal: true
+
+require "erb"
+require "yaml"
+
+if !defined?(Prism)
+ fail <<~ERR
+
+ rage-rb depends on Prism to build OpenAPI specifications. Add the following line to your Gemfile:
+ gem "prism"
+
+ ERR
+end
+
+module Rage::OpenAPI
+ # Create a new OpenAPI application.
+ #
+ # @param namespace [String, Module] limit the parser to a specific namespace
+ # @example
+ # map "/publicapi" do
+ # run Rage.openapi.application
+ # end
+ # @example
+ # map "/publicapi/v1" do
+ # run Rage.openapi.application(namespace: "Api::V1")
+ # end
+ #
+ # map "/publicapi/v2" do
+ # run Rage.openapi.application(namespace: "Api::V2")
+ # end
+ def self.application(namespace: nil)
+ html_app = ->(env) do
+ __data_cache[[:page, namespace]] ||= begin
+ scheme, host, path = env["rack.url_scheme"], env["HTTP_HOST"], env["SCRIPT_NAME"]
+ spec_url = "#{scheme}://#{host}#{path}/json"
+ page = ERB.new(File.read("#{__dir__}/index.html.erb")).result(binding)
+
+ [200, { "Content-Type" => "text/html; charset=UTF-8" }, [page]]
+ end
+ end
+
+ json_app = ->(env) do
+ spec = (__data_cache[[:spec, namespace]] ||= build(namespace:).to_json)
+ [200, { "Content-Type" => "application/json" }, [spec]]
+ end
+
+ app = ->(env) do
+ if env["PATH_INFO"] == ""
+ html_app.call(env)
+ elsif env["PATH_INFO"] == "/json"
+ json_app.call(env)
+ else
+ [404, {}, ["Not Found"]]
+ end
+ end
+
+ if Rage.config.middleware.include?(Rage::Reloader)
+ Rage.with_middlewares(app, [Rage::Reloader])
+ elsif defined?(ActionDispatch::Reloader) && Rage.config.middleware.include?(ActionDispatch::Reloader)
+ Rage.with_middlewares(app, [ActionDispatch::Reloader])
+ else
+ app
+ end
+ end
+
+ # Build an OpenAPI specification for the application.
+ # @param namespace [String, Module] limit the parser to a specific namespace
+ # @return [Hash]
+ def self.build(namespace: nil)
+ Builder.new(namespace:).run
+ end
+
+ # @private
+ def self.__shared_components
+ __data_cache[:shared_components] ||= begin
+ components_file = Rage.root.join("config").glob("openapi_components.*")[0]
+
+ if components_file.nil?
+ {}
+ else
+ case components_file.extname
+ when ".yml", ".yaml"
+ YAML.safe_load(components_file.read)
+ when ".json"
+ JSON.parse(components_file.read)
+ else
+ Rage::OpenAPI.__log_warn "unrecognized file extension: #{components_file.relative_path_from(Rage.root)}; expected either .yml or .json"
+ {}
+ end
+ end
+ end
+ end
+
+ # @private
+ def self.__data_cache
+ @__data_cache ||= {}
+ end
+
+ # @private
+ def self.__reset_data_cache
+ __data_cache.clear
+ end
+
+ # @private
+ def self.__try_parse_collection(str)
+ if str =~ /^Array<([\w\s:\(\)]+)>$/ || str =~ /^\[([\w\s:\(\)]+)\]$/
+ [true, $1]
+ else
+ [false, str]
+ end
+ end
+
+ # @private
+ def self.__module_parent(klass)
+ klass.name =~ /::[^:]+\z/ ? Object.const_get($`) : Object
+ rescue NameError
+ Object
+ end
+
+ # @private
+ def self.__log_warn(log)
+ puts "WARNING: #{log}"
+ end
+
+ module Nodes
+ end
+
+ module Parsers
+ module Ext
+ end
+ end
+end
+
+require_relative "builder"
+require_relative "collector"
+require_relative "parser"
+require_relative "converter"
+require_relative "nodes/root"
+require_relative "nodes/parent"
+require_relative "nodes/method"
+require_relative "parsers/ext/alba"
+require_relative "parsers/ext/active_record"
+require_relative "parsers/yaml"
+require_relative "parsers/shared_reference"
+require_relative "parsers/request"
+require_relative "parsers/response"
diff --git a/lib/rage/openapi/parser.rb b/lib/rage/openapi/parser.rb
new file mode 100644
index 00000000..1e915bc5
--- /dev/null
+++ b/lib/rage/openapi/parser.rb
@@ -0,0 +1,193 @@
+# frozen_string_literal: true
+
+class Rage::OpenAPI::Parser
+ def parse_dangling_comments(node, comments)
+ i = 0
+
+ while i < comments.length
+ children = nil
+ expression = comments[i].slice.delete_prefix("#").strip
+
+ if expression =~ /@deprecated\b/
+ if node.deprecated
+ Rage::OpenAPI.__log_warn "duplicate @deprecated tag detected at #{location_msg(comments[i])}"
+ else
+ node.deprecated = true
+ end
+ children = find_children(comments[i + 1..])
+
+ elsif expression =~ /@private\b/
+ if node.private
+ Rage::OpenAPI.__log_warn "duplicate @private tag detected at #{location_msg(comments[i])}"
+ else
+ node.private = true
+ end
+ children = find_children(comments[i + 1..])
+
+ elsif expression =~ /@version\s/
+ if node.root.version
+ Rage::OpenAPI.__log_warn "duplicate @version tag detected at #{location_msg(comments[i])}"
+ else
+ node.root.version = expression[9..]
+ end
+
+ elsif expression =~ /@title\s/
+ if node.root.title
+ Rage::OpenAPI.__log_warn "duplicate @title tag detected at #{location_msg(comments[i])}"
+ else
+ node.root.title = expression[7..]
+ end
+
+ elsif expression =~ /@auth\s/
+ method, name, tail_name = expression[6..].split(" ", 3)
+ children = find_children(comments[i + 1..])
+
+ if tail_name
+ Rage::OpenAPI.__log_warn "incorrect `@auth` name detected at #{location_msg(comments[i])}; security scheme name cannot contain spaces"
+ end
+
+ auth_entry = {
+ method:,
+ name: name || method,
+ definition: children.any? ? YAML.safe_load(children.join("\n")) : { "type" => "http", "scheme" => "bearer" }
+ }
+
+ if !node.controller.__before_action_exists?(method.to_sym)
+ Rage::OpenAPI.__log_warn "referenced before action `#{method}` is not defined in #{node.controller} at #{location_msg(comments[i])}; ensure a corresponding `before_action` call exists"
+ elsif node.auth.include?(auth_entry) || node.root.parent_nodes.any? { |parent_node| parent_node.auth.include?(auth_entry) }
+ Rage::OpenAPI.__log_warn "duplicate @auth tag detected at #{location_msg(comments[i])}"
+ else
+ node.auth << auth_entry
+ end
+ end
+
+ if children&.any?
+ i += children.length + 1
+ else
+ i += 1
+ end
+ end
+ end
+
+ def parse_method_comments(node, comments)
+ i = 0
+
+ while i < comments.length
+ children = nil
+ expression = comments[i].slice.delete_prefix("#").strip
+
+ if !expression.start_with?("@")
+ if node.summary
+ Rage::OpenAPI.__log_warn "invalid summary entry detected at #{location_msg(comments[i])}; summary should only be one line"
+ else
+ node.summary = expression
+ end
+
+ elsif expression =~ /@deprecated\b/
+ if node.parents.any?(&:deprecated)
+ Rage::OpenAPI.__log_warn "duplicate `@deprecated` tag detected at #{location_msg(comments[i])}; tag already exists in a parent class"
+ else
+ node.deprecated = true
+ end
+ children = find_children(comments[i + 1..])
+
+ elsif expression =~ /@private\b/
+ if node.parents.any?(&:private)
+ Rage::OpenAPI.__log_warn "duplicate `@private` tag detected at #{location_msg(comments[i])}; tag already exists in a parent class"
+ else
+ node.private = true
+ end
+ children = find_children(comments[i + 1..])
+
+ elsif expression =~ /@description\s/
+ children = find_children(comments[i + 1..])
+ node.description = [expression[13..]] + children
+
+ elsif expression =~ /@response\s/
+ response = expression[10..].strip
+ status, response_data = if response =~ /^\d{3}$/
+ [response, nil]
+ elsif response =~ /^\d{3}/
+ response.split(" ", 2)
+ else
+ ["200", response]
+ end
+
+ if node.responses.has_key?(status)
+ Rage::OpenAPI.__log_warn "duplicate `@response` tag detected at #{location_msg(comments[i])}"
+ elsif response_data.nil?
+ node.responses[status] = nil
+ else
+ parsed = Rage::OpenAPI::Parsers::Response.parse(
+ response_data,
+ namespace: Rage::OpenAPI.__module_parent(node.controller)
+ )
+
+ if parsed
+ node.responses[status] = parsed
+ else
+ Rage::OpenAPI.__log_warn "unrecognized `@response` tag detected at #{location_msg(comments[i])}"
+ end
+ end
+
+ elsif expression =~ /@request\s/
+ request = expression[9..]
+ if node.request
+ Rage::OpenAPI.__log_warn "duplicate `@request` tag detected at #{location_msg(comments[i])}"
+ else
+ parsed = Rage::OpenAPI::Parsers::Request.parse(
+ request,
+ namespace: Rage::OpenAPI.__module_parent(node.controller)
+ )
+
+ if parsed
+ node.request = parsed
+ else
+ Rage::OpenAPI.__log_warn "unrecognized `@request` tag detected at #{location_msg(comments[i])}"
+ end
+ end
+
+ elsif expression =~ /@internal\b/
+ # no-op
+ children = find_children(comments[i + 1..])
+
+ else
+ Rage::OpenAPI.__log_warn "unrecognized `#{expression.split(" ")[0]}` tag detected at #{location_msg(comments[i])}"
+ end
+
+ if children&.any?
+ i += children.length + 1
+ else
+ i += 1
+ end
+ end
+ end
+
+ private
+
+ def find_children(comments)
+ children = []
+
+ comments.each do |comment|
+ expression = comment.slice.sub(/^#\s/, "")
+
+ if expression.start_with?(/\s{2}/)
+ children << expression.strip
+ elsif expression.start_with?("@")
+ break
+ else
+ Rage::OpenAPI.__log_warn "unrecognized expression detected at #{location_msg(comment)}; use two spaces to mark multi-line expressions"
+ break
+ end
+ end
+
+ children
+ end
+
+ def location_msg(comment)
+ location = comment.location
+ relative_path = Pathname.new(location.__source_path).relative_path_from(Rage.root)
+
+ "#{relative_path}:#{location.start_line}"
+ end
+end
diff --git a/lib/rage/openapi/parsers/ext/active_record.rb b/lib/rage/openapi/parsers/ext/active_record.rb
new file mode 100644
index 00000000..d664448d
--- /dev/null
+++ b/lib/rage/openapi/parsers/ext/active_record.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+class Rage::OpenAPI::Parsers::Ext::ActiveRecord
+ BLACKLISTED_ATTRIBUTES = %w(id created_at updated_at)
+
+ def initialize(namespace: Object, **)
+ @namespace = namespace
+ end
+
+ def known_definition?(str)
+ _, str = Rage::OpenAPI.__try_parse_collection(str)
+ defined?(ActiveRecord::Base) && @namespace.const_get(str).ancestors.include?(ActiveRecord::Base)
+ rescue NameError
+ false
+ end
+
+ # TODO: collection
+ def parse(klass_str)
+ is_collection, klass_str = Rage::OpenAPI.__try_parse_collection(klass_str)
+ klass = @namespace.const_get(klass_str)
+
+ schema = {}
+
+ klass.attribute_types.each do |attr_name, attr_type|
+ next if BLACKLISTED_ATTRIBUTES.include?(attr_name) ||
+ attr_name.end_with?("_id") ||
+ attr_name == klass.inheritance_column ||
+ klass.defined_enums.include?(attr_name)
+
+ schema[attr_name] = case attr_type.type
+ when :integer
+ { "type" => "integer" }
+ when :boolean
+ { "type" => "boolean" }
+ when :binary
+ { "type" => "string", "format" => "binary" }
+ when :date
+ { "type" => "string", "format" => "date" }
+ when :datetime, :time
+ { "type" => "string", "format" => "date-time" }
+ when :float
+ { "type" => "number", "format" => "float" }
+ when :decimal
+ { "type" => "number" }
+ when :json
+ { "type" => "object" }
+ else
+ { "type" => "string" }
+ end
+ end
+
+ klass.defined_enums.each do |attr_name, mapping|
+ schema[attr_name] = { "type" => "string", "enum" => mapping.keys }
+ end
+
+ result = { "type" => "object" }
+ result["properties"] = schema if schema.any?
+
+ result = { "type" => "array", "items" => result } if is_collection
+
+ result
+ end
+end
diff --git a/lib/rage/openapi/parsers/ext/alba.rb b/lib/rage/openapi/parsers/ext/alba.rb
new file mode 100644
index 00000000..1f0129be
--- /dev/null
+++ b/lib/rage/openapi/parsers/ext/alba.rb
@@ -0,0 +1,281 @@
+# frozen_string_literal: true
+
+class Rage::OpenAPI::Parsers::Ext::Alba
+ attr_reader :namespace
+
+ def initialize(namespace: Object, **)
+ @namespace = namespace
+ end
+
+ def known_definition?(str)
+ _, str = Rage::OpenAPI.__try_parse_collection(str)
+ defined?(Alba::Resource) && @namespace.const_get(str).ancestors.include?(Alba::Resource)
+ rescue NameError
+ false
+ end
+
+ def parse(klass_str)
+ __parse(klass_str).build_schema
+ end
+
+ def __parse_nested(klass_str)
+ __parse(klass_str).tap { |visitor|
+ visitor.root_key = visitor.root_key_for_collection = visitor.key_transformer = nil
+ }.build_schema
+ end
+
+ def __parse(klass_str)
+ is_collection, klass_str = Rage::OpenAPI.__try_parse_collection(klass_str)
+
+ klass = @namespace.const_get(klass_str)
+ source_path, _ = Object.const_source_location(klass.name)
+ ast = Prism.parse_file(source_path)
+
+ visitor = Visitor.new(self, is_collection)
+ ast.value.accept(visitor)
+
+ visitor
+ end
+
+ class VisitorContext
+ attr_accessor :symbols, :hashes, :keywords, :consts, :nil
+
+ def initialize
+ @symbols = []
+ @hashes = []
+ @keywords = {}
+ @consts = []
+ @nil = false
+ end
+ end
+
+ class Visitor < Prism::Visitor
+ attr_accessor :schema, :root_key, :root_key_for_collection, :key_transformer, :collection_key, :meta
+
+ def initialize(parser, is_collection)
+ @parser = parser
+ @is_collection = is_collection
+
+ @schema = {}
+ @segment = @schema
+ @context = nil
+ @prev_contexts = []
+
+ @self_name = nil
+ @root_key = nil
+ @root_key_for_collection = nil
+ @key_transformer = nil
+ @collection_key = false
+ @meta = {}
+ end
+
+ def visit_class_node(node)
+ @self_name ||= node.name.to_s
+
+ if node.name =~ /Resource$|Serializer$/ && node.superclass
+ visitor = @parser.__parse(node.superclass.name)
+ @root_key, @root_key_for_collection = visitor.root_key, visitor.root_key_for_collection
+ @key_transformer, @collection_key, @meta = visitor.key_transformer, visitor.collection_key, visitor.meta
+ @schema.merge!(visitor.schema)
+ end
+
+ super
+ end
+
+ def build_schema
+ result = { "type" => "object" }
+
+ result["properties"] = @schema if @schema.any?
+
+ if @is_collection
+ result = if @collection_key && @root_key_for_collection
+ { "type" => "object", "properties" => { @root_key_for_collection => { "type" => "object", "additionalProperties" => result }, **@meta } }
+ elsif @collection_key
+ { "type" => "object", "additionalProperties" => result }
+ elsif @root_key_for_collection
+ { "type" => "object", "properties" => { @root_key_for_collection => { "type" => "array", "items" => result }, **@meta } }
+ else
+ { "type" => "array", "items" => result }
+ end
+ elsif @root_key
+ result = { "type" => "object", "properties" => { @root_key => result, **@meta } }
+ end
+
+ result = deep_transform_keys(result) if @key_transformer
+
+ result
+ end
+
+ def visit_call_node(node)
+ case node.name
+ when :root_key
+ context = with_context { visit(node.arguments) }
+ @root_key, @root_key_for_collection = context.symbols
+
+ when :attributes, :attribute
+ context = with_context { visit(node.arguments) }
+ context.symbols.each { |symbol| @segment[symbol] = { "type" => "string" } }
+ context.keywords.except("if").each { |key, type| @segment[key] = get_type_definition(type) }
+
+ when :nested, :nested_attribute
+ context = with_context { visit(node.arguments) }
+ with_inner_segment(context.symbols[0]) { visit(node.block) }
+
+ when :meta
+ context = with_context do
+ visit(node.arguments)
+ visit(node.block)
+ end
+
+ key = context.symbols[0] || "meta"
+ unless context.nil
+ @meta = { key => hash_to_openapi_schema(context.hashes[0]) }
+ end
+
+ when :many, :has_many, :one, :has_one, :association
+ is_array = node.name == :many || node.name == :has_many
+ context = with_context { visit(node.arguments) }
+ key = context.keywords["key"] || context.symbols[0]
+
+ if node.block
+ with_inner_segment(key, is_array:) { visit(node.block) }
+ else
+ resource = context.keywords["resource"] || (::Alba.inflector && "#{::Alba.inflector.classify(key.to_s)}Resource")
+ is_valid_resource = @parser.namespace.const_get(resource) rescue false
+
+ @segment[key] = if is_array
+ @parser.__parse_nested(is_valid_resource ? "[#{resource}]" : "[Rage]") # TODO
+ else
+ @parser.__parse_nested(is_valid_resource ? resource : "Rage")
+ end
+ end
+
+ when :transform_keys
+ context = with_context { visit(node.arguments) }
+ @key_transformer = get_key_transformer(context.symbols[0])
+
+ when :collection_key
+ @collection_key = true
+
+ when :root_key!
+ if (inflector = ::Alba.inflector)
+ suffix = @self_name.end_with?("Resource") ? "Resource" : "Serializer"
+ name = inflector.demodulize(@self_name).delete_suffix(suffix)
+ @root_key = inflector.underscore(name)
+ @root_key_for_collection = inflector.pluralize(@root_key) if @is_collection
+ end
+ end
+ end
+
+ def visit_hash_node(node)
+ parsed_hash = YAML.safe_load(node.slice) rescue nil
+ @context.hashes << parsed_hash if parsed_hash
+ end
+
+ def visit_assoc_node(node)
+ value = case node.value
+ when Prism::StringNode
+ node.value.content
+ when Prism::ArrayNode
+ context = with_context { visit(node.value) }
+ context.symbols[0] || context.consts[0]
+ else
+ node.value.slice
+ end
+
+ @context.keywords[node.key.value] = value
+ end
+
+ def visit_constant_read_node(node)
+ return unless @context
+ @context.consts << node.name.to_s
+ end
+
+ def visit_symbol_node(node)
+ @context.symbols << node.value
+ end
+
+ def visit_nil_node(node)
+ @context.nil = true
+ end
+
+ private
+
+ def with_inner_segment(key, is_array: false)
+ prev_segment = @segment
+
+ properties = {}
+ if is_array
+ @segment[key] = { "type" => "array", "items" => { "type" => "object", "properties" => properties } }
+ else
+ @segment[key] = { "type" => "object", "properties" => properties }
+ end
+ @segment = properties
+
+ yield
+ @segment = prev_segment
+ end
+
+ def with_context
+ @prev_contexts << @context if @context
+ @context = VisitorContext.new
+ yield
+ current_context = @context
+ @context = @prev_contexts.pop
+ current_context
+ end
+
+ def hash_to_openapi_schema(hash)
+ return { "type" => "object" } unless hash
+
+ schema = hash.each_with_object({}) do |(key, value), memo|
+ memo[key.to_s] = if value.is_a?(Hash)
+ hash_to_openapi_schema(value)
+ elsif value.is_a?(Array)
+ { "type" => "array", "items" => { "type" => "string" } }
+ else
+ { "type" => "string" }
+ end
+ end
+
+ { "type" => "object", "properties" => schema }
+ end
+
+ def deep_transform_keys(schema)
+ schema.each_with_object({}) do |(key, value), memo|
+ transformed_key = %w(type properties items additionalProperties).include?(key) ? key : @key_transformer.call(key)
+ memo[transformed_key] = value.is_a?(Hash) ? deep_transform_keys(value) : value
+ end
+ end
+
+ def get_key_transformer(transformer_id)
+ return nil unless ::Alba.inflector
+
+ case transformer_id
+ when "camel"
+ ->(key) { ::Alba.inflector.camelize(key) }
+ when "lower_camel"
+ ->(key) { ::Alba.inflector.camelize_lower(key) }
+ when "dash"
+ ->(key) { ::Alba.inflector.dasherize(key) }
+ when "snake"
+ ->(key) { ::Alba.inflector.underscore(key) }
+ end
+ end
+
+ def get_type_definition(type_id)
+ case type_id
+ when "Integer"
+ { "type" => "integer" }
+ when "Boolean", ":Boolean"
+ { "type" => "boolean" }
+ when "Numeric"
+ { "type" => "number" }
+ when "Float"
+ { "type" => "number", "format" => "float" }
+ else
+ { "type" => "string" }
+ end
+ end
+ end
+end
diff --git a/lib/rage/openapi/parsers/request.rb b/lib/rage/openapi/parsers/request.rb
new file mode 100644
index 00000000..d8e36eb4
--- /dev/null
+++ b/lib/rage/openapi/parsers/request.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class Rage::OpenAPI::Parsers::Request
+ AVAILABLE_PARSERS = [
+ Rage::OpenAPI::Parsers::SharedReference,
+ Rage::OpenAPI::Parsers::YAML,
+ Rage::OpenAPI::Parsers::Ext::ActiveRecord
+ ]
+
+ def self.parse(request_tag, namespace:)
+ parser = AVAILABLE_PARSERS.find do |parser_class|
+ parser = parser_class.new(namespace:)
+ break parser if parser.known_definition?(request_tag)
+ end
+
+ parser.parse(request_tag) if parser
+ end
+end
diff --git a/lib/rage/openapi/parsers/response.rb b/lib/rage/openapi/parsers/response.rb
new file mode 100644
index 00000000..962f7490
--- /dev/null
+++ b/lib/rage/openapi/parsers/response.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Rage::OpenAPI::Parsers::Response
+ AVAILABLE_PARSERS = [
+ Rage::OpenAPI::Parsers::SharedReference,
+ Rage::OpenAPI::Parsers::Ext::ActiveRecord,
+ Rage::OpenAPI::Parsers::Ext::Alba,
+ Rage::OpenAPI::Parsers::YAML
+ ]
+
+ def self.parse(response_tag, namespace:)
+ parser = AVAILABLE_PARSERS.find do |parser_class|
+ parser = parser_class.new(namespace:)
+ break parser if parser.known_definition?(response_tag)
+ end
+
+ parser.parse(response_tag) if parser
+ end
+end
diff --git a/lib/rage/openapi/parsers/shared_reference.rb b/lib/rage/openapi/parsers/shared_reference.rb
new file mode 100644
index 00000000..593676a9
--- /dev/null
+++ b/lib/rage/openapi/parsers/shared_reference.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class Rage::OpenAPI::Parsers::SharedReference
+ def initialize(**)
+ end
+
+ def known_definition?(str)
+ str.start_with?("#/components")
+ end
+
+ def parse(component_path)
+ { "$ref" => component_path } if valid_components_ref?(component_path)
+ end
+
+ private
+
+ def valid_components_ref?(component_path)
+ # TODO ''
+ shared_components = Rage::OpenAPI.__shared_components
+ return false if shared_components.empty?
+
+ !!component_path[2..].split("/").reduce(shared_components) do |components, component_key|
+ components[component_key] if components
+ end
+ end
+end
diff --git a/lib/rage/openapi/parsers/yaml.rb b/lib/rage/openapi/parsers/yaml.rb
new file mode 100644
index 00000000..229269f1
--- /dev/null
+++ b/lib/rage/openapi/parsers/yaml.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+class Rage::OpenAPI::Parsers::YAML
+ def initialize(**)
+ end
+
+ def known_definition?(yaml)
+ object = YAML.safe_load(yaml) rescue nil
+ !!object && object.is_a?(Enumerable)
+ end
+
+ def parse(yaml)
+ object = yaml.is_a?(Enumerable) ? yaml : YAML.safe_load(yaml)
+ spec = {}
+
+ if object.is_a?(Hash)
+ spec = { "type" => "object", "properties" => {} }
+
+ object.each do |key, value|
+ spec["properties"][key] = if value.is_a?(Enumerable)
+ parse(value)
+ else
+ type_to_spec(value)
+ end
+ end
+
+ elsif object.is_a?(Array) && object.length == 1
+ spec = { "type" => "array", "items" => object[0].is_a?(Enumerable) ? parse(object[0]) : type_to_spec(object[0]) }
+
+ elsif object.is_a?(Array)
+ spec = { "type" => "string", "enum" => object }
+ end
+
+ spec
+ end
+
+ private
+
+ def type_to_spec(type)
+ case type
+ when Integer, "Integer"
+ { "type" => "integer" }
+ when Float, "Float"
+ { "type" => "number", "format" => "float" }
+ when Numeric, "Numeric"
+ { "type" => "number" }
+ when TrueClass, FalseClass, "Boolean"
+ { "type" => "boolean" }
+ when Hash, "Hash"
+ { "type" => "object" }
+ else
+ { "type" => "string" }
+ end
+ end
+end
diff --git a/lib/rage/router/backend.rb b/lib/rage/router/backend.rb
index b05fc68e..88fa2c17 100644
--- a/lib/rage/router/backend.rb
+++ b/lib/rage/router/backend.rb
@@ -74,6 +74,7 @@ def on(method, path, handler, constraints: {}, defaults: nil)
meta[:controller] = $1
meta[:action] = $2
+ meta[:controller_class] = controller
handler = eval("->(env, params) { #{controller}.new(env, params).#{run_action_method_name} }")
else
diff --git a/spec/integration/integration_spec.rb b/spec/integration/integration_spec.rb
index 8e4f3ade..2f21aea8 100644
--- a/spec/integration/integration_spec.rb
+++ b/spec/integration/integration_spec.rb
@@ -337,4 +337,21 @@
end
end
end
+
+ context "with API docs" do
+ it "correctly renders the OpenAPI specification" do
+ response = HTTP.get("http://localhost:3000/publicapi/json")
+ spec = response.parse
+
+ expect(spec["info"]).to match({ "version" => "2.0.0", "title" => "My Test API" })
+ expect(spec["components"]).to match({ "securitySchemes" => { "authenticate_user" => { "type" => "http", "scheme" => "bearer" } }, "schemas" => { "V3_User" => { "type" => "object", "properties" => { "uuid" => { "type" => "string" }, "is_admin" => { "type" => "boolean" } } } }, "responses" => { "404NotFound" => { "description" => "The specified resource was not found.", "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "code" => { "type" => "string" }, "message" => { "type" => "string" } } } } } } } })
+ expect(spec["tags"]).to include({ "name" => "v1/Users" }, { "name" => "v2/Users" }, { "name" => "v3/Users" })
+
+ expect(spec["paths"]["/api/v1/users"]).to match({ "get" => { "summary" => "Returns the list of all users.", "description" => "Test description for the method.", "deprecated" => false, "security" => [{ "authenticate_user" => [] }], "tags" => ["v1/Users"], "responses" => { "200" => { "description" => "", "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "users" => { "type" => "array", "items" => { "type" => "object", "properties" => { "email" => { "type" => "string" }, "id" => { "type" => "string" }, "name" => { "type" => "string" }, "avatar" => { "type" => "object", "properties" => { "url" => { "type" => "string" }, "updated_at" => { "type" => "string" } } }, "address" => { "type" => "object", "properties" => { "city" => { "type" => "string" }, "zip" => { "type" => "string" }, "country" => { "type" => "string" } } } } } } } } } } } } }, "post" => { "summary" => "Creates a user.", "description" => "", "deprecated" => false, "security" => [{ "authenticate_user" => [] }], "tags" => ["v1/Users"], "responses" => { "200" => { "description" => "", "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "user" => { "type" => "object", "properties" => { "email" => { "type" => "string" }, "id" => { "type" => "string" }, "name" => { "type" => "string" }, "avatar" => { "type" => "object", "properties" => { "url" => { "type" => "string" }, "updated_at" => { "type" => "string" } } }, "address" => { "type" => "object", "properties" => { "city" => { "type" => "string" }, "zip" => { "type" => "string" }, "country" => { "type" => "string" } } } } } } } } } } }, "requestBody" => { "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "user" => { "type" => "object", "properties" => { "name" => { "type" => "string" }, "email" => { "type" => "string" }, "password" => { "type" => "string" } } } } } } } } } })
+ expect(spec["paths"]["/api/v1/users/{id}"]).to match({ "parameters" => [{ "in" => "path", "name" => "id", "required" => true, "schema" => { "type" => "integer" } }], "get" => { "summary" => "Returns a specific user.", "description" => "", "deprecated" => false, "security" => [{ "authenticate_user" => [] }], "tags" => ["v1/Users"], "responses" => { "200" => { "description" => "", "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "full_name" => { "type" => "string" }, "comments" => { "type" => "array", "items" => { "type" => "object", "properties" => { "content" => { "type" => "string" }, "created_at" => { "type" => "string" } } } } } } } } }, "404" => { "description" => "" } } } })
+ expect(spec["paths"]["/api/v2/users"]).to match({ "get" => { "summary" => "Returns the list of all users.", "description" => "Test description.", "deprecated" => false, "security" => [], "tags" => ["v2/Users"], "responses" => { "200" => { "description" => "", "content" => { "application/json" => { "schema" => { "type" => "array", "items" => { "type" => "object", "properties" => { "full_name" => { "type" => "string" }, "comments" => { "type" => "array", "items" => { "type" => "object", "properties" => { "content" => { "type" => "string" }, "created_at" => { "type" => "string" } } } } } } } } } } } } })
+ expect(spec["paths"]["/api/v2/users/{id}"]).to match({ "parameters" => [{ "in" => "path", "name" => "id", "required" => true, "schema" => { "type" => "integer" } }], "get" => { "summary" => "Returns a specific user.", "description" => "", "deprecated" => true, "security" => [{ "authenticate_user" => [] }], "tags" => ["v2/Users"], "responses" => { "200" => { "description" => "", "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "full_name" => { "type" => "string" }, "comments" => { "type" => "array", "items" => { "type" => "object", "properties" => { "content" => { "type" => "string" }, "created_at" => { "type" => "string" } } } } } } } } } } } })
+ expect(spec["paths"]["/api/v3/users/{id}"]).to match({ "parameters" => [{ "in" => "path", "name" => "id", "required" => true, "schema" => { "type" => "integer" } }], "get" => { "summary" => "Returns a specific user.", "description" => "", "deprecated" => false, "security" => [{ "authenticate_user" => [] }], "tags" => ["v3/Users"], "responses" => { "200" => { "description" => "", "content" => { "application/json" => { "schema" => { "$ref" => "#/components/schemas/V3_User" } } } }, "404" => { "$ref" => "#/components/responses/404NotFound" } } } })
+ end
+ end
end
diff --git a/spec/integration/test_app/Gemfile b/spec/integration/test_app/Gemfile
index 51b6be65..37b9acdc 100644
--- a/spec/integration/test_app/Gemfile
+++ b/spec/integration/test_app/Gemfile
@@ -1,9 +1,8 @@
source "https://rubygems.org"
gem "rage-rb"
-
-# Build JSON APIs with ease
-# gem "alba"
+gem "alba", "~> 3.0"
+gem "prism"
# Get 50% to 150% boost when parsing JSON.
# Rage will automatically use FastJsonparser if it is available.
diff --git a/spec/integration/test_app/app/controllers/api/base_controller.rb b/spec/integration/test_app/app/controllers/api/base_controller.rb
new file mode 100644
index 00000000..5d5b7bec
--- /dev/null
+++ b/spec/integration/test_app/app/controllers/api/base_controller.rb
@@ -0,0 +1,14 @@
+module Api
+ class BaseController < RageController::API
+ # @version 2.0.0
+ # @title My Test API
+ # @auth authenticate_user
+
+ before_action :authenticate_user
+
+ private
+
+ def authenticate_user
+ end
+ end
+end
diff --git a/spec/integration/test_app/app/controllers/api/v1/users_controller.rb b/spec/integration/test_app/app/controllers/api/v1/users_controller.rb
new file mode 100644
index 00000000..9d6e063e
--- /dev/null
+++ b/spec/integration/test_app/app/controllers/api/v1/users_controller.rb
@@ -0,0 +1,27 @@
+module Api
+ module V1
+ class UsersController < BaseController
+ # Returns the list of all users.
+ # @description Test
+ # description
+ # for
+ # the
+ # method.
+ # @response [UserResource]
+ def index
+ end
+
+ # Returns a specific user.
+ # @response ::UserResource
+ # @response 404
+ def show
+ end
+
+ # Creates a user.
+ # @request { user: { name: String, email: String, password: String } }
+ # @response Api::V1::UserResource
+ def create
+ end
+ end
+ end
+end
diff --git a/spec/integration/test_app/app/controllers/api/v2/users_controller.rb b/spec/integration/test_app/app/controllers/api/v2/users_controller.rb
new file mode 100644
index 00000000..7f72143f
--- /dev/null
+++ b/spec/integration/test_app/app/controllers/api/v2/users_controller.rb
@@ -0,0 +1,24 @@
+module Api
+ module V2
+ class UsersController < BaseController
+ skip_before_action :authenticate_user, only: :index
+
+ # Returns the list of all users.
+ # @description Test description.
+ # @response Array
+ def index
+ end
+
+ # Returns a specific user.
+ # @response UserResource
+ # @deprecated
+ def show
+ end
+
+ # @private
+ # Creates a user.
+ def create
+ end
+ end
+ end
+end
diff --git a/spec/integration/test_app/app/controllers/api/v3/users_controller.rb b/spec/integration/test_app/app/controllers/api/v3/users_controller.rb
new file mode 100644
index 00000000..cddc752c
--- /dev/null
+++ b/spec/integration/test_app/app/controllers/api/v3/users_controller.rb
@@ -0,0 +1,11 @@
+module Api
+ module V3
+ class UsersController < BaseController
+ # Returns a specific user.
+ # @response 200 #/components/schemas/V3_User
+ # @response 404 #/components/responses/404NotFound
+ def show
+ end
+ end
+ end
+end
diff --git a/spec/integration/test_app/app/resources/api/v1/avatar_resource.rb b/spec/integration/test_app/app/resources/api/v1/avatar_resource.rb
new file mode 100644
index 00000000..06bc9349
--- /dev/null
+++ b/spec/integration/test_app/app/resources/api/v1/avatar_resource.rb
@@ -0,0 +1,8 @@
+module Api
+ module V1
+ class AvatarResource
+ include Alba::Resource
+ attributes :url, :updated_at
+ end
+ end
+end
diff --git a/spec/integration/test_app/app/resources/api/v1/user_resource.rb b/spec/integration/test_app/app/resources/api/v1/user_resource.rb
new file mode 100644
index 00000000..892d507b
--- /dev/null
+++ b/spec/integration/test_app/app/resources/api/v1/user_resource.rb
@@ -0,0 +1,14 @@
+module Api
+ module V1
+ class UserResource < BaseUserResource
+ include Alba::Resource
+
+ attributes :id, :name
+ has_one :avatar
+
+ nested_attribute :address do
+ attributes :city, :zip, :country
+ end
+ end
+ end
+end
diff --git a/spec/integration/test_app/app/resources/base_user_resource.rb b/spec/integration/test_app/app/resources/base_user_resource.rb
new file mode 100644
index 00000000..e92a4d60
--- /dev/null
+++ b/spec/integration/test_app/app/resources/base_user_resource.rb
@@ -0,0 +1,5 @@
+class BaseUserResource
+ include Alba::Resource
+ root_key :user, :users
+ attributes :email
+end
diff --git a/spec/integration/test_app/app/resources/comment_resource.rb b/spec/integration/test_app/app/resources/comment_resource.rb
new file mode 100644
index 00000000..db00e7b9
--- /dev/null
+++ b/spec/integration/test_app/app/resources/comment_resource.rb
@@ -0,0 +1,4 @@
+class CommentResource
+ include Alba::Resource
+ attributes :content, :created_at
+end
diff --git a/spec/integration/test_app/app/resources/user_resource.rb b/spec/integration/test_app/app/resources/user_resource.rb
new file mode 100644
index 00000000..77f308c1
--- /dev/null
+++ b/spec/integration/test_app/app/resources/user_resource.rb
@@ -0,0 +1,6 @@
+class UserResource
+ include Alba::Resource
+
+ attributes :full_name
+ has_many :comments
+end
diff --git a/spec/integration/test_app/config.ru b/spec/integration/test_app/config.ru
index a307ef47..5518db23 100644
--- a/spec/integration/test_app/config.ru
+++ b/spec/integration/test_app/config.ru
@@ -5,3 +5,7 @@ run Rage.application
map "/cable" do
run Rage.cable.application
end
+
+map "/publicapi" do
+ run Rage.openapi.application
+end
diff --git a/spec/integration/test_app/config/initializers/alba.rb b/spec/integration/test_app/config/initializers/alba.rb
new file mode 100644
index 00000000..86cf255e
--- /dev/null
+++ b/spec/integration/test_app/config/initializers/alba.rb
@@ -0,0 +1,26 @@
+module TestInflector
+ module_function
+
+ def camelize(_)
+ end
+
+ def camelize_lower(_)
+ end
+
+ def dasherize(_)
+ end
+
+ def underscore(_)
+ end
+
+ def classify(string)
+ case string.to_s
+ when "avatar"
+ "Avatar"
+ when "comments"
+ "Comment"
+ end
+ end
+end
+
+Alba.inflector = TestInflector
diff --git a/spec/integration/test_app/config/openapi_components.yml b/spec/integration/test_app/config/openapi_components.yml
new file mode 100644
index 00000000..4890c44e
--- /dev/null
+++ b/spec/integration/test_app/config/openapi_components.yml
@@ -0,0 +1,21 @@
+components:
+ schemas:
+ V3_User:
+ type: object
+ properties:
+ uuid:
+ type: string
+ is_admin:
+ type: boolean
+ responses:
+ 404NotFound:
+ description: The specified resource was not found.
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ code:
+ type: string
+ message:
+ type: string
diff --git a/spec/integration/test_app/config/routes.rb b/spec/integration/test_app/config/routes.rb
index efc58bbe..8acf9aaf 100644
--- a/spec/integration/test_app/config/routes.rb
+++ b/spec/integration/test_app/config/routes.rb
@@ -25,4 +25,18 @@
get "logs/fiber", to: "logs#fiber"
mount ->(_) { [200, {}, ""] }, at: "/admin"
+
+ namespace :api do
+ namespace :v1 do
+ resources :users, only: %i(index show create)
+ end
+
+ namespace :v2 do
+ resources :users, only: %i(index show create)
+ end
+
+ namespace :v3 do
+ resources :users, only: :show
+ end
+ end
end
diff --git a/spec/openapi/builder/auth_spec.rb b/spec/openapi/builder/auth_spec.rb
new file mode 100644
index 00000000..670ba548
--- /dev/null
+++ b/spec/openapi/builder/auth_spec.rb
@@ -0,0 +1,460 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Builder do
+ include_context "mocked_classes"
+ include_context "mocked_rage_routes"
+
+ subject { described_class.new.run }
+
+ describe "@auth" do
+ context "with before_action" do
+ before do
+ allow(RageController::API).to receive(:__before_action_exists?).with(:authenticate!).and_return(true)
+ allow(RageController::API).to receive(:__before_actions_for).with(:index).and_return([{ name: :authenticate! }])
+ end
+
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @auth authenticate!
+
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => { "securitySchemes" => { "authenticate" => { "type" => "http", "scheme" => "bearer" } } }, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [{ "authenticate" => [] }], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with skip_before_action" do
+ before do
+ allow(RageController::API).to receive(:__before_action_exists?).with(:authenticate!).and_return(true)
+ allow(RageController::API).to receive(:__before_actions_for).with(:index).and_return([])
+ allow(RageController::API).to receive(:__before_actions_for).with(:create).and_return([{ name: :authenticate! }])
+ end
+
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @auth authenticate!
+
+ def index
+ end
+
+ def create
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index",
+ "POST /users" => "UsersController#create"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => { "securitySchemes" => { "authenticate" => { "type" => "http", "scheme" => "bearer" } } }, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } }, "post" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [{ "authenticate" => [] }], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with different security schemes" do
+ before do
+ allow(RageController::API).to receive(:__before_action_exists?).with(:auth_read).and_return(true)
+ allow(RageController::API).to receive(:__before_action_exists?).with(:auth_create).and_return(true)
+
+ allow(RageController::API).to receive(:__before_actions_for).with(:index).and_return([{ name: :auth_read }])
+ allow(RageController::API).to receive(:__before_actions_for).with(:create).and_return([{ name: :auth_create }])
+ end
+
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @auth auth_read
+ # @auth auth_create
+
+ def index
+ end
+
+ def create
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index",
+ "POST /users" => "UsersController#create"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => { "securitySchemes" => { "auth_read" => { "type" => "http", "scheme" => "bearer" }, "auth_create" => { "type" => "http", "scheme" => "bearer" } } }, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [{ "auth_read" => [] }], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } }, "post" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [{ "auth_create" => [] }], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with multiple security schemes" do
+ before do
+ allow(RageController::API).to receive(:__before_action_exists?).with(:auth_internal).and_return(true)
+ allow(RageController::API).to receive(:__before_action_exists?).with(:auth_external).and_return(true)
+
+ allow(RageController::API).to receive(:__before_actions_for).with(:index).and_return([
+ { name: :auth_internal },
+ { name: :auth_external }
+ ])
+ end
+
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @auth auth_internal
+ # @auth auth_external
+
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => { "securitySchemes" => { "auth_internal" => { "type" => "http", "scheme" => "bearer" }, "auth_external" => { "type" => "http", "scheme" => "bearer" } } }, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [{ "auth_internal" => [] }, { "auth_external" => [] }], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with name" do
+ before do
+ allow(RageController::API).to receive(:__before_action_exists?).with(:authenticate!).and_return(true)
+ allow(RageController::API).to receive(:__before_actions_for).with(:index).and_return([{ name: :authenticate! }])
+ end
+
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @auth authenticate! ApiKeyAuth
+
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => { "securitySchemes" => { "ApiKeyAuth" => { "type" => "http", "scheme" => "bearer" } } }, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [{ "ApiKeyAuth" => [] }], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with custom definition" do
+ before do
+ allow(RageController::API).to receive(:__before_action_exists?).with(:authenticate!).and_return(true)
+ allow(RageController::API).to receive(:__before_actions_for).with(:index).and_return([{ name: :authenticate! }])
+ end
+
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @auth authenticate!
+ # type: apiKey
+ # in: header
+ # name: X-API-Key
+
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => { "securitySchemes" => { "authenticate" => { "type" => "apiKey", "in" => "header", "name" => "X-API-Key" } } }, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [{ "authenticate" => [] }], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with shared references" do
+ before do
+ allow(RageController::API).to receive(:__before_action_exists?).with(:authenticate_with_token).and_return(true)
+ allow(RageController::API).to receive(:__before_actions_for).with(:index).and_return([{ name: :authenticate_with_token }])
+
+ allow(Rage::OpenAPI).to receive(:__shared_components).and_return(YAML.safe_load(<<~YAML
+ components:
+ securitySchemes:
+ authenticate:
+ type: apiKey
+ in: header
+ name: X-API-Key
+ YAML
+ ))
+ end
+
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @auth authenticate_with_token
+
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => { "securitySchemes" => { "authenticate_with_token" => { "type" => "http", "scheme" => "bearer" }, "authenticate" => { "type" => "apiKey", "in" => "header", "name" => "X-API-Key" } } }, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [{ "authenticate_with_token" => [] }], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with unused security" do
+ before do
+ allow(RageController::API).to receive(:__before_action_exists?).with(:authenticate_with_token).and_return(true)
+ allow(RageController::API).to receive(:__before_actions_for).with(:index).and_return([])
+ end
+
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @auth authenticate_with_token
+
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with inheritance" do
+ before do
+ allow(RageController::API).to receive(:__before_action_exists?).with(:authenticate!).and_return(true)
+ allow(RageController::API).to receive(:__before_action_exists?).with(:auth_with_token).and_return(true)
+ allow(RageController::API).to receive(:__before_actions_for).with(:index).and_return([{ name: :auth_with_token }])
+ end
+
+ let_class("BaseController", parent: RageController::API) do
+ <<~'RUBY'
+ # @auth authenticate!
+ RUBY
+ end
+
+ let_class("Api::V1::BaseController", parent: mocked_classes.BaseController) do
+ <<~'RUBY'
+ # @auth auth_with_token V1-auth
+ RUBY
+ end
+
+ let_class("Api::V1::UsersController", parent: mocked_classes["Api::V1::BaseController"]) do
+ <<~'RUBY'
+ def index
+ end
+ RUBY
+ end
+
+ let_class("Api::V2::BaseController", parent: mocked_classes.BaseController) do
+ <<~'RUBY'
+ # @auth auth_with_token V2-auth
+ RUBY
+ end
+
+ let_class("Api::V2::UsersController", parent: mocked_classes["Api::V2::BaseController"]) do
+ <<~'RUBY'
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /api/v1/users" => "Api::V1::UsersController#index",
+ "GET /api/v2/users" => "Api::V2::UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => { "securitySchemes" => { "V1-auth" => { "type" => "http", "scheme" => "bearer" }, "V2-auth" => { "type" => "http", "scheme" => "bearer" } } }, "tags" => [{ "name" => "v1/Users" }, { "name" => "v2/Users" }], "paths" => { "/api/v1/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [{ "V1-auth" => [] }], "tags" => ["v1/Users"], "responses" => { "200" => { "description" => "" } } } }, "/api/v2/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [{ "V2-auth" => [] }], "tags" => ["v2/Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with unknown before action" do
+ before do
+ allow(RageController::API).to receive(:__before_action_exists?).with(:authenticate!).and_return(false)
+ end
+
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @auth authenticate!
+
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+
+ it "logs error" do
+ expect(Rage::OpenAPI).to receive(:__log_warn).with(/before action `authenticate!` is not defined/)
+ subject
+ end
+ end
+
+ context "with duplicate tag" do
+ before do
+ allow(RageController::API).to receive(:__before_action_exists?).with(:authenticate!).and_return(true)
+ allow(RageController::API).to receive(:__before_actions_for).with(:index).and_return([{ name: :authenticate! }])
+ end
+
+ let_class("BaseController", parent: RageController::API) do
+ <<~'RUBY'
+ # @auth authenticate!
+ RUBY
+ end
+
+ let_class("Api::V1::UsersController", parent: mocked_classes.BaseController) do
+ <<~'RUBY'
+ # @auth authenticate!
+
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /api/v1/users" => "Api::V1::UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => { "securitySchemes" => { "authenticate" => { "type" => "http", "scheme" => "bearer" } } }, "tags" => [{ "name" => "v1/Users" }], "paths" => { "/api/v1/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [{ "authenticate" => [] }], "tags" => ["v1/Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+
+ it "logs error" do
+ expect(Rage::OpenAPI).to receive(:__log_warn).with(/duplicate @auth tag detected/)
+ subject
+ end
+ end
+
+ context "with duplicate tag in a difference inheritance chain" do
+ before do
+ allow(RageController::API).to receive(:__before_action_exists?).with(:authenticate!).and_return(true)
+ allow(RageController::API).to receive(:__before_actions_for).with(:index).and_return([{ name: :authenticate! }])
+ end
+
+ let_class("Api::V1::UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @auth authenticate!
+
+ def index
+ end
+ RUBY
+ end
+
+ let_class("Api::V2::UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @auth authenticate!
+
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /api/v1/users" => "Api::V1::UsersController#index",
+ "GET /api/v2/users" => "Api::V2::UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => { "securitySchemes" => { "authenticate" => { "type" => "http", "scheme" => "bearer" } } }, "tags" => [{ "name" => "v1/Users" }, { "name" => "v2/Users" }], "paths" => { "/api/v1/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [{ "authenticate" => [] }], "tags" => ["v1/Users"], "responses" => { "200" => { "description" => "" } } } }, "/api/v2/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["v2/Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+
+ it "logs error" do
+ expect(Rage::OpenAPI).to receive(:__log_warn).with(/duplicate @auth tag detected/)
+ subject
+ end
+ end
+
+ context "with duplicate tag and a custom name" do
+ before do
+ allow(RageController::API).to receive(:__before_action_exists?).with(:authenticate!).and_return(true)
+ allow(RageController::API).to receive(:__before_actions_for).with(:index).and_return([{ name: :authenticate! }])
+ end
+
+ let_class("Api::V1::UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @auth authenticate!
+
+ def index
+ end
+ RUBY
+ end
+
+ let_class("Api::V2::UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @auth authenticate! AuthV2
+
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /api/v1/users" => "Api::V1::UsersController#index",
+ "GET /api/v2/users" => "Api::V2::UsersController#index"
+ }
+ end
+
+ it "does not log error" do
+ expect(Rage::OpenAPI).not_to receive(:__log_warn)
+ subject
+ end
+ end
+
+ context "with incorrect name" do
+ before do
+ allow(RageController::API).to receive(:__before_action_exists?).with(:authenticate!).and_return(true)
+ end
+
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @auth authenticate! Authenticate By Token
+
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "logs error" do
+ expect(Rage::OpenAPI).to receive(:__log_warn).with(/cannot contain spaces/)
+ subject
+ end
+ end
+ end
+end
diff --git a/spec/openapi/builder/base_spec.rb b/spec/openapi/builder/base_spec.rb
new file mode 100644
index 00000000..b76aee4f
--- /dev/null
+++ b/spec/openapi/builder/base_spec.rb
@@ -0,0 +1,268 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Builder do
+ include_context "mocked_classes"
+ include_context "mocked_rage_routes"
+
+ subject { described_class.new(**options).run }
+
+ let(:options) { {} }
+
+ context "with no routes" do
+ let(:routes) { {} }
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [], "paths" => {} })
+ end
+ end
+
+ context "with a valid controller" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with an invalid controller" do
+ let_class("UsersController", parent: Object) do
+ <<~'RUBY'
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [], "paths" => {} })
+ end
+ end
+
+ context "with multiple actions" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ def index
+ end
+
+ def show
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index",
+ "GET /users/:id" => "UsersController#show"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } }, "/users/{id}" => { "parameters" => [{ "in" => "path", "name" => "id", "required" => true, "schema" => { "type" => "integer" } }], "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with multiple controllers" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ def index
+ end
+ RUBY
+ end
+
+ let_class("PhotosController", parent: RageController::API) do
+ <<~'RUBY'
+ def create
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index",
+ "POST /photos" => "PhotosController#create"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Photos" }, { "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } }, "/photos" => { "post" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Photos"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with parsing error" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ def index
+ RUBY
+ end
+
+ let_class("PhotosController", parent: RageController::API) do
+ <<~'RUBY'
+ def create
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index",
+ "POST /photos" => "PhotosController#create"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Photos" }], "paths" => { "/photos" => { "post" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Photos"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+
+ it "logs error" do
+ expect(Rage::OpenAPI).to receive(:__log_warn).with("skipping UsersController because of parsing error")
+ subject
+ end
+ end
+
+ context "with namespaces" do
+ let_class("Api::V1::UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ def index
+ end
+ RUBY
+ end
+
+ let_class("Api::V2::UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /api/v1/users" => "Api::V1::UsersController#index",
+ "GET /api/v2/users" => "Api::V2::UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "v1/Users" }, { "name" => "v2/Users" }], "paths" => { "/api/v1/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["v1/Users"], "responses" => { "200" => { "description" => "" } } } }, "/api/v2/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["v2/Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+
+ context "with a namespace excluded" do
+ let(:options) { { namespace: "Api::V2" } }
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "v2/Users" }], "paths" => { "/api/v2/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["v2/Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+ end
+
+ context "with query parameters" do
+ let_class("PhotosController", parent: RageController::API) do
+ <<~'RUBY'
+ def show
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users/:user_id/photos/:id" => "PhotosController#show"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Photos" }], "paths" => { "/users/{user_id}/photos/{id}" => { "parameters" => [{ "in" => "path", "name" => "user_id", "required" => true, "schema" => { "type" => "integer" } }, { "in" => "path", "name" => "id", "required" => true, "schema" => { "type" => "integer" } }], "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Photos"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with invalid tag" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @who_am_i
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+
+ it "logs error" do
+ expect(Rage::OpenAPI).to receive(:__log_warn).with(/unrecognized `@who_am_i` tag detected/)
+ subject
+ end
+ end
+
+ context "with multi-line tags" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @response 201
+ # @description This
+ # is
+ # a test
+ # description.
+ # @response 202
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "This is a test description.", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "201" => { "description" => "" }, "202" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with incorrect multi-line tags" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @response 200
+ # This is
+ # a summary.
+ # @response 201
+ # @description This
+ # is
+ # a test
+ # description.
+ # @response 202
+ # @response 404
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "This is", "description" => "This", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" }, "201" => { "description" => "" }, "202" => { "description" => "" }, "404" => { "description" => "" } } } } } })
+ end
+ end
+end
diff --git a/spec/openapi/builder/deprecated_spec.rb b/spec/openapi/builder/deprecated_spec.rb
new file mode 100644
index 00000000..e41f59af
--- /dev/null
+++ b/spec/openapi/builder/deprecated_spec.rb
@@ -0,0 +1,311 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Builder do
+ include_context "mocked_classes"
+ include_context "mocked_rage_routes"
+
+ subject { described_class.new.run }
+
+ describe "@deprecated" do
+ context "with a deprecated method" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @deprecated
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => true, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with multiple methods" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @deprecated
+ def index
+ end
+
+ def create
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index",
+ "POST /users" => "UsersController#create"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => true, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } }, "post" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with a deprecated controller" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @deprecated
+
+ def index
+ end
+
+ def create
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index",
+ "POST /users" => "UsersController#create"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => true, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } }, "post" => { "summary" => "", "description" => "", "deprecated" => true, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with inherited deprecated method" do
+ let_class("BaseController", parent: RageController::API) do
+ <<~'RUBY'
+ # @deprecated
+ def index
+ end
+ RUBY
+ end
+
+ let_class("UsersController", parent: mocked_classes.BaseController)
+
+ let(:routes) { { "GET /users" => "UsersController#index" } }
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => true, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with overriden deprecated method" do
+ let_class("BaseController", parent: RageController::API) do
+ <<~'RUBY'
+ # @deprecated
+ def index
+ end
+ RUBY
+ end
+
+ let_class("UsersController", parent: mocked_classes.BaseController) do
+ <<~'RUBY'
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) { { "GET /users" => "UsersController#index" } }
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with deprecated parent" do
+ let_class("BaseController", parent: RageController::API) do
+ <<~'RUBY'
+ # @deprecated
+ RUBY
+ end
+
+ let_class("UsersController", parent: mocked_classes.BaseController) do
+ <<~'RUBY'
+ def index
+ end
+
+ def create
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index",
+ "POST /users" => "UsersController#create"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => true, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } }, "post" => { "summary" => "", "description" => "", "deprecated" => true, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with duplicate tags" do
+ let_class("BaseController", parent: RageController::API) do
+ <<~'RUBY'
+ # @deprecated
+ RUBY
+ end
+
+ let_class("UsersController", parent: mocked_classes.BaseController) do
+ <<~'RUBY'
+ # @deprecated
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => true, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+
+ it "logs error" do
+ expect(Rage::OpenAPI).to receive(:__log_warn).with(/duplicate `@deprecated` tag detected/)
+ subject
+ end
+ end
+
+ context "with internal comments" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @deprecated Use Members API instead
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => true, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with multi-line internal comments" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @deprecated Use
+ # Members
+ # API
+ # instead
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => true, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with multi-line internal comments and another tags" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @deprecated Use
+ # Members
+ # API
+ # instead
+ # @description Test
+ # API
+ # Description
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "Test API Description", "deprecated" => true, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with incorrect tag" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @deprecatedd
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+
+ it "logs error" do
+ expect(Rage::OpenAPI).to receive(:__log_warn).with(/unrecognized `@deprecatedd` tag detected/)
+ subject
+ end
+ end
+
+ context "with deprecated class in a separate inheritance chain" do
+ let_class("BaseController", parent: RageController::API)
+
+ let_class("Api::V1::BaseController", parent: mocked_classes.BaseController) do
+ <<~'RUBY'
+ # @deprecated
+ RUBY
+ end
+
+ let_class("Api::V1::UsersController", parent: mocked_classes["Api::V1::BaseController"]) do
+ <<~'RUBY'
+ def index
+ end
+ RUBY
+ end
+
+ let_class("Api::V2::BaseController", parent: mocked_classes.BaseController)
+
+ let_class("Api::V2::UsersController", parent: mocked_classes["Api::V2::BaseController"]) do
+ <<~'RUBY'
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /api/v1/users" => "Api::V1::UsersController#index",
+ "GET /api/v2/users" => "Api::V2::UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "v1/Users" }, { "name" => "v2/Users" }], "paths" => { "/api/v1/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => true, "security" => [], "tags" => ["v1/Users"], "responses" => { "200" => { "description" => "" } } } }, "/api/v2/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["v2/Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+ end
+end
diff --git a/spec/openapi/builder/description_spec.rb b/spec/openapi/builder/description_spec.rb
new file mode 100644
index 00000000..308b1156
--- /dev/null
+++ b/spec/openapi/builder/description_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Builder do
+ include_context "mocked_classes"
+ include_context "mocked_rage_routes"
+
+ subject { described_class.new.run }
+
+ describe "@description" do
+ context "with one-line description" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @description Returns the list of all internal and external users.
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "Returns the list of all internal and external users.", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with multi-line description" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @description Returns the list of users.
+ # Pass `with_deleted` to include deleted records.
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "Returns the list of users. Pass `with_deleted` to include deleted records.", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with summary" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # Returns the list of users.
+ # @description The list includes both internal and external users.
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "Returns the list of users.", "description" => "The list includes both internal and external users.", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+ end
+end
diff --git a/spec/openapi/builder/internal_spec.rb b/spec/openapi/builder/internal_spec.rb
new file mode 100644
index 00000000..b1bf52fc
--- /dev/null
+++ b/spec/openapi/builder/internal_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Builder do
+ include_context "mocked_classes"
+ include_context "mocked_rage_routes"
+
+ subject { described_class.new.run }
+
+ describe "@internal" do
+ context "with an internal comment" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @internal this is an internal comment
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with a multi-line internal comment" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @internal this
+ # is
+ # an
+ # internal
+ # comment
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with another tag" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @internal this is an internal comment
+ # @deprecated
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => true, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+ end
+end
diff --git a/spec/openapi/builder/private_spec.rb b/spec/openapi/builder/private_spec.rb
new file mode 100644
index 00000000..93c9ca49
--- /dev/null
+++ b/spec/openapi/builder/private_spec.rb
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Builder do
+ include_context "mocked_classes"
+ include_context "mocked_rage_routes"
+
+ subject { described_class.new.run }
+
+ describe "@private" do
+ context "with an private method" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @private
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [], "paths" => {} })
+ end
+ end
+
+ context "with multiple methods" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @private
+ def index
+ end
+
+ def create
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index",
+ "POST /users" => "UsersController#create"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "post" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with private parent" do
+ let_class("BaseController", parent: RageController::API) do
+ <<~'RUBY'
+ # @private
+ RUBY
+ end
+
+ let_class("UsersController", parent: mocked_classes.BaseController) do
+ <<~'RUBY'
+ def index
+ end
+
+ def create
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index",
+ "POST /users" => "UsersController#create"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [], "paths" => {} })
+ end
+ end
+
+ context "with private comments" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @private External clients should use the Members API
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [], "paths" => {} })
+ end
+ end
+
+ context "with multi-line private comments" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @private External clients
+ # should use the
+ # Members API
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [], "paths" => {} })
+ end
+ end
+
+ context "with multi-line private comments on the class level" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @private External clients
+ # should use the
+ # Members API
+
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [], "paths" => {} })
+ end
+ end
+ end
+end
diff --git a/spec/openapi/builder/request_spec.rb b/spec/openapi/builder/request_spec.rb
new file mode 100644
index 00000000..a61d94a2
--- /dev/null
+++ b/spec/openapi/builder/request_spec.rb
@@ -0,0 +1,153 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Builder do
+ include_context "mocked_classes"
+ include_context "mocked_rage_routes"
+
+ subject { described_class.new.run }
+
+ describe "@request" do
+ context "with a data request" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @request { email: String, password: String }
+ def create
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "POST /users" => "UsersController#create" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "post" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } }, "requestBody" => { "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "email" => { "type" => "string" }, "password" => { "type" => "string" } } } } } } } } } })
+ end
+ end
+
+ context "with shared requestBody reference" do
+ before do
+ allow(Rage::OpenAPI).to receive(:__shared_components).and_return(YAML.safe_load(<<~YAML
+ components:
+ schemas:
+ User:
+ type: object
+ properties:
+ id:
+ type: integer
+ format: int64
+ name:
+ type: string
+ requestBodies:
+ UserBody:
+ description: A JSON object containing user information
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/User'
+ YAML
+ ))
+ end
+
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @request #/components/requestBodies/UserBody
+ def create
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "POST /users" => "UsersController#create" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => { "schemas" => { "User" => { "type" => "object", "properties" => { "id" => { "type" => "integer", "format" => "int64" }, "name" => { "type" => "string" } } } }, "requestBodies" => { "UserBody" => { "description" => "A JSON object containing user information", "required" => true, "content" => { "application/json" => { "schema" => { "$ref" => "#/components/schemas/User" } } } } } }, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "post" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } }, "requestBody" => { "$ref" => "#/components/requestBodies/UserBody" } } } } })
+ end
+ end
+
+ context "with shared scheme reference" do
+ before do
+ allow(Rage::OpenAPI).to receive(:__shared_components).and_return(YAML.safe_load(<<~YAML
+ components:
+ schemas:
+ User:
+ type: object
+ properties:
+ id:
+ type: integer
+ format: int64
+ name:
+ type: string
+ YAML
+ ))
+ end
+
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @request #/components/schemas/User
+ def create
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "POST /users" => "UsersController#create" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => { "schemas" => { "User" => { "type" => "object", "properties" => { "id" => { "type" => "integer", "format" => "int64" }, "name" => { "type" => "string" } } } } }, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "post" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } }, "requestBody" => { "content" => { "application/json" => { "schema" => { "$ref" => "#/components/schemas/User" } } } } } } } })
+ end
+ end
+
+ context "with an invalid tag" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @request {]}
+ def create
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "POST /users" => "UsersController#create" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "post" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+
+ it "logs error" do
+ expect(Rage::OpenAPI).to receive(:__log_warn).with(/unrecognized `@request` tag detected/)
+ subject
+ end
+ end
+
+ context "with a duplicate tag" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @request { email: String, password: String }
+ # @request { uuid: String }
+ def create
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "POST /users" => "UsersController#create" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "post" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } }, "requestBody" => { "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "email" => { "type" => "string" }, "password" => { "type" => "string" } } } } } } } } } })
+ end
+
+ it "logs error" do
+ expect(Rage::OpenAPI).to receive(:__log_warn).with(/duplicate `@request` tag detected/)
+ subject
+ end
+ end
+ end
+end
diff --git a/spec/openapi/builder/response_spec.rb b/spec/openapi/builder/response_spec.rb
new file mode 100644
index 00000000..697dfc72
--- /dev/null
+++ b/spec/openapi/builder/response_spec.rb
@@ -0,0 +1,254 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Builder do
+ include_context "mocked_classes"
+ include_context "mocked_rage_routes"
+
+ subject { described_class.new.run }
+
+ describe "@response" do
+ context "with a data response" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @response { id: Integer, full_name: String }
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "", "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "id" => { "type" => "integer" }, "full_name" => { "type" => "string" } } } } } } } } } } })
+ end
+ end
+
+ context "with a status code response" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @response 204
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "204" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with both data and status response" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @response 202 { id: Integer, full_name: String }
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "202" => { "description" => "", "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "id" => { "type" => "integer" }, "full_name" => { "type" => "string" } } } } } } } } } } })
+ end
+ end
+
+ context "with multiple responses" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @response { id: Integer, full_name: String }
+ # @response 500 { session_id: String }
+ # @response 404
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "", "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "id" => { "type" => "integer" }, "full_name" => { "type" => "string" } } } } } }, "500" => { "description" => "", "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "session_id" => { "type" => "string" } } } } } }, "404" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with collection responses" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @response [{ id: Integer, full_name: String }]
+ # @response 500 { session_id: String }
+ # @response 404
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "", "content" => { "application/json" => { "schema" => { "type" => "array", "items" => { "type" => "object", "properties" => { "id" => { "type" => "integer" }, "full_name" => { "type" => "string" } } } } } } }, "500" => { "description" => "", "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "session_id" => { "type" => "string" } } } } } }, "404" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with serializer responses" do
+ before do
+ allow_any_instance_of(Rage::OpenAPI::Parsers::Ext::Alba).to receive(:known_definition?).and_call_original
+ allow_any_instance_of(Rage::OpenAPI::Parsers::Ext::Alba).to receive(:known_definition?).with("UserResource").and_return(true)
+ end
+
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :full_name, :email
+ RUBY
+ end
+
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @response UserResource
+ # @response 500 { session_id: String }
+ # @response 404
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "", "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "full_name" => { "type" => "string" }, "email" => { "type" => "string" } } } } } }, "500" => { "description" => "", "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "session_id" => { "type" => "string" } } } } } }, "404" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with serializer collection" do
+ before do
+ allow_any_instance_of(Rage::OpenAPI::Parsers::Ext::Alba).to receive(:known_definition?).and_call_original
+ allow_any_instance_of(Rage::OpenAPI::Parsers::Ext::Alba).to receive(:known_definition?).with("[UserResource]").and_return(true)
+ end
+
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :full_name, :email
+ RUBY
+ end
+
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @response 202 [UserResource]
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "202" => { "description" => "", "content" => { "application/json" => { "schema" => { "type" => "array", "items" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "full_name" => { "type" => "string" }, "email" => { "type" => "string" } } } } } } } } } } } })
+ end
+ end
+
+ context "with shared reference" do
+ before do
+ allow(Rage::OpenAPI).to receive(:__shared_components).and_return(YAML.safe_load(<<~YAML
+ components:
+ schemas:
+ User:
+ type: object
+ properties:
+ id:
+ type: integer
+ format: int64
+ name:
+ type: string
+ responses:
+ 404NotFound:
+ description: The specified resource was not found.
+ YAML
+ ))
+ end
+
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @response #/components/schemas/User
+ # @response 404 #/components/responses/404NotFound
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => { "schemas" => { "User" => { "type" => "object", "properties" => { "id" => { "type" => "integer", "format" => "int64" }, "name" => { "type" => "string" } } } }, "responses" => { "404NotFound" => { "description" => "The specified resource was not found." } } }, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "", "content" => { "application/json" => { "schema" => { "$ref" => "#/components/schemas/User" } } } }, "404" => { "$ref" => "#/components/responses/404NotFound" } } } } } })
+ end
+ end
+
+ context "with invalid serializer" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @response UnknownResource
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+
+ it "logs error" do
+ expect(Rage::OpenAPI).to receive(:__log_warn).with(/unrecognized `@response` tag detected/)
+ subject
+ end
+ end
+
+ context "with duplicate tag" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @response 200 { id: Integer, full_name: String }
+ # @response 404
+ # @response { uuid: String }
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "", "content" => { "application/json" => { "schema" => { "type" => "object", "properties" => { "id" => { "type" => "integer" }, "full_name" => { "type" => "string" } } } } } }, "404" => { "description" => "" } } } } } })
+ end
+
+ it "logs error" do
+ expect(Rage::OpenAPI).to receive(:__log_warn).with(/duplicate `@response` tag detected at .+:4/)
+ subject
+ end
+ end
+ end
+end
diff --git a/spec/openapi/builder/summary_spec.rb b/spec/openapi/builder/summary_spec.rb
new file mode 100644
index 00000000..8474bca3
--- /dev/null
+++ b/spec/openapi/builder/summary_spec.rb
@@ -0,0 +1,165 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Builder do
+ include_context "mocked_classes"
+ include_context "mocked_rage_routes"
+
+ subject { described_class.new.run }
+
+ describe "@summary" do
+ context "with summary" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # Returns the list of all users.
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "Returns the list of all users.", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with multiple controllers" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # Returns the list of all users.
+ def index
+ end
+ RUBY
+ end
+
+ let_class("PhotosController", parent: RageController::API) do
+ <<~'RUBY'
+ # Returns the list of all photos.
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "UsersController#index",
+ "GET /photos" => "PhotosController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Photos" }, { "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "Returns the list of all users.", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } }, "/photos" => { "get" => { "summary" => "Returns the list of all photos.", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Photos"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with inheritance" do
+ let_class("BaseUsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # Returns the list of all users.
+ def index
+ end
+ RUBY
+ end
+
+ let_class("Api::V1::UsersController", parent: mocked_classes.BaseUsersController)
+
+ let(:routes) do
+ {
+ "GET /api/v1/users" => "Api::V1::UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "v1/Users" }], "paths" => { "/api/v1/users" => { "get" => { "summary" => "Returns the list of all users.", "description" => "", "deprecated" => false, "security" => [], "tags" => ["v1/Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with inheritance and an override in child controller" do
+ let_class("BaseUsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # Returns the list of all users.
+ def index
+ end
+ RUBY
+ end
+
+ let_class("Api::V1::UsersController", parent: mocked_classes.BaseUsersController) do
+ <<~'RUBY'
+ # Returns the list of some users.
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /api/v1/users" => "Api::V1::UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "v1/Users" }], "paths" => { "/api/v1/users" => { "get" => { "summary" => "Returns the list of some users.", "description" => "", "deprecated" => false, "security" => [], "tags" => ["v1/Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with multiple inheritance chains" do
+ let_class("BaseController", parent: RageController::API) do
+ <<~'RUBY'
+ # Returns the list of records.
+ def index
+ end
+ RUBY
+ end
+
+ let_class("Api::V1::BaseController", parent: mocked_classes.BaseController) do
+ <<~'RUBY'
+ # Returns the list of API V1 records.
+ def index
+ end
+ RUBY
+ end
+
+ let_class("Api::V1::UsersController", parent: mocked_classes["Api::V1::BaseController"])
+ let_class("Api::V2::UsersController", parent: mocked_classes.BaseController)
+
+ let(:routes) do
+ {
+ "GET /api/v1/users" => "Api::V1::UsersController#index",
+ "GET /api/v2/users" => "Api::V2::UsersController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "v1/Users" }, { "name" => "v2/Users" }], "paths" => { "/api/v1/users" => { "get" => { "summary" => "Returns the list of API V1 records.", "description" => "", "deprecated" => false, "security" => [], "tags" => ["v1/Users"], "responses" => { "200" => { "description" => "" } } } }, "/api/v2/users" => { "get" => { "summary" => "Returns the list of records.", "description" => "", "deprecated" => false, "security" => [], "tags" => ["v2/Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with multi-line summary" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # Returns the list of all users
+ # which were deleted.
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "Returns the list of all users", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+
+ it "logs error" do
+ expect(Rage::OpenAPI).to receive(:__log_warn).with(/summary should only be one line/)
+ subject
+ end
+ end
+ end
+end
diff --git a/spec/openapi/builder/tag_resolver_spec.rb b/spec/openapi/builder/tag_resolver_spec.rb
new file mode 100644
index 00000000..5c2f06ef
--- /dev/null
+++ b/spec/openapi/builder/tag_resolver_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Builder do
+ include_context "mocked_classes"
+ include_context "mocked_rage_routes"
+
+ subject { described_class.new.run }
+
+ before do
+ allow(Rage.config.openapi).to receive(:tag_resolver).and_return(tag_resolver)
+ end
+
+ describe "custom tag resolver" do
+ context "with a custom tag" do
+ let(:tag_resolver) do
+ proc { "User_Records" }
+ end
+
+ let_class("Api::V1::UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "Api::V1::UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "User_Records" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["User_Records"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with multiple custom tags" do
+ let(:tag_resolver) do
+ proc { %w(Users Records) }
+ end
+
+ let_class("Api::V1::UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "Api::V1::UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Records" }, { "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users", "Records"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with conditionals" do
+ let(:tag_resolver) do
+ proc do |controller, action, default_tag|
+ if controller.name == "Api::V1::PhotosController"
+ "UserRecords"
+ elsif action == :create
+ "ModifyOperations"
+ else
+ default_tag
+ end
+ end
+ end
+
+ let_class("Api::V1::UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ def index
+ end
+
+ def create
+ end
+ RUBY
+ end
+
+ let_class("Api::V1::PhotosController", parent: RageController::API) do
+ <<~'RUBY'
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ {
+ "GET /users" => "Api::V1::UsersController#index",
+ "POST /users" => "Api::V1::UsersController#create",
+ "GET /photos" => "Api::V1::PhotosController#index"
+ }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "ModifyOperations" }, { "name" => "UserRecords" }, { "name" => "v1/Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["v1/Users"], "responses" => { "200" => { "description" => "" } } }, "post" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["ModifyOperations"], "responses" => { "200" => { "description" => "" } } } }, "/photos" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["UserRecords"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+ end
+end
diff --git a/spec/openapi/builder/title_spec.rb b/spec/openapi/builder/title_spec.rb
new file mode 100644
index 00000000..bfb0e984
--- /dev/null
+++ b/spec/openapi/builder/title_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Builder do
+ include_context "mocked_classes"
+ include_context "mocked_rage_routes"
+
+ subject { described_class.new.run }
+
+ describe "@title" do
+ context "with a title" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @title My Test API
+
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "My Test API" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with inherited title" do
+ let_class("BaseController", parent: RageController::API) do
+ <<~'RUBY'
+ # @title My Test API
+ RUBY
+ end
+
+ let_class("UsersController", parent: mocked_classes.BaseController) do
+ <<~'RUBY'
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "1.0.0", "title" => "My Test API" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+ end
+end
diff --git a/spec/openapi/builder/version_spec.rb b/spec/openapi/builder/version_spec.rb
new file mode 100644
index 00000000..7747bc72
--- /dev/null
+++ b/spec/openapi/builder/version_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Builder do
+ include_context "mocked_classes"
+ include_context "mocked_rage_routes"
+
+ subject { described_class.new.run }
+
+ describe "@version" do
+ context "with a title" do
+ let_class("UsersController", parent: RageController::API) do
+ <<~'RUBY'
+ # @version 2.3.4
+
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "2.3.4", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+
+ context "with inherited title" do
+ let_class("BaseController", parent: RageController::API) do
+ <<~'RUBY'
+ # @version 2.3.4
+ RUBY
+ end
+
+ let_class("UsersController", parent: mocked_classes.BaseController) do
+ <<~'RUBY'
+ def index
+ end
+ RUBY
+ end
+
+ let(:routes) do
+ { "GET /users" => "UsersController#index" }
+ end
+
+ it "returns correct schema" do
+ expect(subject).to eq({ "openapi" => "3.0.0", "info" => { "version" => "2.3.4", "title" => "Rage" }, "components" => {}, "tags" => [{ "name" => "Users" }], "paths" => { "/users" => { "get" => { "summary" => "", "description" => "", "deprecated" => false, "security" => [], "tags" => ["Users"], "responses" => { "200" => { "description" => "" } } } } } })
+ end
+ end
+ end
+end
diff --git a/spec/openapi/openapi_spec.rb b/spec/openapi/openapi_spec.rb
new file mode 100644
index 00000000..6784743b
--- /dev/null
+++ b/spec/openapi/openapi_spec.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI do
+ describe ".__try_parse_collection" do
+ subject { described_class.__try_parse_collection(input) }
+
+ context "with Array" do
+ context "with a class" do
+ let(:input) { "Array" }
+ it { is_expected.to eq([true, "UserResource"]) }
+ end
+
+ context "with a namespaced class" do
+ let(:input) { "Array" }
+ it { is_expected.to eq([true, "Api::V1::UserResource"]) }
+ end
+
+ context "with a namespaced class with namespace resolution" do
+ let(:input) { "Array<::Api::V1::UserResource>" }
+ it { is_expected.to eq([true, "::Api::V1::UserResource"]) }
+ end
+
+ context "with a class with config" do
+ let(:input) { "Array" }
+ it { is_expected.to eq([true, "UserResource(view: :extended)"]) }
+ end
+ end
+
+ context "with []" do
+ context "with a class" do
+ let(:input) { "[UserResource]" }
+ it { is_expected.to eq([true, "UserResource"]) }
+ end
+
+ context "with a namespaced class" do
+ let(:input) { "[Api::V1::UserResource]" }
+ it { is_expected.to eq([true, "Api::V1::UserResource"]) }
+ end
+
+ context "with a namespaced class with namespace resolution" do
+ let(:input) { "[::Api::V1::UserResource]" }
+ it { is_expected.to eq([true, "::Api::V1::UserResource"]) }
+ end
+
+ context "with a class with config" do
+ let(:input) { "[UserResource(view: :extended)]" }
+ it { is_expected.to eq([true, "UserResource(view: :extended)"]) }
+ end
+ end
+
+ context "without collection" do
+ context "with a class" do
+ let(:input) { "UserResource" }
+ it { is_expected.to eq([false, "UserResource"]) }
+ end
+
+ context "with a namespaced class" do
+ let(:input) { "Api::V1::UserResource" }
+ it { is_expected.to eq([false, "Api::V1::UserResource"]) }
+ end
+
+ context "with a namespaced class with namespace resolution" do
+ let(:input) { "::Api::V1::UserResource" }
+ it { is_expected.to eq([false, "::Api::V1::UserResource"]) }
+ end
+
+ context "with a class with config" do
+ let(:input) { "UserResource(view: :extended)" }
+ it { is_expected.to eq([false, "UserResource(view: :extended)"]) }
+ end
+ end
+ end
+
+ describe ".__module_parent" do
+ subject { described_class.__module_parent(klass) }
+
+ context "with one module" do
+ let(:klass) { double(name: "Api::UsersController") }
+
+ it do
+ expect(Object).to receive(:const_get).with("Api")
+ subject
+ end
+ end
+
+ context "with multiple modules" do
+ let(:klass) { double(name: "Api::Internal::V1::UsersController") }
+
+ it do
+ expect(Object).to receive(:const_get).with("Api::Internal::V1")
+ subject
+ end
+ end
+
+ context "with no modules" do
+ let(:klass) { double(name: "UsersController") }
+ it { is_expected.to eq(Object) }
+ end
+ end
+end
diff --git a/spec/openapi/parsers/ext/active_record_spec.rb b/spec/openapi/parsers/ext/active_record_spec.rb
new file mode 100644
index 00000000..c42ad6ba
--- /dev/null
+++ b/spec/openapi/parsers/ext/active_record_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Parsers::Ext::ActiveRecord do
+ include_context "mocked_classes"
+
+ subject { described_class.new.parse(arg) }
+
+ let_class("User")
+ let(:arg) { "User" }
+
+ let(:attributes) { {} }
+ let(:inheritance_column) { nil }
+ let(:enums) { [] }
+
+ before do
+ klass = Object.const_get("User")
+
+ allow(klass).to receive(:attribute_types).and_return(attributes)
+ allow(klass).to receive(:inheritance_column).and_return(inheritance_column)
+ allow(klass).to receive(:defined_enums).and_return(enums)
+ end
+
+ context "with no attributes" do
+ it do
+ is_expected.to eq({ "type" => "object" })
+ end
+
+ context "with collection" do
+ let(:arg) { "[User]" }
+
+ it do
+ is_expected.to eq({ "type" => "array", "items" => { "type" => "object" } })
+ end
+ end
+ end
+
+ context "with attributes" do
+ let(:attributes) { { age: double(type: :integer), admin: double(type: :boolean), comments: double(type: :json) } }
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { :age => { "type" => "integer" }, :admin => { "type" => "boolean" }, :comments => { "type" => "object" } } })
+ end
+
+ context "with collection" do
+ let(:arg) { "[User]" }
+
+ it do
+ is_expected.to eq({ "type" => "array", "items" => { "type" => "object", "properties" => { :age => { "type" => "integer" }, :admin => { "type" => "boolean" }, :comments => { "type" => "object" } } } })
+ end
+ end
+ end
+
+ context "with inheritance column" do
+ let(:attributes) { { age: double(type: :integer), type: double(type: :string) } }
+ let(:inheritance_column) { :type }
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { :age => { "type" => "integer" } } })
+ end
+ end
+
+ context "with enum" do
+ let(:attributes) { { email: double(type: :string) } }
+ let(:enums) { { status: double(keys: %i(active inactive)) } }
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { :email => { "type" => "string" }, :status => { "type" => "string", "enum" => [:active, :inactive] } } })
+ end
+ end
+
+ context "with unknown type" do
+ let(:attributes) { { uuid: double(type: :uuid) } }
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { :uuid => { "type" => "string" } } })
+ end
+ end
+end
diff --git a/spec/openapi/parsers/ext/alba_spec.rb b/spec/openapi/parsers/ext/alba_spec.rb
new file mode 100644
index 00000000..6aca7f80
--- /dev/null
+++ b/spec/openapi/parsers/ext/alba_spec.rb
@@ -0,0 +1,1312 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Parsers::Ext::Alba do
+ include_context "mocked_classes"
+
+ subject { described_class.new(**options).parse(resource) }
+
+ let(:options) { {} }
+ let(:resource) { "UserResource" }
+
+ context "with one attribute" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" } } })
+ end
+ end
+
+ context "with multiple attributes" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name, :email
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" } } })
+ end
+ end
+
+ context "with block attributes" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+
+ attribute :name_with_email do |resource|
+ "#{resource.name}: #{resource.email}"
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "name_with_email" => { "type" => "string" } } })
+ end
+ end
+
+ context "with method attributes" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name, :name_with_email
+
+ def name_with_email(user)
+ "#{user.name}: #{user.email}"
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "name_with_email" => { "type" => "string" } } })
+ end
+ end
+
+ context "with proc shortcuts" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :email
+ attribute :full_name, &:name
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "email" => { "type" => "string" }, "full_name" => { "type" => "string" } } })
+ end
+ end
+
+ context "with conditional attributes" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name, :email, if: proc { |user, attribute| !attribute.nil? }
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" } } })
+ end
+ end
+
+ context "with prefer_object_method!" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ prefer_object_method!
+
+ attributes :id, :email
+
+ def id
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "email" => { "type" => "string" } } })
+ end
+ end
+
+ context "with a root key" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ root_key :user
+ attributes :id, :name, :email
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "user" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" } } } } })
+ end
+
+ context "with an empty resource" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ root_key :user
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "user" => { "type" => "object" } } })
+ end
+ end
+
+ context "with root key for collection" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ root_key :user, :users
+ attributes :id, :name, :email
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "user" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" } } } } })
+ end
+ end
+ end
+
+ context "with collection_key" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ collection_key :id
+ attributes :id, :name, :email
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" } } })
+ end
+ end
+
+ context "with an empty resource" do
+ let_class("UserResource")
+
+ it do
+ is_expected.to eq({ "type" => "object" })
+ end
+ end
+
+ context "with a many association" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ many :articles, resource: ArticleResource
+ RUBY
+ end
+
+ let_class("ArticleResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :title, :content
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "articles" => { "type" => "array", "items" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "content" => { "type" => "string" } } } } } })
+ end
+
+ context "with an inline association" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :email
+
+ many :articles do
+ attributes :title, :content
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "email" => { "type" => "string" }, "articles" => { "type" => "array", "items" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "content" => { "type" => "string" } } } } } })
+ end
+ end
+
+ context "with a proc argument" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ many :articles,
+ proc { |articles, params, user|
+ filter = params[:filter] || :odd?
+ articles.select { |a| a.id.__send__(filter) && !user.banned }
+ },
+ resource: ArticleResource
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "articles" => { "type" => "array", "items" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "content" => { "type" => "string" } } } } } })
+ end
+ end
+
+ context "with a key option" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ many :articles, key: "my_articles", resource: ArticleResource
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "my_articles" => { "type" => "array", "items" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "content" => { "type" => "string" } } } } } })
+ end
+ end
+
+ context "with a proc resource" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ many :articles, resource: ->(article) { article.with_comment? ? ArticleResource : ArticleResource }
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "articles" => { "type" => "array", "items" => { "type" => "object" } } } })
+ end
+ end
+
+ context "with params" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ many :articles, resource: ArticleResource, params: { expose_comments: false }
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "articles" => { "type" => "array", "items" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "content" => { "type" => "string" } } } } } })
+ end
+ end
+
+ context "with multiple associations" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ many :articles, resource: ArticleResource
+ one :avatar, resource: AvatarResource
+ RUBY
+ end
+
+ let_class("AvatarResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :url, :caption
+ RUBY
+ end
+
+ let_class("ArticleResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :title, :body
+ many :comments, resource: CommentResource
+ RUBY
+ end
+
+ let_class("CommentResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :author, :content
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "articles" => { "type" => "array", "items" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "body" => { "type" => "string" }, "comments" => { "type" => "array", "items" => { "type" => "object", "properties" => { "author" => { "type" => "string" }, "content" => { "type" => "string" } } } } } } }, "avatar" => { "type" => "object", "properties" => { "url" => { "type" => "string" }, "caption" => { "type" => "string" } } } } })
+ end
+ end
+
+ context "with the association inside a nested attribute" do
+ let_class("ArticleResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :title, :body
+ RUBY
+ end
+
+ let_class("CommentResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :author, :content
+ RUBY
+ end
+
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+
+ nested_attribute :relationships do
+ many :articles, resource: ArticleResource
+ many :comments, resource: CommentResource
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "relationships" => { "type" => "object", "properties" => { "articles" => { "type" => "array", "items" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "body" => { "type" => "string" } } } }, "comments" => { "type" => "array", "items" => { "type" => "object", "properties" => { "author" => { "type" => "string" }, "content" => { "type" => "string" } } } } } } } })
+ end
+ end
+
+ context "with root_key in the association" do
+ let_class("ArticleResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :title, :content
+ root_key :article, :articles
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "articles" => { "type" => "array", "items" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "content" => { "type" => "string" } } } } } })
+ end
+ end
+
+ context "with root_key and metadata in the association" do
+ let_class("ArticleResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :title, :content
+ root_key :article, :articles
+
+ meta do
+ { created_at: object.created_at }
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "articles" => { "type" => "array", "items" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "content" => { "type" => "string" } } } } } })
+ end
+ end
+ end
+
+ context "with a has_one association" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ has_one :article, resource: ArticleResource
+ RUBY
+ end
+
+ let_class("ArticleResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :title, :body
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "article" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "body" => { "type" => "string" } } } } })
+ end
+
+ context "with a key option" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ has_one :article, key: "my_article", resource: ArticleResource
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "my_article" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "body" => { "type" => "string" } } } } })
+ end
+ end
+
+ context "with a proc resource" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ has_one :article, resource: ->(article) { ArticleResource }
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "article" => { "type" => "object" } } })
+ end
+ end
+ end
+
+ context "with nested attributes" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ root_key :user
+ attributes :id
+
+ nested_attribute :address do
+ attributes :city, :zipcode
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "user" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "address" => { "type" => "object", "properties" => { "city" => { "type" => "string" }, "zipcode" => { "type" => "string" } } } } } } })
+ end
+
+ context "with deep nesting" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ root_key :user
+ attributes :id
+
+ nested :address do
+ nested :geo do
+ attribute :id { 42 }
+ attributes :lat, :lng
+ end
+
+ attributes :city, :zipcode
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "user" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "address" => { "type" => "object", "properties" => { "geo" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "lat" => { "type" => "string" }, "lng" => { "type" => "string" } } }, "city" => { "type" => "string" }, "zipcode" => { "type" => "string" } } } } } } })
+ end
+ end
+ end
+
+ context "with inheritance" do
+ let_class("BaseResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :created_at
+ RUBY
+ end
+
+ let_class("UserResource", parent: mocked_classes.BaseResource) do
+ <<~'RUBY'
+ attributes :name, :email
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "created_at" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" } } })
+ end
+
+ context "with a root key" do
+ let_class("BaseResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ root_key :data
+ attributes :id, :created_at
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "data" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "created_at" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" } } } } })
+ end
+ end
+
+ context "with nested attributes and associations" do
+ let_class("AddressResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :street, :postal_code
+ RUBY
+ end
+
+ let_class("MinimalUserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ root_key :user
+
+ attributes :id, :name, :email
+ one :address, resource: AddressResource
+
+ nested_attribute :avatar do
+ attribute :url
+ end
+ RUBY
+ end
+
+ let_class("UserResource", parent: mocked_classes.MinimalUserResource) do
+ <<~'RUBY'
+ many :comments do
+ attribute :content, :created_at
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "user" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" }, "address" => { "type" => "object", "properties" => { "street" => { "type" => "string" }, "postal_code" => { "type" => "string" } } }, "avatar" => { "type" => "object", "properties" => { "url" => { "type" => "string" } } }, "comments" => { "type" => "array", "items" => { "type" => "object", "properties" => { "content" => { "type" => "string" }, "created_at" => { "type" => "string" } } } } } } } })
+ end
+ end
+
+ context "with metadata" do
+ let_class("BaseResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :created_at
+ root_key :data
+
+ meta do
+ { session_id: Current.session_id }
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "data" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "created_at" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" } } }, "meta" => { "type" => "object", "properties" => { "session_id" => { "type" => "string" } } } } })
+ end
+ end
+ end
+
+ context "with key transformation" do
+ before do
+ stub_const("Alba", double(inflector: double))
+ allow(Alba.inflector).to receive(:camelize) { |str| str.gsub("_", "+").capitalize }
+ end
+
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :first_name, :last_name
+ transform_keys :camel
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "Id" => { "type" => "string" }, "First+name" => { "type" => "string" }, "Last+name" => { "type" => "string" } } })
+ end
+
+ context "with associations" do
+ let_class("CommentResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :content, :is_edited
+ transform_keys :camel
+ RUBY
+ end
+
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :first_name, :last_name
+ many :comments, resource: CommentResource
+ transform_keys :camel
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "Id" => { "type" => "string" }, "First+name" => { "type" => "string" }, "Last+name" => { "type" => "string" }, "Comments" => { "type" => "array", "items" => { "type" => "object", "properties" => { "Content" => { "type" => "string" }, "Is+edited" => { "type" => "string" } } } } } })
+ end
+ end
+
+ context "with collection_key" do
+ let(:resource) { "[UserResource]" }
+
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :first_name, :last_name
+ transform_keys :camel
+ collection_key :id
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "additionalProperties" => { "type" => "object", "properties" => { "Id" => { "type" => "string" }, "First+name" => { "type" => "string" }, "Last+name" => { "type" => "string" } } } })
+ end
+ end
+
+ context "with inheritance" do
+ let_class("BaseResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id
+ transform_keys :camel
+ RUBY
+ end
+
+ let_class("UserResource", parent: mocked_classes.BaseResource) do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :first_name, :last_name
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "Id" => { "type" => "string" }, "First+name" => { "type" => "string" }, "Last+name" => { "type" => "string" } } })
+ end
+
+ context "with disabled transformation" do
+ let_class("BaseResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id
+ transform_keys :camel
+ RUBY
+ end
+
+ let_class("UserResource", parent: mocked_classes.BaseResource) do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :first_name, :last_name
+ transform_keys :none
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "first_name" => { "type" => "string" }, "last_name" => { "type" => "string" } } })
+ end
+ end
+ end
+
+ context "with no inflector" do
+ before do
+ stub_const("Alba", double(inflector: nil))
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "first_name" => { "type" => "string" }, "last_name" => { "type" => "string" } } })
+ end
+ end
+ end
+
+ context "with metadata" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ root_key :user
+
+ meta do
+ if object.is_a?(Enumerable)
+ { size: object.size }
+ else
+ { foo: :bar }
+ end
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "user" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" } } }, "meta" => { "type" => "object", "properties" => { "size" => { "type" => "string" } } } } })
+ end
+
+ context "with a custom key" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ root_key :user
+
+ meta :my_meta do
+ { created_at: object.created_at }
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "user" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" } } }, "my_meta" => { "type" => "object", "properties" => { "created_at" => { "type" => "string" } } } } })
+ end
+ end
+
+ context "with a custom key and no data" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ root_key :user
+ meta :my_meta
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "user" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" } } }, "my_meta" => { "type" => "object" } } })
+ end
+ end
+
+ context "with no key" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ root_key :user
+ meta nil
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "user" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" } } } } })
+ end
+ end
+
+ context "with custom class" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ root_key :user
+
+ meta do
+ MetaService.new(object).call
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "user" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" } } }, "meta" => { "type" => "object" } } })
+ end
+ end
+
+ context "with no root key" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+
+ meta :my_meta do
+ { created_at: object.created_at }
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" } } })
+ end
+ end
+
+ context "with no root key and collection" do
+ let(:resource) { "[UserResource]" }
+
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+
+ meta :my_meta do
+ { created_at: object.created_at }
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "array", "items" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" } } } })
+ end
+
+ context "with collection_key" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ collection_key :id
+
+ meta :my_meta do
+ { created_at: object.created_at }
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "additionalProperties" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" } } } })
+ end
+ end
+ end
+ end
+
+ context "with types" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :name, id: [String, true], age: [Integer, true], bio: String, admin: [:Boolean, true], salary: Float, created_at: [String, ->(object) { object.strftime('%F') }]
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "name" => { "type" => "string" }, "id" => { "type" => "string" }, "age" => { "type" => "integer" }, "bio" => { "type" => "string" }, "admin" => { "type" => "boolean" }, "salary" => { "type" => "number", "format" => "float" }, "created_at" => { "type" => "string" } } })
+ end
+
+ context "with nested attributes" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+
+ nested_attribute :data do
+ attributes :name, id: Integer, admin: :Boolean, salary: Float
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "data" => { "type" => "object", "properties" => { "name" => { "type" => "string" }, "id" => { "type" => "integer" }, "admin" => { "type" => "boolean" }, "salary" => { "type" => "number", "format" => "float" } } } } })
+ end
+ end
+ end
+
+ context "with automatic resource inference" do
+ before do
+ stub_const("Alba", double(inflector: double))
+ allow(Alba.inflector).to receive(:classify) do |str|
+ case str
+ when "articles"
+ "Article"
+ when "avatar"
+ "Avatar"
+ end
+ end
+ end
+
+ let_class("ArticleResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :title, :body
+ RUBY
+ end
+
+ let_class("AvatarResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :url
+ RUBY
+ end
+
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ many :articles
+ one :avatar
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "articles" => { "type" => "array", "items" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "body" => { "type" => "string" } } } }, "avatar" => { "type" => "object", "properties" => { "url" => { "type" => "string" } } } } })
+ end
+
+ context "with no inflector" do
+ before do
+ stub_const("Alba", double(inflector: nil))
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "articles" => { "type" => "array", "items" => { "type" => "object" } }, "avatar" => { "type" => "object" } } })
+ end
+ end
+ end
+
+ context "with collection" do
+ let(:resource) { "[UserResource]" }
+
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name, :email
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "array", "items" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" } } } })
+ end
+
+ context "with a has_many association" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ has_many :articles, resource: ArticleResource
+ RUBY
+ end
+
+ let_class("ArticleResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :title, :content
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "array", "items" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "articles" => { "type" => "array", "items" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "content" => { "type" => "string" } } } } } } })
+ end
+ end
+
+ context "with a has_one association" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ has_one :article, resource: ArticleResource
+ RUBY
+ end
+
+ let_class("ArticleResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :title, :content
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "array", "items" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "article" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "content" => { "type" => "string" } } } } } })
+ end
+ end
+
+ context "with inline has_many association" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+
+ has_many :articles do
+ attributes :title, :content
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "array", "items" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "articles" => { "type" => "array", "items" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "content" => { "type" => "string" } } } } } } })
+ end
+ end
+
+ context "with inline has_one association" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+
+ has_one :article do
+ attributes :title, :content
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "array", "items" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "article" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "content" => { "type" => "string" } } } } } })
+ end
+ end
+
+ context "with root key" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name, :email
+ root_key :user
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "array", "items" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" } } } })
+ end
+
+ context "with metadata" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name, :email
+ root_key :user
+
+ meta do
+ { session_id: Current.session_id }
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "array", "items" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" } } } })
+ end
+ end
+ end
+
+ context "with root key for collection" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name, :email
+ root_key :user, :all_users
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "all_users" => { "type" => "array", "items" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" } } } } } })
+ end
+
+ context "with metadata" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name, :email
+ root_key :user, :all_users
+
+ meta do
+ { session_id: Current.session_id }
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "all_users" => { "type" => "array", "items" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" } } } }, "meta" => { "type" => "object", "properties" => { "session_id" => { "type" => "string" } } } } })
+ end
+ end
+ end
+
+ context "with root key for collection and collection_key" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name, :email
+ root_key :user, :all_users
+ collection_key :id
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "all_users" => { "type" => "object", "additionalProperties" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" } } } } } })
+ end
+
+ context "with metadata" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name, :email
+ root_key :user, :all_users
+ collection_key :id
+
+ meta do
+ { session_id: Current.session_id }
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "all_users" => { "type" => "object", "additionalProperties" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" } } } }, "meta" => { "type" => "object", "properties" => { "session_id" => { "type" => "string" } } } } })
+ end
+ end
+ end
+ end
+
+ context "with collection_key" do
+ let(:resource) { "[UserResource]" }
+
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ collection_key :id
+ attributes :id, :name, :email
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "additionalProperties" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "email" => { "type" => "string" } } } })
+ end
+
+ context "with a has_many association" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ collection_key :id
+
+ attributes :id, :name
+ has_many :articles, resource: ArticleResource
+ RUBY
+ end
+
+ let_class("ArticleResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :title, :content
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "additionalProperties" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "articles" => { "type" => "array", "items" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "content" => { "type" => "string" } } } } } } })
+ end
+
+ context "with collection_key in the association" do
+ let_class("ArticleResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ collection_key :title
+ attributes :title, :content
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "additionalProperties" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "articles" => { "type" => "object", "additionalProperties" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "content" => { "type" => "string" } } } } } } })
+ end
+ end
+
+ context "with collection_key in parent" do
+ let_class("BaseResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ collection_key :id
+ RUBY
+ end
+
+ let_class("UserResource", parent: mocked_classes.BaseResource) do
+ <<~'RUBY'
+ attributes :id, :name
+ has_many :articles, resource: ArticleResource
+ RUBY
+ end
+
+ let_class("ArticleResource", parent: mocked_classes.BaseResource) do
+ <<~'RUBY'
+ attributes :title, :content
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "additionalProperties" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "articles" => { "type" => "object", "additionalProperties" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "content" => { "type" => "string" } } } } } } })
+ end
+ end
+ end
+
+ context "with a has_one association" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ collection_key :id
+
+ attributes :id, :name
+ has_one :article, resource: ArticleResource
+ RUBY
+ end
+
+ let_class("ArticleResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :title, :content
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "additionalProperties" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "article" => { "type" => "object", "properties" => { "title" => { "type" => "string" }, "content" => { "type" => "string" } } } } } })
+ end
+ end
+
+ xcontext "with an inline has_many association" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+
+ has_one :articles do
+ collection_key :title
+ attributes :title, :content
+ end
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({})
+ end
+ end
+
+ context "with an empty resource" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ collection_key :id
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "additionalProperties" => { "type" => "object" } })
+ end
+ end
+ end
+
+ context "with root_key!" do
+ before do
+ stub_const("Alba", double(inflector: double))
+ allow(Alba.inflector).to receive(:demodulize) { |str| str.delete_prefix("Api::V1") }
+ allow(Alba.inflector).to receive(:underscore) { |str| str.downcase }
+ allow(Alba.inflector).to receive(:pluralize) { |str| "#{str}s" }
+ end
+
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ root_key!
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "user" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" } } } } })
+ end
+
+ context "with namespace" do
+ let(:resource) { "Api::V1::UserResource" }
+
+ let_class("Api::V1::UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ root_key!
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "user" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" } } } } })
+ end
+ end
+
+ context "with collection" do
+ let(:resource) { "[UserResource]" }
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "users" => { "type" => "array", "items" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" } } } } } })
+ end
+
+ context "with collection_key" do
+ let_class("UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ collection_key :id
+ root_key!
+ RUBY
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "users" => { "type" => "object", "additionalProperties" => { "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" } } } } } })
+ end
+ end
+ end
+ end
+
+ context "with namespaced associations" do
+ let(:resource) { "Api::V3::UserResource" }
+ let(:namespace) { double }
+ let(:options) { { namespace: } }
+
+ before do
+ stub_const("Alba", double(inflector: double))
+ allow(Alba.inflector).to receive(:classify).with("articles").and_return("Article")
+ end
+
+ let_class("ArticleResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :title, :content
+ RUBY
+ end
+
+ let_class("Api::V3::ArticleResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :v3_title, :v3_content
+ RUBY
+ end
+
+ let_class("Api::V3::UserResource") do
+ <<~'RUBY'
+ include Alba::Resource
+ attributes :id, :name
+ has_many :articles
+ RUBY
+ end
+
+ it do
+ allow(namespace).to receive(:const_get).with("Api::V3::UserResource").and_return(Object.const_get("Api::V3::UserResource"))
+ allow(namespace).to receive(:const_get).with("ArticleResource").and_return(Object.const_get("Api::V3::ArticleResource"))
+
+ is_expected.to eq({ "type" => "object", "properties" => { "id" => { "type" => "string" }, "name" => { "type" => "string" }, "articles" => { "type" => "array", "items" => { "type" => "object", "properties" => { "v3_title" => { "type" => "string" }, "v3_content" => { "type" => "string" } } } } } })
+ end
+ end
+end
diff --git a/spec/openapi/parsers/request_spec.rb b/spec/openapi/parsers/request_spec.rb
new file mode 100644
index 00000000..7eefe12b
--- /dev/null
+++ b/spec/openapi/parsers/request_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Parsers::Request do
+ subject { described_class.parse(tag, namespace:) }
+
+ let(:tag) { "test_tag" }
+ let(:namespace) { double }
+
+ before do
+ described_class::AVAILABLE_PARSERS.each do |parser_class|
+ parser = double
+ allow(parser_class).to receive(:new).with(namespace:).and_return(parser)
+ allow(parser).to receive(:known_definition?).and_return(false)
+ end
+ end
+
+ context "with no matching parsers" do
+ it { is_expected.to be_nil }
+ end
+
+ context "with a matching parser" do
+ let(:parser) { double }
+
+ before do
+ allow(Rage::OpenAPI::Parsers::YAML).to receive(:new).with(namespace:).and_return(parser)
+ allow(parser).to receive(:known_definition?).and_return(true)
+ end
+
+ it do
+ expect(parser).to receive(:parse).and_return("test_parse_result")
+ expect(subject).to eq("test_parse_result")
+ end
+ end
+end
diff --git a/spec/openapi/parsers/response_spec.rb b/spec/openapi/parsers/response_spec.rb
new file mode 100644
index 00000000..2bc5b258
--- /dev/null
+++ b/spec/openapi/parsers/response_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Parsers::Response do
+ subject { described_class.parse(tag, namespace:) }
+
+ let(:tag) { "test_tag" }
+ let(:namespace) { double }
+
+ before do
+ described_class::AVAILABLE_PARSERS.each do |parser_class|
+ parser = double
+ allow(parser_class).to receive(:new).with(namespace:).and_return(parser)
+ allow(parser).to receive(:known_definition?).and_return(false)
+ end
+ end
+
+ context "with no matching parsers" do
+ it { is_expected.to be_nil }
+ end
+
+ context "with a matching parser" do
+ let(:parser) { double }
+
+ before do
+ allow(Rage::OpenAPI::Parsers::Ext::ActiveRecord).to receive(:new).with(namespace:).and_return(parser)
+ allow(parser).to receive(:known_definition?).and_return(true)
+ end
+
+ it do
+ expect(parser).to receive(:parse).and_return("test_parse_result")
+ expect(subject).to eq("test_parse_result")
+ end
+ end
+end
diff --git a/spec/openapi/parsers/shared_reference_spec.rb b/spec/openapi/parsers/shared_reference_spec.rb
new file mode 100644
index 00000000..4b7ee173
--- /dev/null
+++ b/spec/openapi/parsers/shared_reference_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Parsers::SharedReference do
+ subject { described_class.new.parse(ref) }
+
+ let(:config) do
+ YAML.safe_load <<~YAML
+ components:
+ schemas:
+ User:
+ type: object
+ Error:
+ type: object
+ parameters:
+ offsetParam:
+ name: offset
+ responses:
+ 404NotFound:
+ description: The specified resource was not found.
+ ImageResponse:
+ description: An image.
+ YAML
+ end
+
+ before do
+ allow(Rage::OpenAPI).to receive(:__shared_components).and_return(config)
+ end
+
+ context "with schema" do
+ context "with User" do
+ let(:ref) { "#/components/schemas/User" }
+ it { is_expected.to eq({ "$ref" => ref }) }
+ end
+
+ context "with Error" do
+ let(:ref) { "#/components/schemas/Error" }
+ it { is_expected.to eq({ "$ref" => ref }) }
+ end
+
+ context "with invalid key" do
+ let(:ref) { "#/components/schemas/Ok" }
+ it { is_expected.to be_nil }
+ end
+ end
+
+ context "with parameters" do
+ context "with offsetParam" do
+ let(:ref) { "#/components/parameters/offsetParam" }
+ it { is_expected.to eq({ "$ref" => ref }) }
+ end
+
+ context "with invalid key" do
+ let(:ref) { "#/components/parameters/pageParam" }
+ it { is_expected.to be_nil }
+ end
+ end
+
+ context "with responses" do
+ context "with 404NotFound" do
+ let(:ref) { "#/components/responses/404NotFound" }
+ it { is_expected.to eq({ "$ref" => ref }) }
+ end
+
+ context "with ImageResponse" do
+ let(:ref) { "#/components/responses/ImageResponse" }
+ it { is_expected.to eq({ "$ref" => ref }) }
+ end
+
+ context "with invalid key" do
+ let(:ref) { "#/components/responses/GenericError" }
+ it { is_expected.to be_nil }
+ end
+ end
+
+ context "with invalid component" do
+ let(:ref) { "#/components/model/User" }
+ it { is_expected.to be_nil }
+ end
+
+ context "with no components" do
+ let(:config) { {} }
+ let(:ref) { "#/components/schemas/User" }
+
+ it { is_expected.to be_nil }
+ end
+end
diff --git a/spec/openapi/parsers/yaml_spec.rb b/spec/openapi/parsers/yaml_spec.rb
new file mode 100644
index 00000000..b37a7961
--- /dev/null
+++ b/spec/openapi/parsers/yaml_spec.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+
+require "prism"
+
+RSpec.describe Rage::OpenAPI::Parsers::YAML do
+ describe "#known_definition?" do
+ subject { described_class.new.known_definition?(yaml) }
+
+ context "with object" do
+ let(:yaml) do
+ "{ status: 'not_found', message: 'Resource Not Found' }"
+ end
+
+ it { is_expected.to be(true) }
+
+ context "with an array key" do
+ let(:yaml) do
+ "{ users: [Hash] }"
+ end
+
+ it { is_expected.to be(true) }
+ end
+ end
+
+ context "with array" do
+ context "with one element" do
+ let(:yaml) do
+ "[String]"
+ end
+
+ it { is_expected.to be(true) }
+ end
+
+ context "with multiple elements" do
+ let(:yaml) do
+ "[red, green, blue]"
+ end
+
+ it { is_expected.to be(true) }
+ end
+ end
+
+ context "with invalid yaml" do
+ let(:yaml) do
+ "[String"
+ end
+
+ it { is_expected.to be(false) }
+ end
+
+ context "with non-enumerable" do
+ let(:yaml) do
+ "Hello"
+ end
+
+ it { is_expected.to be(false) }
+ end
+ end
+
+ describe ".parse" do
+ subject { described_class.new.parse(yaml) }
+
+ context "with scalar values" do
+ let(:yaml) do
+ "{ is_error: true, status: 'not_found', code: 404, message: 'Resource Not Found' }"
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "is_error" => { "type" => "boolean" }, "status" => { "type" => "string" }, "code" => { "type" => "integer" }, "message" => { "type" => "string" } } })
+ end
+ end
+
+ context "with class values" do
+ let(:yaml) do
+ "{ is_error: Boolean, status: String, code: Integer, message: String }"
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "is_error" => { "type" => "boolean" }, "status" => { "type" => "string" }, "code" => { "type" => "integer" }, "message" => { "type" => "string" } } })
+ end
+ end
+
+ context "with unary array" do
+ context "with String" do
+ let(:yaml) do
+ "{ roles: [String] }"
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "roles" => { "type" => "array", "items" => { "type" => "string" } } } })
+ end
+ end
+
+ context "with Integer" do
+ let(:yaml) do
+ "{ ids: [Integer] }"
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "ids" => { "type" => "array", "items" => { "type" => "integer" } } } })
+ end
+ end
+
+ context "with Hash" do
+ let(:yaml) do
+ "{ users: [Hash] }"
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "users" => { "type" => "array", "items" => { "type" => "object" } } } })
+ end
+ end
+ end
+
+ context "with non-unary array" do
+ let(:yaml) do
+ "{ colors: [red, green, blue] }"
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "colors" => { "type" => "string", "enum" => ["red", "green", "blue"] } } })
+ end
+ end
+
+ context "with objects inside array" do
+ let(:yaml) do
+ "{ users: [{ id: Integer, name: String, is_active: Boolean, comments: [{ id: Integer, content: String }] }] }"
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "users" => { "type" => "array", "items" => { "type" => "object", "properties" => { "id" => { "type" => "integer" }, "name" => { "type" => "string" }, "is_active" => { "type" => "boolean" }, "comments" => { "type" => "array", "items" => { "type" => "object", "properties" => { "id" => { "type" => "integer" }, "content" => { "type" => "string" } } } } } } } } })
+ end
+ end
+
+ context "with arrays inside arrays" do
+ let(:yaml) do
+ "{ users: [{ id: Integer, data: { comments: [{ status: [is_active, is_edited, is_deleted] }], friend_names: [String] } }] }"
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "users" => { "type" => "array", "items" => { "type" => "object", "properties" => { "id" => { "type" => "integer" }, "data" => { "type" => "object", "properties" => { "comments" => { "type" => "array", "items" => { "type" => "object", "properties" => { "status" => { "type" => "string", "enum" => ["is_active", "is_edited", "is_deleted"] } } } }, "friend_names" => { "type" => "array", "items" => { "type" => "string" } } } } } } } } })
+ end
+ end
+
+ context "with objects inside objects" do
+ let(:yaml) do
+ "{ user: { id: Integer, avatar: { url: String, width: Integer, height: Integer }, geo: { lat: Float, lng: Float } } }"
+ end
+
+ it do
+ is_expected.to eq({ "type" => "object", "properties" => { "user" => { "type" => "object", "properties" => { "id" => { "type" => "integer" }, "avatar" => { "type" => "object", "properties" => { "url" => { "type" => "string" }, "width" => { "type" => "integer" }, "height" => { "type" => "integer" } } }, "geo" => { "type" => "object", "properties" => { "lat" => { "type" => "number", "format" => "float" }, "lng" => { "type" => "number", "format" => "float" } } } } } } })
+ end
+ end
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 1816bd07..6224c61c 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -6,6 +6,8 @@
require_relative "support/controller_helper"
require_relative "support/reactor_helper"
require_relative "support/websocket_helper"
+require_relative "support/contexts/mocked_classes"
+require_relative "support/contexts/mocked_rage_routes"
RSpec.configure do |config|
# Enable flags like --only-failures and --next-failure
@@ -27,4 +29,7 @@
config.include ControllerHelper
config.include ReactorHelper
config.include WebSocketHelper
+
+ config.include_context "mocked_classes", include_shared: true
+ config.include_context "mocked_rage_routes", include_shared: true
end
diff --git a/spec/support/contexts/mocked_classes.rb b/spec/support/contexts/mocked_classes.rb
new file mode 100644
index 00000000..cb80288e
--- /dev/null
+++ b/spec/support/contexts/mocked_classes.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require "ostruct"
+
+RSpec.shared_context "mocked_classes" do
+ before do
+ allow(Object).to receive(:const_get).and_call_original
+ allow(Object).to receive(:const_source_location).and_call_original
+ end
+
+ def self.mocked_classes
+ @mocked_classes ||= OpenStruct.new
+ end
+
+ def self.let_class(class_name, parent: Object, &block)
+ source = Tempfile.new.tap do |f|
+ if block
+ f.write <<~RUBY
+ class #{class_name} #{"< #{parent.name}" if parent != Object}
+ #{block.call}
+ end
+ RUBY
+ end
+ f.close
+ end
+
+ klass = Class.new(parent, &block)
+ klass.define_singleton_method(:name) { class_name }
+
+ mocked_classes[class_name] = klass
+
+ before do
+ allow(Object).to receive(:const_get).with(satisfy { |c| c.to_s == class_name.to_s }).and_return(klass)
+ allow(Object).to receive(:const_source_location).with(class_name).and_return(source.path)
+ end
+ end
+end
diff --git a/spec/support/contexts/mocked_rage_routes.rb b/spec/support/contexts/mocked_rage_routes.rb
new file mode 100644
index 00000000..26c7cd9b
--- /dev/null
+++ b/spec/support/contexts/mocked_rage_routes.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+RSpec.shared_context "mocked_rage_routes" do
+ before do
+ allow(Rage.__router).to receive(:routes) do
+ routes.map do |method_path_component, controller_action_component|
+ method, path = method_path_component.split(" ", 2)
+ controller, action = controller_action_component.split("#", 2)
+
+ {
+ method:,
+ path:,
+ meta: { controller_class: Object.const_get(controller), action: }
+ }
+ end
+ end
+ end
+end