From 14384de04784afcd3b3182e007f1b57eba5e171b Mon Sep 17 00:00:00 2001 From: Oleksandr Rohachev Date: Mon, 28 Jul 2025 15:26:54 +0300 Subject: [PATCH 1/9] Added thor task that builds openapi spec and returns errors if any --- lib/rage/cli.rb | 15 +++++++++++++++ lib/rage/openapi/openapi.rb | 12 ++++++++++++ 2 files changed, 27 insertions(+) diff --git a/lib/rage/cli.rb b/lib/rage/cli.rb index 9bc60356..6d640ebb 100644 --- a/lib/rage/cli.rb +++ b/lib/rage/cli.rb @@ -212,6 +212,21 @@ def tasks end end + desc "openapi:validate", "Validate the API specification." + map "openapi:validate" => :openapi_validate + def openapi_validate + set_env(options) + environment + Rage::OpenAPI.build + + if Rage::OpenAPI.__warnings.any? + puts "OpenAPI validation failed. Warnings: #{Rage::OpenAPI.__warnings}" + exit 1 + else + puts "OpenAPI validation passed without warnings." + end + end + def method_missing(method_name, *, &) set_env({}) diff --git a/lib/rage/openapi/openapi.rb b/lib/rage/openapi/openapi.rb index 2e889ccb..f3196ee3 100644 --- a/lib/rage/openapi/openapi.rb +++ b/lib/rage/openapi/openapi.rb @@ -80,6 +80,7 @@ def self.application(namespace: nil) # @param namespace [String, Module] limit the parser to a specific namespace # @return [Hash] def self.build(namespace: nil) + __reset_warnings Builder.new(namespace:).run end @@ -156,9 +157,20 @@ def self.__type_to_spec(type, default: false) # @private def self.__log_warn(log) + __warnings << log puts "[OpenAPI] WARNING: #{log}" end + # @private + def self.__warnings + @__warnings ||= [] + end + + # @private + def self.__reset_warnings + @__warnings = [] + end + module Nodes end From e62337b55c4045cc1a0dbbea1972ef9367171694 Mon Sep 17 00:00:00 2001 From: Oleksandr Rohachev Date: Thu, 7 Aug 2025 14:49:56 +0300 Subject: [PATCH 2/9] Moved openapi validation task from cli to tasks.rb --- lib/rage/cli.rb | 15 --------------- lib/rage/tasks.rb | 5 +++++ lib/rage/tasks/openapi.rake | 12 ++++++++++++ 3 files changed, 17 insertions(+), 15 deletions(-) create mode 100644 lib/rage/tasks/openapi.rake diff --git a/lib/rage/cli.rb b/lib/rage/cli.rb index 6d640ebb..9bc60356 100644 --- a/lib/rage/cli.rb +++ b/lib/rage/cli.rb @@ -212,21 +212,6 @@ def tasks end end - desc "openapi:validate", "Validate the API specification." - map "openapi:validate" => :openapi_validate - def openapi_validate - set_env(options) - environment - Rage::OpenAPI.build - - if Rage::OpenAPI.__warnings.any? - puts "OpenAPI validation failed. Warnings: #{Rage::OpenAPI.__warnings}" - exit 1 - else - puts "OpenAPI validation passed without warnings." - end - end - def method_missing(method_name, *, &) set_env({}) diff --git a/lib/rage/tasks.rb b/lib/rage/tasks.rb index 95e94293..a3adf783 100644 --- a/lib/rage/tasks.rb +++ b/lib/rage/tasks.rb @@ -7,6 +7,7 @@ class Rage::Tasks class << self def init load_db_tasks if defined?(StandaloneMigrations) + load_rage_tasks load_app_tasks end @@ -34,5 +35,9 @@ def configuration_file def load_app_tasks Dir[Rage.root.join("lib/tasks/**/*.rake")].each { |file| load file } end + + def load_rage_tasks + Dir[File.expand_path("tasks/**/*.rake", __dir__)].each { |file| load file } + end end end diff --git a/lib/rage/tasks/openapi.rake b/lib/rage/tasks/openapi.rake new file mode 100644 index 00000000..aef97707 --- /dev/null +++ b/lib/rage/tasks/openapi.rake @@ -0,0 +1,12 @@ +namespace :openapi do + task :validate do + Rage::OpenAPI.build + + if Rage::OpenAPI.__warnings.any? + puts "OpenAPI validation failed. Warnings: #{Rage::OpenAPI.__warnings}" + exit 1 + else + puts "OpenAPI validation passed without warnings." + end + end +end From f49f55114d30f6a1b19eda16f8383a83c2586bc5 Mon Sep 17 00:00:00 2001 From: Oleksandr Rohachev Date: Thu, 7 Aug 2025 15:02:01 +0300 Subject: [PATCH 3/9] Use Rage.openapi instead of Rage::OpeAPI --- lib/rage/tasks/openapi.rake | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/rage/tasks/openapi.rake b/lib/rage/tasks/openapi.rake index aef97707..c2ff8abf 100644 --- a/lib/rage/tasks/openapi.rake +++ b/lib/rage/tasks/openapi.rake @@ -1,9 +1,9 @@ namespace :openapi do task :validate do - Rage::OpenAPI.build + Rage.openapi.build - if Rage::OpenAPI.__warnings.any? - puts "OpenAPI validation failed. Warnings: #{Rage::OpenAPI.__warnings}" + if Rage.openapi.__warnings.any? + puts "OpenAPI validation failed. Warnings: #{Rage.openapi.__warnings}" exit 1 else puts "OpenAPI validation passed without warnings." From f5314fa9ed68f84617dff59e31adc2f8a5ed4506 Mon Sep 17 00:00:00 2001 From: Oleksandr Rohachev Date: Mon, 11 Aug 2025 12:04:15 +0300 Subject: [PATCH 4/9] Removed unnecessary errors reset --- lib/rage/openapi/openapi.rb | 6 ------ lib/rage/tasks/openapi.rake | 6 +++--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/lib/rage/openapi/openapi.rb b/lib/rage/openapi/openapi.rb index f3196ee3..a9484ec5 100644 --- a/lib/rage/openapi/openapi.rb +++ b/lib/rage/openapi/openapi.rb @@ -80,7 +80,6 @@ def self.application(namespace: nil) # @param namespace [String, Module] limit the parser to a specific namespace # @return [Hash] def self.build(namespace: nil) - __reset_warnings Builder.new(namespace:).run end @@ -166,11 +165,6 @@ def self.__warnings @__warnings ||= [] end - # @private - def self.__reset_warnings - @__warnings = [] - end - module Nodes end diff --git a/lib/rage/tasks/openapi.rake b/lib/rage/tasks/openapi.rake index c2ff8abf..aef97707 100644 --- a/lib/rage/tasks/openapi.rake +++ b/lib/rage/tasks/openapi.rake @@ -1,9 +1,9 @@ namespace :openapi do task :validate do - Rage.openapi.build + Rage::OpenAPI.build - if Rage.openapi.__warnings.any? - puts "OpenAPI validation failed. Warnings: #{Rage.openapi.__warnings}" + if Rage::OpenAPI.__warnings.any? + puts "OpenAPI validation failed. Warnings: #{Rage::OpenAPI.__warnings}" exit 1 else puts "OpenAPI validation passed without warnings." From 74ecb3061048dff5ce8c18657d68da2624f9a530 Mon Sep 17 00:00:00 2001 From: Oleksandr Rohachev Date: Wed, 5 Aug 2026 17:41:40 +0300 Subject: [PATCH 5/9] Tests for the OpenAPI validator task --- spec/openapi/openapi_spec.rb | 29 ++++++++++++++ spec/rage/tasks_spec.rb | 78 ++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 spec/rage/tasks_spec.rb diff --git a/spec/openapi/openapi_spec.rb b/spec/openapi/openapi_spec.rb index e71187fa..35edb276 100644 --- a/spec/openapi/openapi_spec.rb +++ b/spec/openapi/openapi_spec.rb @@ -249,6 +249,35 @@ end end + describe ".__log_warn" do + before do + described_class.instance_variable_set(:@__warnings, nil) + end + + it "prints a warning and records it in __warnings" do + expect { described_class.__log_warn("something went wrong") } + .to output("[OpenAPI] WARNING: something went wrong\n").to_stdout + + expect(described_class.__warnings).to eq(["something went wrong"]) + end + + it "accumulates multiple warnings" do + allow(described_class).to receive(:puts) + + described_class.__log_warn("first") + described_class.__log_warn("second") + + expect(described_class.__warnings).to eq(["first", "second"]) + end + end + + describe ".__warnings" do + it "returns an empty array by default" do + described_class.instance_variable_set(:@__warnings, nil) + expect(described_class.__warnings).to eq([]) + end + end + describe ".__type_to_spec" do subject { described_class.__type_to_spec(type) } diff --git a/spec/rage/tasks_spec.rb b/spec/rage/tasks_spec.rb new file mode 100644 index 00000000..f8f443d3 --- /dev/null +++ b/spec/rage/tasks_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rake" +require "rage/tasks" + +RSpec.describe Rage::Tasks do + describe ".load_rage_tasks" do + before do + Rake.application = Rake::Application.new + end + + it "loads the openapi:validate task" do + expect(Rake::Task.task_defined?("openapi:validate")).to eq(false) + + described_class.send(:load_rage_tasks) + + expect(Rake::Task.task_defined?("openapi:validate")).to eq(true) + end + end +end + +RSpec.describe "openapi:validate" do + before do + Rake.application = Rake::Application.new + Rage::Tasks.send(:load_rage_tasks) + Rage::OpenAPI.instance_variable_set(:@__warnings, []) + allow(Rage::OpenAPI).to receive(:build) + end + + after do + Rage::OpenAPI.instance_variable_set(:@__warnings, nil) + end + + subject { Rake::Task["openapi:validate"].invoke } + + it "builds the OpenAPI spec" do + allow($stdout).to receive(:puts) + + expect(Rage::OpenAPI).to receive(:build) + + subject + end + + context "when there are no warnings" do + it "prints a success message" do + expect { subject }.to output(/OpenAPI validation passed without warnings\./).to_stdout + end + + it "does not exit with an error" do + allow($stdout).to receive(:puts) + + expect { subject }.not_to raise_error + end + end + + context "when there are warnings" do + before do + Rage::OpenAPI.__warnings << "unrecognized tag" + end + + it "prints a failure message including the warnings" do + expect { + begin + subject + rescue SystemExit + end + }.to output(/OpenAPI validation failed\. Warnings:.*unrecognized tag/).to_stdout + end + + it "exits with status 1" do + allow($stdout).to receive(:puts) + + expect { subject }.to raise_error(SystemExit) { |error| + expect(error.status).to eq(1) + } + end + end +end From 605c59553bde4b3d6ef1ffd2c5303429602cab30 Mon Sep 17 00:00:00 2001 From: Oleksandr Rohachev Date: Mon, 10 Aug 2026 15:22:50 +0300 Subject: [PATCH 6/9] Removed warnings from the task output. --- lib/rage/tasks/openapi.rake | 2 +- spec/openapi/openapi_spec.rb | 4 ++-- spec/rage/tasks_spec.rb | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/rage/tasks/openapi.rake b/lib/rage/tasks/openapi.rake index aef97707..0af4734b 100644 --- a/lib/rage/tasks/openapi.rake +++ b/lib/rage/tasks/openapi.rake @@ -3,7 +3,7 @@ namespace :openapi do Rage::OpenAPI.build if Rage::OpenAPI.__warnings.any? - puts "OpenAPI validation failed. Warnings: #{Rage::OpenAPI.__warnings}" + puts "OpenAPI validation failed." exit 1 else puts "OpenAPI validation passed without warnings." diff --git a/spec/openapi/openapi_spec.rb b/spec/openapi/openapi_spec.rb index 35edb276..ae4449b6 100644 --- a/spec/openapi/openapi_spec.rb +++ b/spec/openapi/openapi_spec.rb @@ -255,8 +255,8 @@ end it "prints a warning and records it in __warnings" do - expect { described_class.__log_warn("something went wrong") } - .to output("[OpenAPI] WARNING: something went wrong\n").to_stdout + expect { described_class.__log_warn("something went wrong") }. + to output("[OpenAPI] WARNING: something went wrong\n").to_stdout expect(described_class.__warnings).to eq(["something went wrong"]) end diff --git a/spec/rage/tasks_spec.rb b/spec/rage/tasks_spec.rb index f8f443d3..33a6077c 100644 --- a/spec/rage/tasks_spec.rb +++ b/spec/rage/tasks_spec.rb @@ -20,6 +20,8 @@ end RSpec.describe "openapi:validate" do + subject { Rake::Task["openapi:validate"].invoke } + before do Rake.application = Rake::Application.new Rage::Tasks.send(:load_rage_tasks) @@ -31,8 +33,6 @@ Rage::OpenAPI.instance_variable_set(:@__warnings, nil) end - subject { Rake::Task["openapi:validate"].invoke } - it "builds the OpenAPI spec" do allow($stdout).to receive(:puts) @@ -58,13 +58,13 @@ Rage::OpenAPI.__warnings << "unrecognized tag" end - it "prints a failure message including the warnings" do + it "prints a failure message" do expect { begin subject rescue SystemExit end - }.to output(/OpenAPI validation failed\. Warnings:.*unrecognized tag/).to_stdout + }.to output(/OpenAPI validation failed\./).to_stdout end it "exits with status 1" do From 9517876c3b6ff6405034f39c930f0ce548dab1b3 Mon Sep 17 00:00:00 2001 From: Oleksandr Rohachev Date: Mon, 10 Aug 2026 15:27:27 +0300 Subject: [PATCH 7/9] Added CHANGELOG.md entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b2e47d6..7aabaf8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## [Unreleased] +### Added + +- [OpenAPI] Add `openapi:validate` Rake task for OpenAPI tags validation (#163). + ## [1.27.0] - 2026-08-06 ### Added From e3c675b31adf817702dbc1127fae04edd3ab7a35 Mon Sep 17 00:00:00 2001 From: Oleksandr Rohachev Date: Tue, 11 Aug 2026 15:16:17 +0300 Subject: [PATCH 8/9] [OpenAPI] Scope openapi:validate warnings to a single build and reject unbooted apps --- lib/rage/openapi/openapi.rb | 11 +++- lib/rage/tasks/openapi.rake | 10 ++-- spec/openapi/openapi_spec.rb | 48 +++++++++++----- spec/rage/tasks_spec.rb | 107 +++++++++++++++++++++++++---------- 4 files changed, 124 insertions(+), 52 deletions(-) diff --git a/lib/rage/openapi/openapi.rb b/lib/rage/openapi/openapi.rb index 39f2be19..3c7889e3 100644 --- a/lib/rage/openapi/openapi.rb +++ b/lib/rage/openapi/openapi.rb @@ -200,13 +200,18 @@ def self.__resolve_resource(klass_str, namespace) # @private def self.__log_warn(log) - __warnings << log + @__warnings << log if @__warnings puts "[OpenAPI] WARNING: #{log}" end # @private - def self.__warnings - @__warnings ||= [] + # @return [Array] the warnings logged inside the block + def self.__collect_warnings + @__warnings = [] + yield + @__warnings + ensure + @__warnings = nil end module Nodes diff --git a/lib/rage/tasks/openapi.rake b/lib/rage/tasks/openapi.rake index 0af4734b..f46c7915 100644 --- a/lib/rage/tasks/openapi.rake +++ b/lib/rage/tasks/openapi.rake @@ -1,10 +1,12 @@ namespace :openapi do + desc "Validate OpenAPI tags and fail if any warnings were produced" task :validate do - Rage::OpenAPI.build + abort "OpenAPI validation requires a booted application." unless Rage.config.internal.initialized? - if Rage::OpenAPI.__warnings.any? - puts "OpenAPI validation failed." - exit 1 + warnings = Rage::OpenAPI.__collect_warnings { Rage::OpenAPI.build } + + if warnings.any? + abort "OpenAPI validation failed with #{warnings.size} warning(s)." else puts "OpenAPI validation passed without warnings." end diff --git a/spec/openapi/openapi_spec.rb b/spec/openapi/openapi_spec.rb index ae4449b6..3dba6809 100644 --- a/spec/openapi/openapi_spec.rb +++ b/spec/openapi/openapi_spec.rb @@ -250,31 +250,51 @@ end describe ".__log_warn" do - before do - described_class.instance_variable_set(:@__warnings, nil) - end - - it "prints a warning and records it in __warnings" do + it "prints a warning" do expect { described_class.__log_warn("something went wrong") }. to output("[OpenAPI] WARNING: something went wrong\n").to_stdout + end - expect(described_class.__warnings).to eq(["something went wrong"]) + it "does not retain the warning when not collecting" do + allow(described_class).to receive(:puts) + + described_class.__log_warn("something went wrong") + + expect(described_class.instance_variable_get(:@__warnings)).to be_nil end + end - it "accumulates multiple warnings" do + describe ".__collect_warnings" do + before do allow(described_class).to receive(:puts) + end + + it "returns the warnings logged inside the block" do + warnings = described_class.__collect_warnings do + described_class.__log_warn("first") + described_class.__log_warn("second") + end + + expect(warnings).to eq(["first", "second"]) + end - described_class.__log_warn("first") + it "returns an empty array when nothing was logged" do + expect(described_class.__collect_warnings {}).to eq([]) + end + + it "stops collecting once the block returns" do + described_class.__collect_warnings { described_class.__log_warn("first") } described_class.__log_warn("second") - expect(described_class.__warnings).to eq(["first", "second"]) + expect(described_class.__collect_warnings {}).to eq([]) end - end - describe ".__warnings" do - it "returns an empty array by default" do - described_class.instance_variable_set(:@__warnings, nil) - expect(described_class.__warnings).to eq([]) + it "stops collecting if the block raises" do + expect { + described_class.__collect_warnings { raise "boom" } + }.to raise_error("boom") + + expect(described_class.instance_variable_get(:@__warnings)).to be_nil end end diff --git a/spec/rage/tasks_spec.rb b/spec/rage/tasks_spec.rb index 33a6077c..4eb041dc 100644 --- a/spec/rage/tasks_spec.rb +++ b/spec/rage/tasks_spec.rb @@ -4,11 +4,17 @@ require "rage/tasks" RSpec.describe Rage::Tasks do - describe ".load_rage_tasks" do - before do - Rake.application = Rake::Application.new - end + # `Rake.application` is a process-wide singleton owning the task registry; a fresh one per example + # keeps the registry empty and tasks re-invokable, while restoring it avoids leaking into other specs. + around do |example| + original_application = Rake.application + Rake.application = Rake::Application.new + example.run + ensure + Rake.application = original_application + end + describe ".load_rage_tasks" do it "loads the openapi:validate task" do expect(Rake::Task.task_defined?("openapi:validate")).to eq(false) @@ -20,59 +26,98 @@ end RSpec.describe "openapi:validate" do - subject { Rake::Task["openapi:validate"].invoke } + include_context "mocked_classes" + include_context "mocked_rage_routes" - before do + around do |example| + original_application = Rake.application Rake.application = Rake::Application.new Rage::Tasks.send(:load_rage_tasks) - Rage::OpenAPI.instance_variable_set(:@__warnings, []) - allow(Rage::OpenAPI).to receive(:build) + example.run + ensure + Rake.application = original_application end - after do - Rage::OpenAPI.instance_variable_set(:@__warnings, nil) + before do + allow(Rage.config.internal).to receive(:initialized?).and_return(true) + end + + let(:routes) do + { "GET /users" => "UsersController#index" } end - it "builds the OpenAPI spec" do - allow($stdout).to receive(:puts) + # returns the `SystemExit` error the task exited with, or `nil` if it didn't exit + def invoke_task + Rake::Task["openapi:validate"].invoke + nil + rescue SystemExit => e + e + end + + context "when the application is not booted" do + before do + allow(Rage.config.internal).to receive(:initialized?).and_return(false) + end + + it "doesn't build the spec" do + expect(Rage::OpenAPI).not_to receive(:build) + + expect { invoke_task }. + to output(/OpenAPI validation requires a booted application\./).to_stderr + end - expect(Rage::OpenAPI).to receive(:build) + it "exits with status 1" do + exit_error = nil - subject + expect { exit_error = invoke_task }.to output.to_stderr + + expect(exit_error.status).to eq(1) + end end - context "when there are no warnings" do + context "when the spec builds without warnings" do + let_class("UsersController", parent: RageController::API) do + <<~'RUBY' + # @response { id: Integer, full_name: String } + def index + end + RUBY + end + it "prints a success message" do - expect { subject }.to output(/OpenAPI validation passed without warnings\./).to_stdout + expect { invoke_task }.to output(/OpenAPI validation passed without warnings\./).to_stdout end - it "does not exit with an error" do + it "doesn't exit with an error" do allow($stdout).to receive(:puts) - expect { subject }.not_to raise_error + expect(invoke_task).to be_nil end end - context "when there are warnings" do - before do - Rage::OpenAPI.__warnings << "unrecognized tag" + context "when the build produces warnings" do + let_class("UsersController", parent: RageController::API) do + <<~'RUBY' + # @response UnknownResource + def index + end + RUBY end - it "prints a failure message" do - expect { - begin - subject - rescue SystemExit - end - }.to output(/OpenAPI validation failed\./).to_stdout + it "prints the warnings and the number of failures" do + expect { invoke_task }. + to output(/unrecognized `@response` tag detected/).to_stdout. + and output(/OpenAPI validation failed with 1 warning\(s\)\./).to_stderr end it "exits with status 1" do allow($stdout).to receive(:puts) - expect { subject }.to raise_error(SystemExit) { |error| - expect(error.status).to eq(1) - } + exit_error = nil + + expect { exit_error = invoke_task }.to output.to_stderr + + expect(exit_error.status).to eq(1) end end end From 24b86a859e1208cf9e085dc7368f0dd86f1a6a3e Mon Sep 17 00:00:00 2001 From: Oleksandr Rohachev Date: Tue, 11 Aug 2026 17:57:34 +0300 Subject: [PATCH 9/9] Tests cleanup --- spec/rage/tasks_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/rage/tasks_spec.rb b/spec/rage/tasks_spec.rb index 4eb041dc..f05a58f2 100644 --- a/spec/rage/tasks_spec.rb +++ b/spec/rage/tasks_spec.rb @@ -47,7 +47,7 @@ end # returns the `SystemExit` error the task exited with, or `nil` if it didn't exit - def invoke_task + subject(:invoke_task) do Rake::Task["openapi:validate"].invoke nil rescue SystemExit => e