diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea303930..c528635f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,10 +43,9 @@ jobs: - { ruby: '3.3', grape: '2.2.0' } - { ruby: '3.4', grape: '2.2.0' } - { ruby: 'head', grape: '2.2.0' } - # - { ruby: '3.1', grape: 'HEAD' } - # - { ruby: '3.2', grape: 'HEAD' } - # - { ruby: '3.3', grape: 'HEAD' } - # - { ruby: '3.4', grape: 'HEAD' } + - { ruby: '3.2', grape: 'HEAD' } + - { ruby: '3.3', grape: 'HEAD' } + - { ruby: '3.4', grape: 'HEAD' } name: test (ruby=${{ matrix.entry.ruby }}, grape=${{ matrix.entry.grape }}) runs-on: ubuntu-latest needs: ['rubocop'] diff --git a/CHANGELOG.md b/CHANGELOG.md index aa559f7e..b75eef0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ #### Fixes +* [#966](https://github.com/ruby-grape/grape-swagger/pull/966): Grape 3.0 compatibility - [@numbata](https://github.com/numbata). * [#954](https://github.com/ruby-grape/grape-swagger/pull/954): Ruby 3.5 compatibility: add cgi gem, drop runtime `rack‑test` - [@numbata](https://github.com/numbata). * [#948](https://github.com/ruby-grape/grape-swagger/pull/948): Grape 2.3.0 and Ruby 3.5 compatibility - [@numbata](https://github.com/numbata). * Your contribution here. diff --git a/Gemfile b/Gemfile index 66255fc2..e0d4802a 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ source 'https://rubygems.org' gemspec # gem 'grape', git: 'https://github.com/ruby-grape/grape' -gem 'grape', case version = ENV.fetch('GRAPE_VERSION', '< 3.0') +gem 'grape', case version = ENV.fetch('GRAPE_VERSION', '< 4.0') when 'HEAD' { git: 'https://github.com/ruby-grape/grape' } else @@ -20,10 +20,11 @@ group :development, :test do gem 'pry', platforms: [:mri] gem 'pry-byebug', platforms: [:mri] - grape_version = ENV.fetch('GRAPE_VERSION', '2.2.0') + grape_version = ENV.fetch('GRAPE_VERSION', '2.4.0') if grape_version == 'HEAD' || Gem::Version.new(grape_version) >= Gem::Version.new('2.0.0') gem 'rack', '>= 3.0' else + gem 'activesupport', '< 7.2' gem 'rack', '< 3.0' end diff --git a/README.md b/README.md index 1f429dd9..e83fd48a 100644 --- a/README.md +++ b/README.md @@ -113,18 +113,19 @@ This screenshot is based on the [Hussars](https://github.com/LeFnord/hussars) sa The following versions of grape, grape-entity and grape-swagger can currently be used together. -| grape-swagger | swagger spec | grape | grape-entity | representable | -| ------------------ | ------------ | ----------------------- | ------------ | ------------- | -| 0.10.5 | 1.2 | >= 0.10.0 ... <= 0.14.0 | < 0.5.0 | n/a | -| 0.11.0 | 1.2 | >= 0.16.2 | < 0.5.0 | n/a | -| 0.25.2 | 2.0 | >= 0.14.0 ... <= 0.18.0 | <= 0.6.0 | >= 2.4.1 | -| 0.26.0 | 2.0 | >= 0.16.2 ... <= 1.1.0 | <= 0.6.1 | >= 2.4.1 | -| 0.27.0 | 2.0 | >= 0.16.2 ... <= 1.1.0 | >= 0.5.0 | >= 2.4.1 | -| 0.32.0 | 2.0 | >= 0.16.2 | >= 0.5.0 | >= 2.4.1 | -| 0.34.0 | 2.0 | >= 0.16.2 ... < 1.3.0 | >= 0.5.0 | >= 2.4.1 | -| >= 1.0.0 | 2.0 | >= 1.3.0 | >= 0.5.0 | >= 2.4.1 | -| >= 2.0.0 | 2.0 | >= 1.7.0 | >= 0.5.0 | >= 2.4.1 | -| >= 2.0.0 ... < 2.2 | 2.0 | >= 1.8.0 ... < 2.3.0 | >= 0.5.0 | >= 2.4.1 | +| grape-swagger | swagger spec | grape | grape-entity | representable | +| --------------------- | ------------ | ----------------------- | ------------ | ------------- | +| 0.10.5 | 1.2 | >= 0.10.0 ... <= 0.14.0 | < 0.5.0 | n/a | +| 0.11.0 | 1.2 | >= 0.16.2 | < 0.5.0 | n/a | +| 0.25.2 | 2.0 | >= 0.14.0 ... <= 0.18.0 | <= 0.6.0 | >= 2.4.1 | +| 0.26.0 | 2.0 | >= 0.16.2 ... <= 1.1.0 | <= 0.6.1 | >= 2.4.1 | +| 0.27.0 | 2.0 | >= 0.16.2 ... <= 1.1.0 | >= 0.5.0 | >= 2.4.1 | +| 0.32.0 | 2.0 | >= 0.16.2 | >= 0.5.0 | >= 2.4.1 | +| 0.34.0 | 2.0 | >= 0.16.2 ... < 1.3.0 | >= 0.5.0 | >= 2.4.1 | +| >= 1.0.0 | 2.0 | >= 1.3.0 | >= 0.5.0 | >= 2.4.1 | +| >= 2.0.0 | 2.0 | >= 1.7.0 | >= 0.5.0 | >= 2.4.1 | +| >= 2.0.0 ... <= 2.1.2 | 2.0 | >= 1.8.0 ... < 2.3.0 | >= 0.5.0 | >= 2.4.1 | +| > 2.1.2 | 2.0 | >= 1.8.0 ... < 4.0 | >= 0.5.0 | >= 2.4.1 | ## Swagger-Spec diff --git a/grape-swagger.gemspec b/grape-swagger.gemspec index 2ced24ac..d88943ef 100644 --- a/grape-swagger.gemspec +++ b/grape-swagger.gemspec @@ -15,7 +15,7 @@ Gem::Specification.new do |s| s.metadata['rubygems_mfa_required'] = 'true' s.required_ruby_version = '>= 3.1' - s.add_dependency 'grape', '>= 1.7', '< 3.0' + s.add_dependency 'grape', '>= 1.7', '< 4.0' s.files = Dir['lib/**/*', '*.md', 'LICENSE.txt', 'grape-swagger.gemspec'] s.require_paths = ['lib'] diff --git a/lib/grape-swagger.rb b/lib/grape-swagger.rb index 8eb42934..9e929b25 100644 --- a/lib/grape-swagger.rb +++ b/lib/grape-swagger.rb @@ -11,6 +11,7 @@ require 'grape-swagger/doc_methods' require 'grape-swagger/model_parsers' require 'grape-swagger/request_param_parser_registry' +require 'grape-swagger/token_owner_resolver' module GrapeSwagger class << self @@ -184,12 +185,13 @@ def combine_namespaces(app) endpoint = endpoints.shift endpoints.push(*endpoint.options[:app].endpoints) if endpoint.options[:app] - ns = endpoint.namespace_stackable(:namespace).last + namespace_stackable = endpoint.inheritable_setting.namespace_stackable + ns = (namespace_stackable[:namespace] || []).last next unless ns # use the full namespace here (not the latest level only) # and strip leading slash - mount_path = (endpoint.namespace_stackable(:mount_path) || []).join('/') + mount_path = (namespace_stackable[:mount_path] || []).join('/') full_namespace = (mount_path + endpoint.namespace).sub(/\/{2,}/, '/').sub(/^\//, '') combined_namespaces[full_namespace] = ns end diff --git a/lib/grape-swagger/endpoint.rb b/lib/grape-swagger/endpoint.rb index 54dd8191..2c7be5ed 100644 --- a/lib/grape-swagger/endpoint.rb +++ b/lib/grape-swagger/endpoint.rb @@ -5,6 +5,7 @@ require_relative 'request_param_parsers/headers' require_relative 'request_param_parsers/route' require_relative 'request_param_parsers/body' +require_relative 'token_owner_resolver' module Grape class Endpoint # rubocop:disable Metrics/ClassLength @@ -439,7 +440,10 @@ def hidden?(route, options) route_hidden = route.options[:hidden] if route.options.key?(:hidden) return route_hidden unless route_hidden.is_a?(Proc) - options[:token_owner] ? route_hidden.call(send(options[:token_owner].to_sym)) : route_hidden.call + return route_hidden.call unless options[:token_owner] + + token_owner = GrapeSwagger::TokenOwnerResolver.resolve(self, options[:token_owner]) + GrapeSwagger::TokenOwnerResolver.evaluate_proc(route_hidden, token_owner) end def hidden_parameter?(value) diff --git a/lib/grape-swagger/errors.rb b/lib/grape-swagger/errors.rb index b4781699..d3cd683d 100644 --- a/lib/grape-swagger/errors.rb +++ b/lib/grape-swagger/errors.rb @@ -13,5 +13,7 @@ def tell!(what) end end end + + class TokenOwnerNotFound < NoMethodError; end end end diff --git a/lib/grape-swagger/request_param_parsers/body.rb b/lib/grape-swagger/request_param_parsers/body.rb index b8814396..6feeca6f 100644 --- a/lib/grape-swagger/request_param_parsers/body.rb +++ b/lib/grape-swagger/request_param_parsers/body.rb @@ -54,13 +54,7 @@ def public_parameter?(param_options) return true unless param_options.key?(:documentation) && !param_options[:required] param_hidden = param_options[:documentation].fetch(:hidden, false) - if param_hidden.is_a?(Proc) - param_hidden = if settings[:token_owner] - param_hidden.call(endpoint.send(settings[:token_owner].to_sym)) - else - param_hidden.call - end - end + param_hidden = evaluate_hidden_proc(param_hidden) if param_hidden.is_a?(Proc) !param_hidden end @@ -69,6 +63,13 @@ def includes_body_param? options.dig(:documentation, :param_type) == 'body' || options.dig(:documentation, :in) == 'body' end end + + def evaluate_hidden_proc(hidden_proc) + return hidden_proc.call unless settings[:token_owner] + + token_owner = GrapeSwagger::TokenOwnerResolver.resolve(endpoint, settings[:token_owner]) + GrapeSwagger::TokenOwnerResolver.evaluate_proc(hidden_proc, token_owner) + end end end end diff --git a/lib/grape-swagger/token_owner_resolver.rb b/lib/grape-swagger/token_owner_resolver.rb new file mode 100644 index 00000000..9bcc794c --- /dev/null +++ b/lib/grape-swagger/token_owner_resolver.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module GrapeSwagger + class TokenOwnerResolver + class << self + SUPPORTED_ARITY_TYPES = %i[req opt rest keyreq key keyrest].freeze + UNRESOLVED = Object.new.freeze + private_constant :UNRESOLVED + + def resolve(endpoint, method_name) + return if method_name.nil? + + method_name = method_name.to_sym + return endpoint.public_send(method_name) if endpoint.respond_to?(method_name, true) + + helper_value = resolve_from_helpers(endpoint, method_name) + return helper_value unless helper_value.equal?(UNRESOLVED) + + raise Errors::TokenOwnerNotFound, "undefined method `#{method_name}` for #{endpoint.class}" + end + + def evaluate_proc(callable, token_owner) + return callable.call unless accepts_argument?(callable) + + callable.call(token_owner) + end + + private + + def resolve_from_helpers(endpoint, method_name) + helpers = gather_helpers(endpoint) + return UNRESOLVED if helpers.empty? + + helpers.each do |helper| + resolved = resolve_from_helper(endpoint, helper, method_name) + return resolved unless resolved.equal?(UNRESOLVED) + end + + UNRESOLVED + end + + def gather_helpers(endpoint) + return [] if endpoint.nil? + + stackable_helpers = fetch_stackable_helpers(endpoint) + normalize_helpers(stackable_helpers) + end + + def fetch_stackable_helpers(endpoint) + return unless endpoint.respond_to?(:inheritable_setting) + + setting = endpoint.inheritable_setting + return unless setting.respond_to?(:namespace_stackable) + + namespace_stackable = setting.namespace_stackable + return unless namespace_stackable.respond_to?(:[]) + + namespace_stackable[:helpers] + rescue NameError + nil + end + + def normalize_helpers(helpers) + case helpers + when nil, false + [] + when Module + [helpers] + when Array + helpers.compact + else + if helpers.respond_to?(:key?) && helpers.respond_to?(:[]) && helpers.key?(:helpers) + normalize_helpers(helpers[:helpers]) + elsif helpers.respond_to?(:to_a) + Array(helpers.to_a).flatten.compact + else + Array(helpers).compact + end + end + end + + def resolve_from_helper(endpoint, helper, method_name) + return UNRESOLVED unless helper_method_defined?(helper, method_name) + + helper.instance_method(method_name).bind(endpoint).call + rescue NameError + UNRESOLVED + end + + def helper_method_defined?(helper, method_name) + helper.method_defined?(method_name) || helper.private_method_defined?(method_name) + end + + def accepts_argument?(callable) + return false unless callable.respond_to?(:parameters) + + callable.parameters.any? { |type, _| SUPPORTED_ARITY_TYPES.include?(type) } + end + end + end +end diff --git a/spec/lib/token_owner_resolver_spec.rb b/spec/lib/token_owner_resolver_spec.rb new file mode 100644 index 00000000..1481bab8 --- /dev/null +++ b/spec/lib/token_owner_resolver_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GrapeSwagger::TokenOwnerResolver do + describe '.resolve' do + let(:helper_module) do + Module.new do + def current_user_id + 'user-123' + end + end + end + + let(:api_class) do + mod = helper_module + Class.new(Grape::API) do + helpers mod + + helpers do + def token_owner + { id: 7, email: 'owner@example.com' } + end + end + + get('/status') { { status: 'ok' } } + end + end + + before { api_class.compile! } + + let(:endpoint) { api_class.endpoints.first } + + it 'returns nil when no method name is provided' do + expect(described_class.resolve(endpoint, nil)).to be_nil + end + + it 'returns the resolved value when method exists' do + expect(described_class.resolve(endpoint, :token_owner)).to eq(id: 7, email: 'owner@example.com') + end + + it 'raises when the endpoint does not respond to the method' do + expect do + expect(described_class.resolve(endpoint, :unknown)) + end.to raise_error(GrapeSwagger::Errors::TokenOwnerNotFound, /undefined method `unknown`/) + end + + context 'when helpers are included from a module' do + it 'resolves the owner using the helper module from the namespace stack' do + expect(described_class.resolve(endpoint, :current_user_id)).to eq('user-123') + end + end + end + + describe '.evaluate_proc' do + let(:token_owner) { double(:token_owner) } + + it 'executes callables without arguments directly' do + callable = -> { :owner } + + expect(callable).to receive(:call).with(no_args).and_call_original + expect(described_class.evaluate_proc(callable, token_owner)).to eq(:owner) + end + + it 'passes the token owner when the callable accepts an argument' do + callable = ->(owner) { owner } + + allow(callable).to receive(:call).with(token_owner).and_call_original + expect(described_class.evaluate_proc(callable, token_owner)).to eq(token_owner) + expect(callable).to have_received(:call).with(token_owner) + end + + it 'defaults to calling without arguments when arity cannot be detected' do + callable = Class.new do + def call(owner = :undetected) + owner + end + end.new + + expect(described_class.evaluate_proc(callable, token_owner)).to eq(:undetected) + end + end + + describe '.resolve_from_helper' do + let(:helper_module) do + Module.new do + def helper_method + 'helper_result' + end + end + end + + let(:endpoint) { instance_double(Grape::Endpoint) } + + it 'resolves the method from the Module helper' do + result = described_class.send(:resolve_from_helper, endpoint, helper_module, :helper_method) + expect(result).to eq('helper_result') + end + + it 'returns a frozen sentinel object when method does not exist on the Module' do + result = described_class.send(:resolve_from_helper, endpoint, helper_module, :nonexistent) + # UNRESOLVED is a private constant, so we check by type and behavior + expect(result).to be_a(Object) + expect(result).to be_frozen + # Verify it's the same object on repeated calls (singleton pattern) + result2 = described_class.send(:resolve_from_helper, endpoint, helper_module, :nonexistent) + expect(result).to equal(result2) + end + end + + describe 'endpoint helpers access' do + let(:helper_module) do + Module.new do + def current_user + { id: 42, name: 'Test User' } + end + end + end + + let(:api_class) do + mod = helper_module + Class.new(Grape::API) do + helpers mod + + get('/test') { { ok: true } } + end + end + + before { api_class.compile! } + + let(:endpoint) { api_class.endpoints.first } + + it 'resolves helper methods from namespace stack' do + resolved_value = described_class.resolve(endpoint, :current_user) + expect(resolved_value).to eq(id: 42, name: 'Test User') + end + end +end