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 diff --git a/lib/rage/openapi/openapi.rb b/lib/rage/openapi/openapi.rb index a7c89b99..3c7889e3 100644 --- a/lib/rage/openapi/openapi.rb +++ b/lib/rage/openapi/openapi.rb @@ -200,9 +200,20 @@ def self.__resolve_resource(klass_str, namespace) # @private def self.__log_warn(log) + @__warnings << log if @__warnings puts "[OpenAPI] WARNING: #{log}" end + # @private + # @return [Array] the warnings logged inside the block + def self.__collect_warnings + @__warnings = [] + yield + @__warnings + ensure + @__warnings = nil + end + module Nodes end diff --git a/lib/rage/tasks.rb b/lib/rage/tasks.rb index 80c10ad8..7da34bf3 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 @@ -40,5 +41,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..f46c7915 --- /dev/null +++ b/lib/rage/tasks/openapi.rake @@ -0,0 +1,14 @@ +namespace :openapi do + desc "Validate OpenAPI tags and fail if any warnings were produced" + task :validate do + abort "OpenAPI validation requires a booted application." unless Rage.config.internal.initialized? + + 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 + end +end diff --git a/spec/openapi/openapi_spec.rb b/spec/openapi/openapi_spec.rb index e71187fa..3dba6809 100644 --- a/spec/openapi/openapi_spec.rb +++ b/spec/openapi/openapi_spec.rb @@ -249,6 +249,55 @@ end end + describe ".__log_warn" 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 + + 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 + + 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 + + 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.__collect_warnings {}).to eq([]) + end + + 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 + 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..f05a58f2 --- /dev/null +++ b/spec/rage/tasks_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "rake" +require "rage/tasks" + +RSpec.describe Rage::Tasks do + # `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) + + described_class.send(:load_rage_tasks) + + expect(Rake::Task.task_defined?("openapi:validate")).to eq(true) + end + end +end + +RSpec.describe "openapi:validate" do + include_context "mocked_classes" + include_context "mocked_rage_routes" + + around do |example| + original_application = Rake.application + Rake.application = Rake::Application.new + Rage::Tasks.send(:load_rage_tasks) + example.run + ensure + Rake.application = original_application + end + + before do + allow(Rage.config.internal).to receive(:initialized?).and_return(true) + end + + let(:routes) do + { "GET /users" => "UsersController#index" } + end + + # returns the `SystemExit` error the task exited with, or `nil` if it didn't exit + subject(:invoke_task) do + 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 + + it "exits with status 1" do + exit_error = nil + + expect { exit_error = invoke_task }.to output.to_stderr + + expect(exit_error.status).to eq(1) + end + end + + 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 { invoke_task }.to output(/OpenAPI validation passed without warnings\./).to_stdout + end + + it "doesn't exit with an error" do + allow($stdout).to receive(:puts) + + expect(invoke_task).to be_nil + end + end + + context "when the build produces warnings" do + let_class("UsersController", parent: RageController::API) do + <<~'RUBY' + # @response UnknownResource + def index + end + RUBY + end + + 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) + + exit_error = nil + + expect { exit_error = invoke_task }.to output.to_stderr + + expect(exit_error.status).to eq(1) + end + end +end