From 335b50d1312b6441ec4d48ca20a46f17c9ce6ead Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Wed, 4 Nov 2026 18:36:44 +0100 Subject: [PATCH 1/5] Hide nested params of a hidden Hash parameter Children of a Hash parameter arrive as separate parent[child] entries that do not carry the parent's documentation, so hidden: true on the parent left orphaned query parameters in the document. Skip entries nested under a hidden parent as well. --- lib/grape-swagger/endpoint.rb | 7 ++-- spec/issues/1015_hidden_hash_children_spec.rb | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 spec/issues/1015_hidden_hash_children_spec.rb diff --git a/lib/grape-swagger/endpoint.rb b/lib/grape-swagger/endpoint.rb index fba5fb40..86a0eb92 100644 --- a/lib/grape-swagger/endpoint.rb +++ b/lib/grape-swagger/endpoint.rb @@ -171,8 +171,11 @@ def consumes_object(route, format) end def params_object(route, options, path, consumes) - parameters = build_request_params(route, options).each_with_object([]) do |(param, value), memo| - next if hidden_parameter?(value) + request_params = build_request_params(route, options) + hidden_roots = request_params.select { |_, value| hidden_parameter?(value) }.keys + + parameters = request_params.each_with_object([]) do |(param, value), memo| + next if hidden_roots.include?(param) || hidden_roots.any? { |root| param.start_with?("#{root}[") } value = { required: false }.merge(value) if value.is_a?(Hash) _, value = default_type([[param, value]]).first if value == '' diff --git a/spec/issues/1015_hidden_hash_children_spec.rb b/spec/issues/1015_hidden_hash_children_spec.rb new file mode 100644 index 00000000..68fbff6d --- /dev/null +++ b/spec/issues/1015_hidden_hash_children_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'hidden hash parameter' do + let(:app) do + Class.new(Grape::API) do + format :json + + params do + optional :query, type: String + optional :filters, type: Hash, documentation: { hidden: true } do + optional :status, type: String + optional :nested, type: Hash do + optional :kind, type: String + end + end + optional :other, type: Hash do + optional :visible, type: String + end + end + get('/search') { 'ok' } + + add_swagger_documentation + end + end + + it 'hides the nested children together with the parent' do + get '/swagger_doc' + names = JSON.parse(last_response.body).dig('paths', '/search', 'get', 'parameters').map { |p| p['name'] } + expect(names).to eq(['query', 'other[visible]']) + end +end From 450c74b485fca94e07bcf55eb66394ff45245fa2 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Wed, 4 Nov 2026 18:37:58 +0100 Subject: [PATCH 2/5] Add changelog entry for pull request 1016 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2ce2a42..5ede1af9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ #### Fixes +* [#1016](https://github.com/ruby-grape/grape-swagger/pull/1016): Hide the nested parameters of a Hash parameter marked `documentation: { hidden: true }` - [@numbata](https://github.com/numbata). * [#1012](https://github.com/ruby-grape/grape-swagger/pull/1012): Keep the Danger PR check running in CI by pinning `git` below 6 - [@numbata](https://github.com/numbata). * [#1011](https://github.com/ruby-grape/grape-swagger/pull/1011): Preserve string length, array item and object property limits in body parameter definitions, and emit `minLength`/`maxLength` only for strings (`minProperties`/`maxProperties` for hashes) instead of any non-array type - [@numbata](https://github.com/numbata). * [#1009](https://github.com/ruby-grape/grape-swagger/pull/1009): Bump the Danger `danger-comment.yml` workflow from 0.1.0 to 0.1.1 - [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D). From eecc72651822d8a256ee19c30c73c6eb57877a45 Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Wed, 4 Nov 2026 19:13:02 +0100 Subject: [PATCH 3/5] Evaluate hidden: procs with the token owner when hiding nested params Deciding whether a parent is hidden now goes through the same token owner aware evaluation as route visibility, so a hidden: proc taking the token owner no longer raises. Specs cover prefix siblings, array parents, proc-hidden parents and the body definition. --- lib/grape-swagger/endpoint.rb | 25 ++--- spec/issues/1015_hidden_hash_children_spec.rb | 105 ++++++++++++++---- 2 files changed, 98 insertions(+), 32 deletions(-) diff --git a/lib/grape-swagger/endpoint.rb b/lib/grape-swagger/endpoint.rb index 86a0eb92..a3fff4f2 100644 --- a/lib/grape-swagger/endpoint.rb +++ b/lib/grape-swagger/endpoint.rb @@ -172,10 +172,10 @@ def consumes_object(route, format) def params_object(route, options, path, consumes) request_params = build_request_params(route, options) - hidden_roots = request_params.select { |_, value| hidden_parameter?(value) }.keys + hidden_roots = request_params.select { |_, value| hidden_parameter?(value, options) }.keys parameters = request_params.each_with_object([]) do |(param, value), memo| - next if hidden_roots.include?(param) || hidden_roots.any? { |root| param.start_with?("#{root}[") } + next if hidden_roots.any? { |root| param == root || param.start_with?("#{root}[") } value = { required: false }.merge(value) if value.is_a?(Hash) _, value = default_type([[param, value]]).first if value == '' @@ -437,22 +437,21 @@ def hidden?(route, options) route_hidden = route.settings.try(:[], :swagger).try(:[], :hidden) hidden_val = route.hidden route_hidden = hidden_val unless hidden_val.nil? - return route_hidden unless route_hidden.is_a?(Proc) + evaluate_hidden(route_hidden, options) + end - return route_hidden.call unless options[:token_owner] + def hidden_parameter?(value, options) + return false if value[:required] - token_owner = GrapeSwagger::TokenOwnerResolver.resolve(self, options[:token_owner]) - GrapeSwagger::TokenOwnerResolver.evaluate_proc(route_hidden, token_owner) + evaluate_hidden(value.dig(:documentation, :hidden), options) end - def hidden_parameter?(value) - return false if value[:required] + def evaluate_hidden(hidden, options) + return hidden unless hidden.is_a?(Proc) + return hidden.call unless options[:token_owner] - if value.dig(:documentation, :hidden).is_a?(Proc) - value.dig(:documentation, :hidden).call - else - value.dig(:documentation, :hidden) - end + token_owner = GrapeSwagger::TokenOwnerResolver.resolve(self, options[:token_owner]) + GrapeSwagger::TokenOwnerResolver.evaluate_proc(hidden, token_owner) end def apply_model_documentation(response_model, model) diff --git a/spec/issues/1015_hidden_hash_children_spec.rb b/spec/issues/1015_hidden_hash_children_spec.rb index 68fbff6d..2dd995dc 100644 --- a/spec/issues/1015_hidden_hash_children_spec.rb +++ b/spec/issues/1015_hidden_hash_children_spec.rb @@ -2,32 +2,99 @@ require 'spec_helper' -describe 'hidden hash parameter' do - let(:app) do - Class.new(Grape::API) do - format :json - - params do - optional :query, type: String - optional :filters, type: Hash, documentation: { hidden: true } do - optional :status, type: String - optional :nested, type: Hash do - optional :kind, type: String +describe 'hidden parameter with nested children' do + def parameter_names(path, verb = 'get') + get '/swagger_doc' + parameters = JSON.parse(last_response.body).dig('paths', path, verb, 'parameters') + expect(parameters).not_to be_nil + parameters.map { |p| p['name'] } + end + + context 'with query parameters' do + let(:app) do + Class.new(Grape::API) do + format :json + + params do + optional :query, type: String + optional :filters, type: Hash, documentation: { hidden: true } do + optional :status, type: String + optional :nested, type: Hash do + optional :kind, type: String + end + end + optional :filters_extra, type: String + optional :other, type: Hash do + optional :visible, type: String + end + optional :tags, type: Array, documentation: { hidden: true } do + optional :name, type: String + end + optional :proc_hidden, type: Hash, documentation: { hidden: -> { true } } do + optional :child, type: String + end + end + get('/search') { 'ok' } + + add_swagger_documentation + end + end + + it 'hides the nested children together with the parent only' do + expect(parameter_names('/search')).to eq(%w[query filters_extra other[visible]]) + end + end + + context 'with a hidden proc that receives the token owner' do + let(:app) do + Class.new(Grape::API) do + format :json + + helpers do + def current_user + :admin end end - optional :other, type: Hash do - optional :visible, type: String + + params do + optional :query, type: String + optional :filters, type: Hash, documentation: { hidden: ->(user) { user == :admin } } do + optional :status, type: String + end end + get('/search') { 'ok' } + + add_swagger_documentation token_owner: 'current_user' end - get('/search') { 'ok' } + end - add_swagger_documentation + it 'evaluates the proc with the token owner' do + expect(parameter_names('/search')).to eq(%w[query]) end end - it 'hides the nested children together with the parent' do - get '/swagger_doc' - names = JSON.parse(last_response.body).dig('paths', '/search', 'get', 'parameters').map { |p| p['name'] } - expect(names).to eq(['query', 'other[visible]']) + context 'with body parameters' do + let(:app) do + Class.new(Grape::API) do + format :json + + params do + optional :query, type: String + optional :filters, type: Hash, documentation: { hidden: true } do + optional :status, type: String + end + end + post('/search') { 'ok' } + + add_swagger_documentation + end + end + + it 'leaves no hidden children in the body definition' do + get '/swagger_doc' + json = JSON.parse(last_response.body) + definition = json['definitions'].values.first + expect(definition['properties'].keys).to eq(%w[query]) + end end end From b1711fa3678c7d4dd5bb6174607f6aadd3e46c5e Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Wed, 4 Nov 2026 19:24:31 +0100 Subject: [PATCH 4/5] Cover a hidden child inside a visible Hash parameter --- spec/issues/1015_hidden_hash_children_spec.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/spec/issues/1015_hidden_hash_children_spec.rb b/spec/issues/1015_hidden_hash_children_spec.rb index 2dd995dc..dffd825f 100644 --- a/spec/issues/1015_hidden_hash_children_spec.rb +++ b/spec/issues/1015_hidden_hash_children_spec.rb @@ -24,6 +24,10 @@ def parameter_names(path, verb = 'get') end end optional :filters_extra, type: String + optional :partial, type: Hash do + optional :shown, type: String + optional :secret, type: String, documentation: { hidden: true } + end optional :other, type: Hash do optional :visible, type: String end @@ -41,7 +45,7 @@ def parameter_names(path, verb = 'get') end it 'hides the nested children together with the parent only' do - expect(parameter_names('/search')).to eq(%w[query filters_extra other[visible]]) + expect(parameter_names('/search')).to eq(%w[query filters_extra partial[shown] other[visible]]) end end From e5978fdbdf9c64098155c8442fb30bb3953c3c1f Mon Sep 17 00:00:00 2001 From: Andrey Subbota Date: Wed, 4 Nov 2026 20:00:23 +0100 Subject: [PATCH 5/5] Resolve the token owner once per request and cover the falsy proc branch Hidden procs previously triggered a token owner lookup per parameter, which scales badly if the lookup hits a database or auth service. The owner is now resolved lazily and at most once per params_object call. --- CHANGELOG.md | 2 +- lib/grape-swagger/endpoint.rb | 31 ++++++++++++----- spec/issues/1015_hidden_hash_children_spec.rb | 34 +++++++++++++------ 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ede1af9..89c69ed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ #### Fixes -* [#1016](https://github.com/ruby-grape/grape-swagger/pull/1016): Hide the nested parameters of a Hash parameter marked `documentation: { hidden: true }` - [@numbata](https://github.com/numbata). +* [#1016](https://github.com/ruby-grape/grape-swagger/pull/1016): Hide the nested parameters of a Hash parameter marked `documentation: { hidden: true }`, and pass the resolved `token_owner` to parameter-level `hidden:` procs like route-level ones (raising `TokenOwnerNotFound` if the configured method is missing) - [@numbata](https://github.com/numbata). * [#1012](https://github.com/ruby-grape/grape-swagger/pull/1012): Keep the Danger PR check running in CI by pinning `git` below 6 - [@numbata](https://github.com/numbata). * [#1011](https://github.com/ruby-grape/grape-swagger/pull/1011): Preserve string length, array item and object property limits in body parameter definitions, and emit `minLength`/`maxLength` only for strings (`minProperties`/`maxProperties` for hashes) instead of any non-array type - [@numbata](https://github.com/numbata). * [#1009](https://github.com/ruby-grape/grape-swagger/pull/1009): Bump the Danger `danger-comment.yml` workflow from 0.1.0 to 0.1.1 - [@dependabot[bot]](https://github.com/dependabot%5Bbot%5D). diff --git a/lib/grape-swagger/endpoint.rb b/lib/grape-swagger/endpoint.rb index a3fff4f2..25fcde2f 100644 --- a/lib/grape-swagger/endpoint.rb +++ b/lib/grape-swagger/endpoint.rb @@ -172,7 +172,8 @@ def consumes_object(route, format) def params_object(route, options, path, consumes) request_params = build_request_params(route, options) - hidden_roots = request_params.select { |_, value| hidden_parameter?(value, options) }.keys + token_owner = lazy_token_owner(options) + hidden_roots = request_params.select { |_, value| hidden_parameter?(value, token_owner) }.keys parameters = request_params.each_with_object([]) do |(param, value), memo| next if hidden_roots.any? { |root| param == root || param.start_with?("#{root}[") } @@ -437,21 +438,35 @@ def hidden?(route, options) route_hidden = route.settings.try(:[], :swagger).try(:[], :hidden) hidden_val = route.hidden route_hidden = hidden_val unless hidden_val.nil? - evaluate_hidden(route_hidden, options) + evaluate_hidden(route_hidden, lazy_token_owner(options)) end - def hidden_parameter?(value, options) + def hidden_parameter?(value, token_owner) return false if value[:required] - evaluate_hidden(value.dig(:documentation, :hidden), options) + evaluate_hidden(value.dig(:documentation, :hidden), token_owner) end - def evaluate_hidden(hidden, options) + def evaluate_hidden(hidden, token_owner) return hidden unless hidden.is_a?(Proc) - return hidden.call unless options[:token_owner] + return hidden.call unless token_owner - token_owner = GrapeSwagger::TokenOwnerResolver.resolve(self, options[:token_owner]) - GrapeSwagger::TokenOwnerResolver.evaluate_proc(hidden, token_owner) + GrapeSwagger::TokenOwnerResolver.evaluate_proc(hidden, token_owner.call) + end + + # Resolves the token owner at most once, and only if a hidden proc asks for it. + def lazy_token_owner(options) + return unless options[:token_owner] + + resolved = false + owner = nil + lambda do + unless resolved + owner = GrapeSwagger::TokenOwnerResolver.resolve(self, options[:token_owner]) + resolved = true + end + owner + end end def apply_model_documentation(response_model, model) diff --git a/spec/issues/1015_hidden_hash_children_spec.rb b/spec/issues/1015_hidden_hash_children_spec.rb index dffd825f..3be90299 100644 --- a/spec/issues/1015_hidden_hash_children_spec.rb +++ b/spec/issues/1015_hidden_hash_children_spec.rb @@ -5,9 +5,7 @@ describe 'hidden parameter with nested children' do def parameter_names(path, verb = 'get') get '/swagger_doc' - parameters = JSON.parse(last_response.body).dig('paths', path, verb, 'parameters') - expect(parameters).not_to be_nil - parameters.map { |p| p['name'] } + Array(JSON.parse(last_response.body).dig('paths', path, verb, 'parameters')).map { |p| p['name'] } end context 'with query parameters' do @@ -51,18 +49,21 @@ def parameter_names(path, verb = 'get') context 'with a hidden proc that receives the token owner' do let(:app) do + current = owner + Class.new(Grape::API) do format :json helpers do - def current_user - :admin - end + define_method(:current_user) { current } end params do optional :query, type: String - optional :filters, type: Hash, documentation: { hidden: ->(user) { user == :admin } } do + optional :admin_hidden, type: Hash, documentation: { hidden: ->(user) { user == :admin } } do + optional :status, type: String + end + optional :guest_hidden, type: Hash, documentation: { hidden: ->(user) { user == :guest } } do optional :status, type: String end end @@ -72,8 +73,20 @@ def current_user end end - it 'evaluates the proc with the token owner' do - expect(parameter_names('/search')).to eq(%w[query]) + context 'when the owner makes the proc truthy' do + let(:owner) { :admin } + + it 'hides the parent and its children' do + expect(parameter_names('/search')).to eq(%w[query guest_hidden[status]]) + end + end + + context 'when the owner makes the proc falsy' do + let(:owner) { :guest } + + it 'keeps the parent and its children' do + expect(parameter_names('/search')).to eq(%w[query admin_hidden[status]]) + end end end @@ -94,10 +107,11 @@ def current_user end end + # regression guard: body params were already handled before nested hiding was added it 'leaves no hidden children in the body definition' do get '/swagger_doc' json = JSON.parse(last_response.body) - definition = json['definitions'].values.first + definition = json['definitions']['postSearch'] expect(definition['properties'].keys).to eq(%w[query]) end end