diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index d70c632..a3b324f 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,12 +1,12 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2025-05-28 16:52:10 UTC using RuboCop version 1.75.8. +# on 2026-06-10 02:07:47 UTC using RuboCop version 1.86.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 8 +# Offense count: 10 # Configuration parameters: AllowedMethods. # AllowedMethods: enums Lint/ConstantDefinitionInBlock: @@ -24,14 +24,16 @@ Lint/EmptyBlock: - 'spec/grape-swagger/entity/attribute_parser_spec.rb' # Offense count: 1 +# Configuration parameters: AllowComments. Lint/EmptyClass: Exclude: - 'spec/support/shared_contexts/custom_type_parser.rb' # Offense count: 2 # This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: AllowedMethods. +# Configuration parameters: AllowedMethods, InferNonNilReceiver, AdditionalNilMethods. # AllowedMethods: instance_of?, kind_of?, is_a?, eql?, respond_to?, equal? +# AdditionalNilMethods: present?, blank?, try, try! Lint/RedundantSafeNavigation: Exclude: - 'lib/grape-swagger/entity/attribute_parser.rb' @@ -39,27 +41,27 @@ Lint/RedundantSafeNavigation: # Offense count: 4 # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: - Max: 34 + Max: 25 # Offense count: 2 # Configuration parameters: CountComments, CountAsOne. Metrics/ClassLength: - Max: 145 + Max: 142 -# Offense count: 2 +# Offense count: 1 # Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/CyclomaticComplexity: - Max: 11 + Max: 9 # Offense count: 7 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: Max: 28 -# Offense count: 2 +# Offense count: 1 # Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/PerceivedComplexity: - Max: 13 + Max: 9 # Offense count: 5 # Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. @@ -86,28 +88,31 @@ RSpec/ContextWording: - 'spec/support/shared_contexts/inheritance_api.rb' - 'spec/support/shared_contexts/this_api.rb' -# Offense count: 3 +# Offense count: 5 # Configuration parameters: IgnoredMetadata. RSpec/DescribeClass: Exclude: - '**/spec/features/**/*' - - '**/spec/issues/**/*' - '**/spec/requests/**/*' - '**/spec/routing/**/*' - '**/spec/system/**/*' - '**/spec/views/**/*' + - 'spec/grape-swagger/entities/additional_properties_spec.rb' - 'spec/grape-swagger/entities/response_model_spec.rb' + - 'spec/issues/44_desc_in_entity_type_spec.rb' + - 'spec/issues/962_polymorphic_entity_with_custom_documentation_spec.rb' -# Offense count: 4 +# Offense count: 6 # Configuration parameters: CountAsOne. RSpec/ExampleLength: - Max: 225 + Max: 221 -# Offense count: 30 +# Offense count: 33 RSpec/LeakyConstantDeclaration: Exclude: - 'spec/grape-swagger/entities/response_model_spec.rb' - - '**/spec/issues/**/*' + - 'spec/issues/44_desc_in_entity_type_spec.rb' + - 'spec/issues/962_polymorphic_entity_with_custom_documentation_spec.rb' - 'spec/support/shared_contexts/inheritance_api.rb' - 'spec/support/shared_contexts/this_api.rb' @@ -116,7 +121,7 @@ RSpec/MultipleDescribes: Exclude: - 'spec/grape-swagger/entities/response_model_spec.rb' -# Offense count: 5 +# Offense count: 6 RSpec/MultipleExpectations: Max: 11 @@ -127,14 +132,13 @@ RSpec/NamedSubject: Exclude: - 'spec/grape-swagger/entities/response_model_spec.rb' -# Offense count: 46 +# Offense count: 57 # Configuration parameters: AllowedGroups. RSpec/NestedGroups: Max: 5 # Offense count: 3 -# Configuration parameters: Include, CustomTransform, IgnoreMethods, IgnoreMetadata. -# Include: **/*_spec.rb +# Configuration parameters: CustomTransform, IgnoreMethods, IgnoreMetadata. RSpec/SpecFilePathFormat: Exclude: - '**/spec/routing/**/*' @@ -157,3 +161,9 @@ Style/OpenStructUse: Exclude: - 'spec/grape-swagger/entities/response_model_spec.rb' - 'spec/support/shared_contexts/this_api.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +Style/ReduceToHash: + Exclude: + - 'lib/grape-swagger/entity/parser.rb' diff --git a/CHANGELOG.md b/CHANGELOG.md index daaf4a0..537e0c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ #### Features * Your contribution here. +* [#92](https://github.com/ruby-grape/grape-swagger-entity/pull/92) Add support for `additional_properties` in entity documentation, mirroring the [grape-swagger feature](https://github.com/ruby-grape/grape-swagger#additional-properties) for request parameters - [@olivier-thatch](https://github.com/olivier-thatch). #### Fixes diff --git a/docs/documentation-options.md b/docs/documentation-options.md index 85e2a87..43427f6 100644 --- a/docs/documentation-options.md +++ b/docs/documentation-options.md @@ -8,6 +8,7 @@ The `documentation` hash in entity exposures supports the following options: |--------|-------------|---------| | `type` | OpenAPI data type | `String`, `Integer`, `Boolean`, `Float`, `Date`, `DateTime` | | `is_array` | Marks field as array type | `true` / `false` | +| `additional_properties` | Schema for free-form object values (maps) | `true`, `String`, `'string'`, `SomeEntity` | ## Description Options @@ -87,3 +88,21 @@ expose :tags, documentation: { desc: 'Associated tags' } ``` + +### Additional properties (maps) + +For object-typed fields whose values follow a single schema (e.g. a string-to-string map), +use `additional_properties` to set the OpenAPI [`additionalProperties`](https://swagger.io/specification/v2/#model-with-mapdictionary-properties) +key on the resulting schema. + +```ruby +# Allow any additional properties +expose :metadata, documentation: { type: Hash, additional_properties: true } + +# Allow any additional properties of a particular type (Ruby class or type name) +expose :counts, documentation: { type: Hash, additional_properties: Integer } +expose :tags, documentation: { type: 'object', additional_properties: 'string' } + +# Allow any additional properties matching a defined entity +expose :widgets, documentation: { type: Hash, additional_properties: WidgetEntity } +``` diff --git a/lib/grape-swagger/entity/attribute_parser.rb b/lib/grape-swagger/entity/attribute_parser.rb index 9067cc6..3fa6637 100644 --- a/lib/grape-swagger/entity/attribute_parser.rb +++ b/lib/grape-swagger/entity/attribute_parser.rb @@ -110,9 +110,29 @@ def document_data_type(documentation, data_type) values = values.call if values.is_a?(Proc) type[:enum] = values if values.is_a?(Array) + if documentation.key?(:additional_properties) + type[:additionalProperties] = parse_additional_properties(documentation[:additional_properties]) + end + type end + def parse_additional_properties(value) + case value + when String + { type: value.to_s } + when Class + if direct_model_type?(value) || ambiguous_model_type?(value) + name = GrapeSwagger::Entity::Helper.model_name(value, endpoint) + { '$ref' => "#/definitions/#{name}" } + else + { type: GrapeSwagger::DocMethods::DataType.call(value) } + end + else + value + end + end + def entity_model_type(name, entity_options) documentation = entity_options[:documentation] diff --git a/spec/grape-swagger/entities/additional_properties_spec.rb b/spec/grape-swagger/entities/additional_properties_spec.rb new file mode 100644 index 0000000..ba542d9 --- /dev/null +++ b/spec/grape-swagger/entities/additional_properties_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module AdditionalPropertiesApi + class ValueEntity < Grape::Entity + expose :name, documentation: { type: 'string' } + end + + class WithAdditionalProperties < Grape::Entity + expose :open_map, + documentation: { type: Hash, additional_properties: true } + expose :string_map, + documentation: { type: Hash, additional_properties: String } + expose :string_map_via_type_name, + documentation: { type: 'object', additional_properties: 'string' } + expose :entity_map, + documentation: { type: Hash, additional_properties: ValueEntity } + end +end + +describe 'additional_properties' do + subject(:swagger_doc) do + get '/swagger_doc' + JSON.parse(last_response.body) + end + + let(:app) do + Class.new(Grape::API) do + namespace :additional_properties do + desc 'Get a thing', success: AdditionalPropertiesApi::WithAdditionalProperties + get '/thing' do + present({}, with: AdditionalPropertiesApi::WithAdditionalProperties) + end + end + + add_swagger_documentation format: :json + end + end + + let(:properties) { swagger_doc['definitions']['AdditionalPropertiesApi_WithAdditionalProperties']['properties'] } + + it 'documents boolean additional_properties' do + expect(properties['open_map']).to eq('type' => 'object', 'additionalProperties' => true) + end + + it 'documents a primitive Ruby class as a typed schema' do + expect(properties['string_map']).to eq( + 'type' => 'object', + 'additionalProperties' => { 'type' => 'string' } + ) + end + + it 'documents a string type name as a typed schema' do + expect(properties['string_map_via_type_name']).to eq( + 'type' => 'object', + 'additionalProperties' => { 'type' => 'string' } + ) + end + + it 'documents an entity class as a $ref' do + expect(properties['entity_map']).to eq( + 'type' => 'object', + 'additionalProperties' => { '$ref' => '#/definitions/AdditionalPropertiesApi_ValueEntity' } + ) + end + + it 'registers the referenced entity in definitions' do + expect(swagger_doc['definitions']).to include('AdditionalPropertiesApi_ValueEntity') + expect(swagger_doc['definitions']['AdditionalPropertiesApi_ValueEntity']).to include( + 'type' => 'object', + 'properties' => { 'name' => { 'type' => 'string' } } + ) + end +end diff --git a/spec/grape-swagger/entity/attribute_parser_spec.rb b/spec/grape-swagger/entity/attribute_parser_spec.rb index 12ab99a..5d96196 100644 --- a/spec/grape-swagger/entity/attribute_parser_spec.rb +++ b/spec/grape-swagger/entity/attribute_parser_spec.rb @@ -341,6 +341,47 @@ def self.to_s it { is_expected.to include(type: 'object', example: example_value, default: example_value) } end end + + context 'when additional_properties is set' do + context 'when the value is true' do + let(:entity_options) { { documentation: { type: Hash, additional_properties: true } } } + + it { is_expected.to include(type: 'object', additionalProperties: true) } + end + + context 'when the value is false' do + let(:entity_options) { { documentation: { type: Hash, additional_properties: false } } } + + it { is_expected.to include(type: 'object', additionalProperties: false) } + end + + context 'when the value is a primitive class' do + let(:entity_options) { { documentation: { type: Hash, additional_properties: String } } } + + it { is_expected.to include(type: 'object', additionalProperties: { type: 'string' }) } + end + + context 'when the value is a type name string' do + let(:entity_options) { { documentation: { type: 'object', additional_properties: 'string' } } } + + it { is_expected.to include(type: 'object', additionalProperties: { type: 'string' }) } + end + + context 'when the value is an entity class' do + let(:entity_options) { { documentation: { type: Hash, additional_properties: ThisApi::Entities::Tag } } } + + it { is_expected.to include(type: 'object', additionalProperties: { '$ref' => '#/definitions/Tag' }) } + end + + context 'when combined with is_array: true' do + let(:entity_options) do + { documentation: { type: 'object', is_array: true, additional_properties: 'string' } } + end + + it { is_expected.to include(type: :array) } + it { is_expected.to include(items: { type: 'object', additionalProperties: { type: 'string' } }) } + end + end end end end