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..e52d62ba --- /dev/null +++ b/lib/rage/openapi/parsers/ext/active_record.rb @@ -0,0 +1,62 @@ +# 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 + + 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..91702246 --- /dev/null +++ b/lib/rage/openapi/parsers/shared_reference.rb @@ -0,0 +1,25 @@ +# 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) + 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..10b3e476 --- /dev/null +++ b/lib/rage/openapi/parsers/yaml.rb @@ -0,0 +1,66 @@ +# 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) + __parse(YAML.safe_load(yaml)) + end + + private + + def __parse(object) + 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" + { "type" => "integer" } + when "Float" + { "type" => "number", "format" => "float" } + when "Numeric" + { "type" => "number" } + when "Boolean" + { "type" => "boolean" } + when "Hash" + { "type" => "object" } + when "Date" + { "type" => "string", "format" => "date" } + when "DateTime", "Time" + { "type" => "string", "format" => "date-time" } + when "String" + { "type" => "string" } + else + { "type" => "string", "enum" => [type] } + 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..eba72cdb --- /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" => "string", "enum" => [true] }, "status" => { "type" => "string", "enum" => ["not_found"] }, "code" => { "type" => "string", "enum" => [404] }, "message" => { "type" => "string", "enum" => ["Resource Not Found"] } } }) + 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 diff --git a/spec/support/integration_helper.rb b/spec/support/integration_helper.rb index 1ac12d55..c790c2dc 100644 --- a/spec/support/integration_helper.rb +++ b/spec/support/integration_helper.rb @@ -3,7 +3,8 @@ module IntegrationHelper def launch_server(env: {}) Bundler.with_unbundled_env do - system("gem build -o rage-local.gem && gem install rage-local.gem --no-document && bundle install") + system("gem build -o rage-local.gem && gem install rage-local.gem --no-document") + system("bundle install", chdir: "spec/integration/test_app") @pid = spawn(env, "bundle exec rage s", chdir: "spec/integration/test_app") sleep(1) end