diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ef5996290..81bb1ea721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ #### Features +* [#2233](https://github.com/ruby-grape/grape/pull/2233): A setting for disabling documentation to internal APIs - [@dnesteryuk](https://github.com/dnesteryuk). * Your contribution here. #### Fixes @@ -14,8 +15,6 @@ ### 1.6.2 (2021/12/30) -#### Features - #### Fixes * [#2219](https://github.com/ruby-grape/grape/pull/2219): Revert the changes for autoloading provided in 1.6.1 - [@dm1try](https://github.com/dm1try). diff --git a/README.md b/README.md index 640cf4620f..0f55d20498 100644 --- a/README.md +++ b/README.md @@ -2249,6 +2249,18 @@ params do end ``` +If documentation isn't needed (for instance, it is an internal API), documentation can be disabled. + +```ruby +class API < Grape::API + do_not_document! + + # endpoints... +end +``` + +In this case, Grape won't create objects related to documentation which are retained in RAM forever. + ## Cookies You can set, get and delete your cookies very simply using `cookies` method. diff --git a/lib/grape/dsl/routing.rb b/lib/grape/dsl/routing.rb index 5facda3ddc..158db99f5a 100644 --- a/lib/grape/dsl/routing.rb +++ b/lib/grape/dsl/routing.rb @@ -77,6 +77,10 @@ def do_not_route_options! namespace_inheritable(:do_not_route_options, true) end + def do_not_document! + namespace_inheritable(:do_not_document, true) + end + def mount(mounts, *opts) mounts = { mounts => '/' } unless mounts.respond_to?(:each_pair) mounts.each_pair do |app, path| diff --git a/lib/grape/dsl/validations.rb b/lib/grape/dsl/validations.rb index a150ac7f3e..66aff55ef2 100644 --- a/lib/grape/dsl/validations.rb +++ b/lib/grape/dsl/validations.rb @@ -38,12 +38,6 @@ def reset_validations! def params(&block) Grape::Validations::ParamsScope.new(api: self, type: Hash, &block) end - - def document_attribute(names, opts) - Array(names).each do |name| - namespace_stackable(:params, name[:full_name].to_s => opts) - end - end end end end diff --git a/lib/grape/validations/attributes_doc.rb b/lib/grape/validations/attributes_doc.rb new file mode 100644 index 0000000000..a046707039 --- /dev/null +++ b/lib/grape/validations/attributes_doc.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Grape + module Validations + class ParamsScope + # Documents parameters of an endpoint. If documentation isn't needed (for instance, it is an + # internal API), the class only cleans up attributes to avoid junk in RAM. + class AttributesDoc + attr_accessor :type, :values + + # @param api [Grape::API::Instance] + # @param scope [Validations::ParamsScope] + def initialize(api, scope) + @api = api + @scope = scope + @type = type + end + + def extract_details(validations) + details[:required] = validations.key?(:presence) + + desc = validations.delete(:desc) || validations.delete(:description) + + details[:desc] = desc if desc + + documentation = validations.delete(:documentation) + + details[:documentation] = documentation if documentation + + details[:default] = validations[:default] if validations.key?(:default) + end + + def document(attrs) + return if @api.namespace_inheritable(:do_not_document) + + details[:type] = type.to_s if type + details[:values] = values if values + + documented_attrs = attrs.each_with_object({}) do |name, memo| + memo[@scope.full_name(name)] = details + end + + @api.namespace_stackable(:params, documented_attrs) + end + + def required + details[:required] + end + + protected + + def details + @details ||= {} + end + end + end + end +end diff --git a/lib/grape/validations/params_scope.rb b/lib/grape/validations/params_scope.rb index 7b57ba42cd..6f69071416 100644 --- a/lib/grape/validations/params_scope.rb +++ b/lib/grape/validations/params_scope.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative 'attributes_doc' + module Grape module Validations class ParamsScope @@ -31,7 +33,7 @@ def initialize(opts, &block) @api = opts[:api] @optional = opts[:optional] || false @type = opts[:type] - @group = opts[:group] || {} + @group = opts[:group] @dependent_on = opts[:dependent_on] @declared_params = [] @index = nil @@ -269,17 +271,14 @@ def configure_declared_params end def validates(attrs, validations) - doc_attrs = { required: validations.key?(:presence) } + doc = AttributesDoc.new @api, self + doc.extract_details validations coerce_type = infer_coercion(validations) - doc_attrs[:type] = coerce_type.to_s if coerce_type - - desc = validations.delete(:desc) || validations.delete(:description) - doc_attrs[:desc] = desc if desc + doc.type = coerce_type default = validations[:default] - doc_attrs[:default] = default if validations.key?(:default) if (values_hash = validations[:values]).is_a? Hash values = values_hash[:value] @@ -288,7 +287,8 @@ def validates(attrs, validations) else values = validations[:values] end - doc_attrs[:values] = values if values + + doc.values = values except_values = options_key?(:except_values, :value, validations) ? validations[:except_values][:value] : validations[:except_values] @@ -304,28 +304,22 @@ def validates(attrs, validations) # type should be compatible with values array, if both exist validate_value_coercion(coerce_type, values, except_values, excepts) - doc_attrs[:documentation] = validations.delete(:documentation) if validations.key?(:documentation) - - document_attribute(attrs, doc_attrs) + doc.document attrs opts = derive_validator_options(validations) - order_specific_validations = Set[:as] - # Validate for presence before any other validators - validates_presence(validations, attrs, doc_attrs, opts) do |validation_type| - order_specific_validations << validation_type - end + validates_presence(validations, attrs, doc, opts) # Before we run the rest of the validators, let's handle # whatever coercion so that we are working with correctly # type casted values - coerce_type validations, attrs, doc_attrs, opts + coerce_type validations, attrs, doc, opts validations.each do |type, options| - next if order_specific_validations.include?(type) + next if type == :as - validate(type, options, attrs, doc_attrs, opts) + validate(type, options, attrs, doc, opts) end end @@ -389,7 +383,7 @@ def check_coerce_with(validations) # composited from more than one +requires+/+optional+ # parameter, and needs to be run before most other # validations. - def coerce_type(validations, attrs, doc_attrs, opts) + def coerce_type(validations, attrs, doc, opts) check_coerce_with(validations) return unless validations.key?(:coerce) @@ -399,7 +393,7 @@ def coerce_type(validations, attrs, doc_attrs, opts) method: validations[:coerce_with], message: validations[:coerce_message] } - validate('coerce', coerce_options, attrs, doc_attrs, opts) + validate('coerce', coerce_options, attrs, doc, opts) validations.delete(:coerce_with) validations.delete(:coerce) validations.delete(:coerce_message) @@ -430,11 +424,11 @@ def check_incompatible_option_values(default, values, except_values, excepts) unless Array(default).none? { |def_val| excepts.include?(def_val) } end - def validate(type, options, attrs, doc_attrs, opts) + def validate(type, options, attrs, doc, opts) validator_options = { attributes: attrs, options: options, - required: doc_attrs[:required], + required: doc.required, params_scope: self, opts: opts, validator_class: Validations.require_validator(type) @@ -481,17 +475,11 @@ def derive_validator_options(validations) } end - def validates_presence(validations, attrs, doc_attrs, opts) + def validates_presence(validations, attrs, doc, opts) return unless validations.key?(:presence) && validations[:presence] - validate(:presence, validations[:presence], attrs, doc_attrs, opts) - yield :presence - yield :message if validations.key?(:message) - end - - def document_attribute(attrs, doc_attrs) - full_attrs = attrs.collect { |name| { name: name, full_name: full_name(name) } } - @api.document_attribute(full_attrs, doc_attrs) + validate(:presence, validations.delete(:presence), attrs, doc, opts) + validations.delete(:message) if validations.key?(:message) end end end diff --git a/spec/grape/api/documentation_spec.rb b/spec/grape/api/documentation_spec.rb new file mode 100644 index 0000000000..f7c50fd492 --- /dev/null +++ b/spec/grape/api/documentation_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Grape::API do + subject { Class.new(described_class) } + + let(:app) { subject } + + context 'an endpoint with documentation' do + it 'documents parameters' do + subject.params do + requires 'price', type: Float, desc: 'Sales price' + end + subject.get '/' + + expect(subject.routes.first.params['price']).to eq(required: true, + type: 'Float', + desc: 'Sales price') + end + + it 'allows documentation with a hash' do + documentation = { example: 'Joe' } + + subject.params do + requires 'first_name', documentation: documentation + end + subject.get '/' + + expect(subject.routes.first.params['first_name'][:documentation]).to eq(documentation) + end + end + + context 'an endpoint without documentation' do + before do + subject.do_not_document! + + subject.params do + requires :city, type: String, desc: 'Should be ignored' + optional :postal_code, type: Integer + end + subject.post '/' do + declared(params).to_json + end + end + + it 'does not document parameters for the endpoint' do + expect(subject.routes.first.params).to eq({}) + end + + it 'still declares params internally' do + data = { city: 'Berlin', postal_code: 10_115 } + + post '/', data + + expect(last_response.body).to eq(data.to_json) + end + end +end diff --git a/spec/grape/dsl/validations_spec.rb b/spec/grape/dsl/validations_spec.rb index d70385b1c3..66db7645af 100644 --- a/spec/grape/dsl/validations_spec.rb +++ b/spec/grape/dsl/validations_spec.rb @@ -50,16 +50,6 @@ class Dummy expect { subject.params { raise 'foo' } }.to raise_error RuntimeError, 'foo' end end - - describe '.document_attribute' do - before do - subject.document_attribute([full_name: 'xxx'], foo: 'bar') - end - - it 'creates a param documentation' do - expect(subject.namespace_stackable(:params)).to eq(['xxx' => { foo: 'bar' }]) - end - end end end end diff --git a/spec/grape/validations/attributes_doc_spec.rb b/spec/grape/validations/attributes_doc_spec.rb new file mode 100644 index 0000000000..25bf40a3d5 --- /dev/null +++ b/spec/grape/validations/attributes_doc_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +describe Grape::Validations::ParamsScope::AttributesDoc do + shared_examples 'an optional doc attribute' do |attr| + it 'does not mention it' do + expected_opts.delete(attr) + validations.delete(attr) + + expect(subject.first['nested[engine_age]']).not_to have_key(attr) + end + end + + let(:api) { Class.new(Grape::API::Instance) } + let(:scope) do + params = nil + api_instance = api + + # just to get nested params + Grape::Validations::ParamsScope.new(type: Hash, api: api) do + params = Grape::Validations::ParamsScope.new(element: 'nested', + type: Hash, + api: api_instance, + parent: self) + end + + params + end + + let(:validations) do + { + presence: true, + desc: 'Age of...', + documentation: 'Age is...', + default: 1 + } + end + + let(:doc) { described_class.new(api, scope) } + + describe '#extract_details' do + subject { doc.extract_details(validations) } + + it 'cleans up doc attrs needed for documentation only' do + subject + + expect(validations[:desc]).to be_nil + expect(validations[:documentation]).to be_nil + end + + it 'does not clean up doc attrs mandatory for validators' do + subject + + expect(validations[:presence]).not_to be_nil + expect(validations[:default]).not_to be_nil + end + + it 'tells when attributes are required' do + subject + + expect(doc.required).to be_truthy + end + end + + describe '#document' do + subject do + doc.extract_details validations + doc.document attrs + end + + let(:attrs) { %w[engine_age car_age] } + let(:valid_values) { [1, 3, 5, 8] } + + let!(:expected_opts) do + { + required: true, + desc: validations[:desc], + documentation: validations[:documentation], + default: validations[:default], + type: 'Integer', + values: valid_values + } + end + + before do + doc.type = Integer + doc.values = valid_values + end + + context 'documentation is enabled' do + subject do + super() + api.namespace_stackable(:params) + end + + it 'documents attributes' do + expect(subject.first).to eq('nested[engine_age]' => expected_opts, + 'nested[car_age]' => expected_opts) + end + + it_behaves_like 'an optional doc attribute', :default + it_behaves_like 'an optional doc attribute', :documentation + it_behaves_like 'an optional doc attribute', :desc + it_behaves_like 'an optional doc attribute', :type do + before { doc.type = nil } + end + it_behaves_like 'an optional doc attribute', :values do + before { doc.values = nil } + end + + context 'false as a default value' do + before { validations[:default] = false } + + it 'is still documented' do + doc = subject.first['nested[engine_age]'] + + expect(doc).to have_key(:default) + expect(doc[:default]).to eq(false) + end + end + + context 'nil as a default value' do + before { validations[:default] = nil } + + it 'is still documented' do + doc = subject.first['nested[engine_age]'] + + expect(doc).to have_key(:default) + expect(doc[:default]).to be_nil + end + end + + context 'the description key instead of desc' do + let!(:desc) { validations.delete(:desc) } + + before { validations[:description] = desc } + + it 'adds the given description' do + expect(subject.first['nested[engine_age]'][:desc]).to eq(desc) + end + end + end + + context 'documentation is disabled' do + before { api.namespace_inheritable :do_not_document, true } + + it 'does not document attributes' do + subject + + expect(api.namespace_stackable(:params)).to eq([]) + end + end + end +end diff --git a/spec/grape/validations/params_scope_spec.rb b/spec/grape/validations/params_scope_spec.rb index 87b9bb962f..cba61b17d5 100644 --- a/spec/grape/validations/params_scope_spec.rb +++ b/spec/grape/validations/params_scope_spec.rb @@ -9,86 +9,6 @@ def app subject end - context 'setting a default' do - let(:documentation) { subject.routes.first.params } - - context 'when the default value is truthy' do - before do - subject.params do - optional :int, type: Integer, default: 42 - end - subject.get - end - - it 'adds documentation about the default value' do - expect(documentation).to have_key('int') - expect(documentation['int']).to have_key(:default) - expect(documentation['int'][:default]).to eq(42) - end - end - - context 'when the default value is false' do - before do - subject.params do - optional :bool, type: Grape::API::Boolean, default: false - end - subject.get - end - - it 'adds documentation about the default value' do - expect(documentation).to have_key('bool') - expect(documentation['bool']).to have_key(:default) - expect(documentation['bool'][:default]).to eq(false) - end - end - - context 'when the default value is nil' do - before do - subject.params do - optional :object, type: Object, default: nil - end - subject.get - end - - it 'adds documentation about the default value' do - expect(documentation).to have_key('object') - expect(documentation['object']).to have_key(:default) - expect(documentation['object'][:default]).to eq(nil) - end - end - end - - context 'without a default' do - before do - subject.params do - optional :object, type: Object - end - subject.get - end - - it 'does not add documentation for the default value' do - documentation = subject.routes.first.params - expect(documentation).to have_key('object') - expect(documentation['object']).not_to have_key(:default) - end - end - - context 'setting description' do - %i[desc description].each do |description_type| - it "allows setting #{description_type}" do - subject.params do - requires :int, type: Integer, description_type => 'My very nice integer' - end - subject.get '/single' do - 'int works' - end - get '/single', int: 420 - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('int works') - end - end - end - context 'when using custom types' do module ParamsScopeSpec class CustomType diff --git a/spec/grape/validations_spec.rb b/spec/grape/validations_spec.rb index d577dce3a2..1ad4585d25 100644 --- a/spec/grape/validations_spec.rb +++ b/spec/grape/validations_spec.rb @@ -1509,20 +1509,6 @@ def validate_param!(attr_name, params) end end - context 'documentation' do - it 'can be included with a hash' do - documentation = { example: 'Joe' } - - subject.params do - requires 'first_name', documentation: documentation - end - subject.get '/' do - end - - expect(subject.routes.first.params['first_name'][:documentation]).to eq(documentation) - end - end - context 'all or none' do context 'optional params' do before do