├── .rspec ├── lib ├── grape-swagger-entity.rb └── grape-swagger │ ├── entity │ ├── version.rb │ ├── helper.rb │ ├── attribute_parser.rb │ └── parser.rb │ └── entity.rb ├── Dangerfile ├── bin ├── setup └── pry ├── .gitignore ├── spec ├── grape-swagger │ ├── entity_spec.rb │ ├── entity │ │ ├── parser_spec.rb │ │ └── attribute_parser_spec.rb │ └── entities │ │ └── response_model_spec.rb ├── support │ └── shared_contexts │ │ ├── inheritance_api.rb │ │ ├── custom_type_parser.rb │ │ └── this_api.rb └── spec_helper.rb ├── Rakefile ├── .github └── workflows │ ├── danger.yml │ └── test.yml ├── grape-swagger-entity.gemspec ├── .rubocop.yml ├── LICENSE.txt ├── Gemfile ├── README.md ├── RELEASING.md ├── UPGRADING.md ├── .rubocop_todo.yml └── CHANGELOG.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /lib/grape-swagger-entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'grape-swagger/entity' 4 | -------------------------------------------------------------------------------- /Dangerfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | danger.import_dangerfile(gem: 'ruby-grape-danger') 4 | -------------------------------------------------------------------------------- /lib/grape-swagger/entity/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GrapeSwagger 4 | module Entity 5 | VERSION = '0.7.0' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | .ruby-gemset 11 | .ruby-version 12 | /.idea 13 | -------------------------------------------------------------------------------- /spec/grape-swagger/entity_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe GrapeSwagger::Entity do 6 | it 'has a version number' do 7 | expect(GrapeSwagger::Entity::VERSION).not_to be_nil 8 | end 9 | 10 | it 'parser should be registred' do 11 | expect(GrapeSwagger.model_parsers.to_a).to include([GrapeSwagger::Entity::Parser, 'Grape::Entity']) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubygems' 4 | require 'bundler' 5 | 6 | Bundler.setup(:default, :development) 7 | 8 | require 'rake' 9 | 10 | Bundler::GemHelper.install_tasks 11 | 12 | require 'rspec/core' 13 | require 'rspec/core/rake_task' 14 | 15 | RSpec::Core::RakeTask.new(:spec) 16 | 17 | require 'rubocop/rake_task' 18 | RuboCop::RakeTask.new(:rubocop) 19 | 20 | task default: %i[spec rubocop] 21 | -------------------------------------------------------------------------------- /bin/pry: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'grape-swagger/entity' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'pry' 15 | Pry.start 16 | -------------------------------------------------------------------------------- /lib/grape-swagger/entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'grape-swagger' 4 | require 'grape-entity' 5 | 6 | require 'grape-swagger/entity/version' 7 | require 'grape-swagger/entity/helper' 8 | require 'grape-swagger/entity/attribute_parser' 9 | require 'grape-swagger/entity/parser' 10 | 11 | module GrapeSwagger 12 | module Entity 13 | end 14 | end 15 | 16 | GrapeSwagger.model_parsers.register(GrapeSwagger::Entity::Parser, Grape::Entity) 17 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/inheritance_api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | shared_context 'inheritance api' do 4 | before :all do 5 | module InheritanceApi 6 | module Entities 7 | class Parent < Grape::Entity 8 | expose :type, documentation: { type: 'string', is_discriminator: true, required: true } 9 | expose :id, documentation: { type: 'integer' } 10 | end 11 | 12 | class Child < Parent 13 | expose :name, documentation: { type: 'string', desc: 'Name' } 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/custom_type_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | CustomType = Class.new 4 | 5 | class CustomTypeParser 6 | attr_reader :model, :endpoint 7 | 8 | def initialize(model, endpoint) 9 | @model = model 10 | @endpoint = endpoint 11 | end 12 | 13 | def call 14 | { 15 | model.name.to_sym => { 16 | type: 'custom_type', 17 | description: "it's a custom type", 18 | data: { 19 | name: model.name 20 | } 21 | } 22 | } 23 | end 24 | end 25 | 26 | GrapeSwagger.model_parsers.register(CustomTypeParser, CustomType) 27 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path('lib', __dir__) 4 | 5 | require 'grape-swagger/entity' 6 | require 'grape' 7 | 8 | Bundler.setup :default, :test 9 | 10 | require 'rack' 11 | require 'rack/test' 12 | 13 | RSpec.configure do |config| 14 | require 'rspec/expectations' 15 | config.include RSpec::Matchers 16 | config.mock_with :rspec 17 | config.include Rack::Test::Methods 18 | config.raise_errors_for_deprecations! 19 | 20 | config.order = 'random' 21 | config.seed = 40_834 22 | end 23 | 24 | Dir['spec/support/**/*.rb'].each { |file| require "./#{file}" } 25 | -------------------------------------------------------------------------------- /.github/workflows/danger.yml: -------------------------------------------------------------------------------- 1 | name: danger 2 | on: pull_request 3 | 4 | jobs: 5 | danger: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | with: 10 | fetch-depth: 100 11 | - name: Set up Ruby 12 | uses: ruby/setup-ruby@v1 13 | with: 14 | ruby-version: 3.2 15 | bundler-cache: true 16 | rubygems: latest 17 | - name: Run Danger 18 | run: | 19 | # the token is public, has public_repo scope and belongs to the grape-bot user owned by @dblock, this is ok 20 | TOKEN=$(echo -n Z2hwX2lYb0dPNXNyejYzOFJyaTV3QUxUdkNiS1dtblFwZTFuRXpmMwo= | base64 --decode) 21 | DANGER_GITHUB_API_TOKEN=$TOKEN bundle exec danger --verbose 22 | -------------------------------------------------------------------------------- /grape-swagger-entity.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'grape-swagger/entity/version' 6 | 7 | Gem::Specification.new do |s| 8 | s.name = 'grape-swagger-entity' 9 | s.version = GrapeSwagger::Entity::VERSION 10 | s.authors = ['Kirill Zaitsev'] 11 | s.email = ['kirik910@gmail.com'] 12 | 13 | s.summary = 'Grape swagger adapter to support grape-entity object parsing' 14 | s.homepage = 'https://github.com/ruby-grape/grape-swagger-entity' 15 | s.license = 'MIT' 16 | 17 | s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | s.bindir = 'exe' 19 | s.executables = s.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | s.require_paths = ['lib'] 21 | 22 | s.required_ruby_version = '>= 3.0' 23 | s.add_dependency 'grape-entity', '~> 1' 24 | s.add_dependency 'grape-swagger', '~> 2' 25 | s.metadata['rubygems_mfa_required'] = 'true' 26 | end 27 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | Exclude: 4 | - vendor/**/* 5 | TargetRubyVersion: 6 | 3.0 7 | 8 | inherit_from: .rubocop_todo.yml 9 | 10 | plugins: 11 | - rubocop-rake 12 | - rubocop-rspec 13 | 14 | Naming/FileName: 15 | Enabled: false 16 | 17 | Layout/FirstHashElementIndentation: 18 | EnforcedStyle: consistent 19 | 20 | Layout/EmptyLinesAroundAttributeAccessor: 21 | Enabled: true 22 | Layout/SpaceAroundMethodCallOperator: 23 | Enabled: true 24 | Lint/DeprecatedOpenSSLConstant: 25 | Enabled: true 26 | Lint/MixedRegexpCaptureTypes: 27 | Enabled: true 28 | Lint/RaiseException: 29 | Enabled: true 30 | Lint/StructNewOverride: 31 | Enabled: true 32 | Style/ExponentialNotation: 33 | Enabled: true 34 | Style/HashEachMethods: 35 | Enabled: true 36 | Style/HashTransformKeys: 37 | Enabled: true 38 | Style/HashTransformValues: 39 | Enabled: true 40 | Style/RedundantFetchBlock: 41 | Enabled: true 42 | Style/RedundantRegexpCharacterClass: 43 | Enabled: true 44 | Style/RedundantRegexpEscape: 45 | Enabled: true 46 | Style/SlicingWithRange: 47 | Enabled: true 48 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Kirill Zaitsev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/grape-swagger/entity/helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GrapeSwagger 4 | module Entity 5 | # Helper methods for DRY 6 | class Helper 7 | class << self 8 | def model_name(entity_model, endpoint) 9 | if endpoint.nil? 10 | entity_model.to_s.demodulize 11 | else 12 | endpoint.send(:expose_params_from_model, entity_model) 13 | end 14 | end 15 | 16 | def discriminator(entity_model) 17 | entity_model.superclass.root_exposures.detect do |value| 18 | value.documentation&.dig(:is_discriminator) 19 | end 20 | end 21 | 22 | def root_exposures_without_parent(entity_model) 23 | entity_model.root_exposures.select do |value| 24 | entity_model.superclass.root_exposures.find_by(value.attribute).nil? 25 | end 26 | end 27 | 28 | def root_exposure_with_discriminator(entity_model) 29 | if discriminator(entity_model) 30 | root_exposures_without_parent(entity_model) 31 | else 32 | entity_model.root_exposures 33 | end 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in grape-swagger-entity.gemspec 6 | gemspec 7 | 8 | grape_version = ENV.fetch('GRAPE_VERSION', '< 3.0') 9 | grape_swagger_version = ENV.fetch('GRAPE_SWAGGER_VERSION', '< 3.0') 10 | grape_entity_version = ENV.fetch('GRAPE_ENTITY_VERSION', '< 2.0') 11 | 12 | grape_spec = grape_version.casecmp('HEAD').zero? ? { git: 'https://github.com/ruby-grape/grape' } : grape_version 13 | grape_swagger_spec = if grape_swagger_version.casecmp('HEAD').zero? 14 | { git: 'https://github.com/ruby-grape/grape-swagger.git' } 15 | else 16 | grape_swagger_version 17 | end 18 | grape_entity_spec = if grape_entity_version.casecmp('HEAD').zero? 19 | { git: 'https://github.com/ruby-grape/grape-entity.git' } 20 | else 21 | grape_entity_version 22 | end 23 | 24 | gem 'grape', grape_spec 25 | gem 'grape-swagger', grape_swagger_spec 26 | 27 | group :development, :test do 28 | gem 'bundler' 29 | gem 'pry', platforms: [:mri] 30 | gem 'pry-byebug', platforms: [:mri] 31 | gem 'rack' 32 | gem 'rack-cors' 33 | gem 'rack-test' 34 | gem 'rake' 35 | gem 'rdoc' 36 | gem 'rspec' 37 | end 38 | 39 | group :development do 40 | gem 'rubocop', '>= 1.72', require: false 41 | gem 'rubocop-rake', '>= 0.7', require: false 42 | gem 'rubocop-rspec', '>= 3.5.0', require: false 43 | end 44 | 45 | group :test do 46 | gem 'grape-entity', grape_entity_spec 47 | gem 'ruby-grape-danger', '~> 0.2.1', require: false 48 | gem 'simplecov', require: false 49 | end 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GrapeSwagger::Entity 2 | 3 | [![Gem Version](https://badge.fury.io/rb/grape-swagger-entity.svg)](https://badge.fury.io/rb/grape-swagger-entity) 4 | [![Build Status](https://github.com/ruby-grape/grape-swagger-entity/actions/workflows/test.yml/badge.svg)](https://github.com/ruby-grape/grape-swagger-entity/actions/workflows/test.yml) 5 | 6 | ## Table of Contents 7 | 8 | - [What is grape-swagger-entity?](#what-is-grape-swagger-entity) 9 | - [Installation](#installation) 10 | - [Development](#development) 11 | - [Contributing](#contributing) 12 | - [License](#license) 13 | 14 | 15 | ## What is grape-swagger-entity? 16 | 17 | A simple grape-swagger adapter to allow parse representers as response model 18 | 19 | ## Installation 20 | 21 | Add this line to your application's Gemfile: 22 | 23 | ```ruby 24 | gem 'grape-swagger-entity' 25 | ``` 26 | 27 | And then execute: 28 | 29 | $ bundle 30 | 31 | Or install it yourself as: 32 | 33 | $ gem install grape-swagger-entity 34 | 35 | ## Development 36 | 37 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rspec` to run the tests. You can also run `bin/pry` for an interactive prompt that will allow you to experiment. 38 | 39 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 40 | 41 | ## Contributing 42 | 43 | Bug reports and pull requests are welcome on GitHub at https://github.com/ruby-grape/grape-swagger-entity. 44 | 45 | ## License 46 | 47 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 48 | 49 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing Grape-Swagger-Entity 2 | 3 | There are no particular rules about when to release Grape-Swagger-Entity. Release bug fixes frequent, features not so frequently and breaking API changes rarely. 4 | 5 | ### Release 6 | 7 | Run tests, check that all tests succeed locally. 8 | 9 | ``` 10 | bundle install 11 | rake 12 | ``` 13 | 14 | Check that the last build succeeded in [GitHub Actions](https://github.com/ruby-grape/grape-swagger-entity/actions) for all supported platforms. 15 | 16 | Increment the version, modify [lib/grape-swagger/entity/version.rb](lib/grape-swagger/entity/version.rb). 17 | 18 | * Increment the third number if the release has bug fixes and/or very minor features, only (eg. change `0.1.0` to `0.1.1`). 19 | * Increment the second number if the release contains major features or breaking API changes (eg. change `0.1.0` to `0.2.0`). 20 | 21 | Change "Next Release" in [CHANGELOG.md](CHANGELOG.md) to the new version. 22 | 23 | ``` 24 | ### 0.1.1 (February 5, 2015) 25 | ``` 26 | 27 | Remove the line with "Your contribution here.", since there will be no more contributions to this release. 28 | 29 | Commit your changes. 30 | 31 | ``` 32 | git add CHANGELOG.md lib/grape-swagger/entity/version.rb CHANGELOG.md 33 | git commit -m "Preparing for release, 0.1.1." 34 | git push origin master 35 | ``` 36 | 37 | Release. 38 | 39 | ``` 40 | $ rake release 41 | 42 | Grape-Swagger-Entity 0.1.1 built to pkg/grape-swagger-entity-0.1.1.gem. 43 | Tagged v0.1.1. 44 | Pushed git commits and tags. 45 | Pushed grape-swagger-entity 0.1.1 to rubygems.org. 46 | ``` 47 | 48 | ### Prepare for the Next Version 49 | 50 | Add the next release to [CHANGELOG.md](CHANGELOG.md). 51 | 52 | ``` 53 | ### Next Release 54 | 55 | * Your contribution here. 56 | ``` 57 | 58 | Commit your changes. 59 | 60 | ``` 61 | git add CHANGELOG.md 62 | git commit -m "Preparing for next release." 63 | git push origin master 64 | ``` 65 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | ## Upgrading grape-swagger-entity 2 | 3 | ### Upgrading to >= 0.7.0 4 | 5 | #### Entity Fields Required by Default 6 | 7 | This release changes how `grape-swagger-entity` determines if an entity field is 8 | **required** in the generated Swagger documentation. This is a **breaking change** 9 | and may require updates to your API documentation and any tools or tests that rely 10 | on the previous behavior. 11 | 12 | **Previous Behavior:** 13 | Fields were considered optional by default unless explicitly marked as `required: true` 14 | in their `documentation` options. 15 | 16 | **New Behavior:** 17 | Fields are now considered **required by default** unless one of the following 18 | conditions is met: 19 | 20 | 1. **`documentation: { required: false }` is explicitly set:** If you want a field to 21 | be optional, you must now explicitly set `required: false` within its 22 | `documentation` hash. 23 | ```ruby 24 | expose :field_name, 25 | documentation: { type: String, required: false, desc: 'An optional field' } 26 | ``` 27 | 2. **`if` or `unless` options are present:** If a field uses `if` or `unless` for 28 | conditional exposure, it will be considered optional. 29 | ```ruby 30 | expose :conditional_field, 31 | if: -> { some_condition? }, 32 | documentation: { type: String, desc: 'Exposed only if condition is met' } 33 | ``` 34 | 3. **`expose_nil: false` is set:** If `expose_nil` is set to `false`, the field will 35 | be considered optional. 36 | ```ruby 37 | expose :non_nil_field, 38 | expose_nil: false, 39 | documentation: { type: String, desc: 'Not exposed if nil' } 40 | ``` 41 | 42 | This change aligns `grape-swagger-entity`'s behavior with `grape-entity`'s rendering 43 | logic, where fields are always exposed unless `if` or `unless` is provided. 44 | 45 | **Action Required:** 46 | Review your existing Grape entities. If you have fields that were implicitly 47 | considered optional but did not explicitly set `required: false`, `if`, `unless`, or 48 | `expose_nil: false`, they will now be marked as required in your Swagger 49 | documentation.Adjust your `documentation` options accordingly to maintain the desired 50 | optionality for these fields. 51 | 52 | For more details, refer to GitHub Pull Request 53 | [#81](https://github.com/ruby-grape/grape-swagger-entity/pull/81). 54 | ```` 55 | -------------------------------------------------------------------------------- /spec/grape-swagger/entity/parser_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require_relative '../../../spec/support/shared_contexts/this_api' 5 | 6 | describe GrapeSwagger::Entity::Parser do 7 | context 'this api' do 8 | include_context 'this api' 9 | 10 | describe '#call' do 11 | let(:parsed_entity) { described_class.new(ThisApi::Entities::Something, endpoint).call } 12 | let(:properties) { parsed_entity.first } 13 | let(:required) { parsed_entity.last } 14 | 15 | context 'when no endpoint is passed' do 16 | let(:endpoint) { nil } 17 | 18 | it 'parses the model with the correct :using definition' do 19 | expect(properties[:kind]['$ref']).to eq('#/definitions/Kind') 20 | expect(properties[:kind2]['$ref']).to eq('#/definitions/Kind') 21 | expect(properties[:kind3]['$ref']).to eq('#/definitions/Kind') 22 | end 23 | 24 | it 'merges attributes that have merge: true defined' do 25 | expect(properties[:merged_attribute]).to be_nil 26 | expect(properties[:code][:type]).to eq('string') 27 | expect(properties[:message][:type]).to eq('string') 28 | expect(properties[:attr][:type]).to eq('string') 29 | end 30 | 31 | it 'hides hidden attributes' do 32 | expect(properties).not_to include(:hidden_attr) 33 | end 34 | end 35 | end 36 | end 37 | 38 | context 'inheritance api' do 39 | include_context 'inheritance api' 40 | 41 | describe '#call for Parent' do 42 | let(:parsed_entity) do 43 | described_class.new(InheritanceApi::Entities::Parent, endpoint).call 44 | end 45 | let(:properties) { parsed_entity.first } 46 | 47 | context 'when no endpoint is passed' do 48 | let(:endpoint) { nil } 49 | 50 | it 'parses the model with discriminator' do 51 | expect(properties[:type][:documentation]).to eq(is_discriminator: true) 52 | end 53 | end 54 | end 55 | 56 | describe '#call for Child' do 57 | let(:parsed_entity) do 58 | described_class.new(InheritanceApi::Entities::Child, endpoint).call 59 | end 60 | let(:properties) { parsed_entity } 61 | 62 | context 'when no endpoint is passed' do 63 | let(:endpoint) { nil } 64 | 65 | it 'parses the model with allOf' do 66 | expect(properties).to include(:allOf) 67 | all_of = properties[:allOf] 68 | child_property = all_of.last.first 69 | child_required = all_of.last.last 70 | expect(all_of.first['$ref']).to eq('#/definitions/Parent') 71 | expect(child_property[:name][:type]).to eq('string') 72 | expect(child_property[:type][:type]).to eq('string') 73 | expect(child_property[:type][:enum]).to eq(['Child']) 74 | expect(child_required).to include(:type) 75 | end 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | lint: 10 | name: RuboCop 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Ruby 16 | uses: ruby/setup-ruby@v1 17 | with: 18 | ruby-version: 3.0 19 | bundler-cache: true 20 | rubygems: latest 21 | 22 | - name: Run RuboCop 23 | run: bundle exec rubocop 24 | 25 | test: 26 | name: test (ruby=${{ matrix.ruby }}, grape=${{ matrix.grape }}, grape-swagger=${{ matrix.grape_swagger}}, grape_entity=${{ matrix.grape_entity }}) 27 | strategy: 28 | matrix: 29 | include: 30 | # Ruby 3.1 combinations 31 | - ruby: '3.0' 32 | grape: '~> 2.0.0' 33 | grape_swagger: '~> 2.0.3' 34 | grape_entity: '~> 1.0.1' 35 | - ruby: '3.0' 36 | grape: '~> 2.0.0' 37 | grape_swagger: '~> 2.1.1' 38 | grape_entity: 'head' 39 | 40 | # Ruby 3.4 combinations 41 | - ruby: '3.4' 42 | grape: '~> 2.0.0' 43 | grape_swagger: '~> 2.0.3' 44 | grape_entity: '~> 1.0.1' 45 | - ruby: '3.4' 46 | grape: '~> 2.0.0' 47 | grape_swagger: '~> 2.0.3' 48 | grape_entity: 'head' 49 | - ruby: '3.4' 50 | grape: '~> 2.0.0' 51 | grape_swagger: 'head' 52 | grape_entity: '~> 1.0.1' 53 | - ruby: '3.4' 54 | grape: '~> 2.0.0' 55 | grape_swagger: 'head' 56 | grape_entity: 'head' 57 | # - ruby: '3.4' 58 | # grape: 'head' 59 | # grape_swagger: 'head' 60 | # grape_entity: '~> 1.0.1' 61 | # - ruby: '3.4' 62 | # grape: 'head' 63 | # grape_swagger: 'head' 64 | # grape_entity: 'head' 65 | 66 | # Ruby head combinations 67 | - ruby: 'head' 68 | grape: '~> 2.0.0' 69 | grape_swagger: '~> 2.0.3' 70 | grape_entity: '~> 1.0.1' 71 | - ruby: 'head' 72 | grape: '~> 2.0.0' 73 | grape_swagger: '~> 2.0.3' 74 | grape_entity: 'head' 75 | - ruby: 'head' 76 | grape: '~> 2.0.0' 77 | grape_swagger: 'head' 78 | grape_entity: '~> 1.0.1' 79 | 80 | # - ruby: 'head' 81 | # grape: '~> 2.0.0' 82 | # grape_swagger: 'head' 83 | # grape_entity: 'head' 84 | # - ruby: 'head' 85 | # grape: 'head' 86 | # grape_swagger: 'head' 87 | # grape_entity: '~> 1.0.1' 88 | # - ruby: 'head' 89 | # grape: 'head' 90 | # grape_swagger: 'head' 91 | # grape_entity: 'head' 92 | 93 | runs-on: ubuntu-latest 94 | env: 95 | GRAPE_VERSION: ${{ matrix.grape }} 96 | GRAPE_SWAGGER_VERSION: ${{ matrix.grape_swagger }} 97 | GRAPE_ENTITY_VERSION: ${{ matrix.grape_entity }} 98 | 99 | steps: 100 | - uses: actions/checkout@v4 101 | - name: Set up Ruby 102 | uses: ruby/setup-ruby@v1 103 | with: 104 | ruby-version: ${{ matrix.ruby }} 105 | bundler-cache: true 106 | - name: Run tests 107 | run: bundle exec rake spec 108 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/this_api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | shared_context 'this api' do 4 | before :all do 5 | module ThisApi 6 | module Entities 7 | class Kind < Grape::Entity 8 | expose :title, documentation: { type: 'string', desc: 'Title of the kind.' } 9 | expose :content, documentation: { type: 'string', desc: 'Content', x: { some: 'stuff' } } 10 | end 11 | 12 | class Relation < Grape::Entity 13 | expose :name, documentation: { type: 'string', desc: 'Name' } 14 | end 15 | 16 | class Tag < Grape::Entity 17 | expose :name, documentation: { type: 'string', desc: 'Name' } 18 | end 19 | 20 | class Error < Grape::Entity 21 | expose :code, documentation: { type: 'string', desc: 'Error code' } 22 | expose :message, documentation: { type: 'string', desc: 'Error message' } 23 | end 24 | 25 | class Nested < Grape::Entity 26 | expose :attr, documentation: { required: true, type: 'string', desc: 'Attribute' } 27 | expose :nested_attrs, merge: true, using: ThisApi::Entities::Error 28 | end 29 | 30 | class Something < Grape::Entity 31 | expose :text, documentation: { type: 'string', desc: 'Content of something.' } 32 | expose :colors, documentation: { type: 'string', desc: 'Colors', is_array: true } 33 | expose :hidden_attr, documentation: { type: 'string', desc: 'Hidden', hidden: true } 34 | expose :created_at, documentation: { type: Time, desc: 'Created at the time.' } 35 | expose :kind, using: Kind, documentation: { type: 'ThisApi::Kind', desc: 'The kind of this something.' } 36 | expose :kind2, using: Kind, documentation: { desc: 'Secondary kind.' } 37 | expose :kind3, using: ThisApi::Entities::Kind, documentation: { desc: 'Tertiary kind.' } 38 | expose :tags, using: ThisApi::Entities::Tag, documentation: { desc: 'Tags.', is_array: true } 39 | expose :relation, using: ThisApi::Entities::Relation, 40 | documentation: { 41 | type: 'ThisApi::Relation', 42 | desc: 'A related model.', 43 | x: { other: 'stuff' } 44 | } 45 | expose :merged_attribute, using: ThisApi::Entities::Nested, merge: true 46 | end 47 | end 48 | 49 | class ResponseModelApi < Grape::API 50 | format :json 51 | desc 'This returns something', 52 | is_array: true, 53 | http_codes: [{ code: 200, message: 'OK', model: Entities::Something }] 54 | get '/something' do 55 | something = OpenStruct.new text: 'something' 56 | present something, with: Entities::Something 57 | end 58 | 59 | # something like an index action 60 | desc 'This returns something or an error', 61 | entity: Entities::Something, 62 | http_codes: [ 63 | { code: 200, message: 'OK', model: Entities::Something }, 64 | { code: 403, message: 'Refused to return something', model: Entities::Error } 65 | ] 66 | params do 67 | optional :id, type: Integer 68 | end 69 | get '/something/:id' do 70 | if params[:id] == 1 71 | something = OpenStruct.new text: 'something' 72 | present something, with: Entities::Something 73 | else 74 | error = OpenStruct.new code: 'some_error', message: 'Some error' 75 | present error, with: Entities::Error 76 | end 77 | end 78 | 79 | add_swagger_documentation 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2025-05-28 16:52:10 UTC using RuboCop version 1.75.8. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 3 10 | # Configuration parameters: AllowedMethods. 11 | # AllowedMethods: enums 12 | Lint/ConstantDefinitionInBlock: 13 | Exclude: 14 | - 'spec/grape-swagger/entities/response_model_spec.rb' 15 | - 'spec/support/shared_contexts/inheritance_api.rb' 16 | - 'spec/support/shared_contexts/this_api.rb' 17 | 18 | # Offense count: 1 19 | # Configuration parameters: AllowComments, AllowEmptyLambdas. 20 | Lint/EmptyBlock: 21 | Exclude: 22 | - 'spec/grape-swagger/entity/attribute_parser_spec.rb' 23 | 24 | # Offense count: 2 25 | # This cop supports unsafe autocorrection (--autocorrect-all). 26 | # Configuration parameters: AllowedMethods. 27 | # AllowedMethods: instance_of?, kind_of?, is_a?, eql?, respond_to?, equal? 28 | Lint/RedundantSafeNavigation: 29 | Exclude: 30 | - 'lib/grape-swagger/entity/attribute_parser.rb' 31 | 32 | # Offense count: 4 33 | # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. 34 | Metrics/AbcSize: 35 | Max: 34 36 | 37 | # Offense count: 2 38 | # Configuration parameters: CountComments, CountAsOne. 39 | Metrics/ClassLength: 40 | Max: 117 41 | 42 | # Offense count: 2 43 | # Configuration parameters: AllowedMethods, AllowedPatterns. 44 | Metrics/CyclomaticComplexity: 45 | Max: 11 46 | 47 | # Offense count: 7 48 | # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. 49 | Metrics/MethodLength: 50 | Max: 28 51 | 52 | # Offense count: 2 53 | # Configuration parameters: AllowedMethods, AllowedPatterns. 54 | Metrics/PerceivedComplexity: 55 | Max: 13 56 | 57 | # Offense count: 5 58 | # Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. 59 | # SupportedStyles: snake_case, normalcase, non_integer 60 | # AllowedIdentifiers: TLS1_1, TLS1_2, capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64 61 | Naming/VariableNumber: 62 | Exclude: 63 | - 'spec/grape-swagger/entities/response_model_spec.rb' 64 | 65 | # Offense count: 1 66 | RSpec/BeforeAfterAll: 67 | Exclude: 68 | - '**/spec/spec_helper.rb' 69 | - '**/spec/rails_helper.rb' 70 | - '**/spec/support/**/*.rb' 71 | - 'spec/grape-swagger/entities/response_model_spec.rb' 72 | 73 | # Offense count: 4 74 | # Configuration parameters: Prefixes, AllowedPatterns. 75 | # Prefixes: when, with, without 76 | RSpec/ContextWording: 77 | Exclude: 78 | - 'spec/grape-swagger/entity/parser_spec.rb' 79 | - 'spec/support/shared_contexts/inheritance_api.rb' 80 | - 'spec/support/shared_contexts/this_api.rb' 81 | 82 | # Offense count: 2 83 | # Configuration parameters: IgnoredMetadata. 84 | RSpec/DescribeClass: 85 | Exclude: 86 | - '**/spec/features/**/*' 87 | - '**/spec/requests/**/*' 88 | - '**/spec/routing/**/*' 89 | - '**/spec/system/**/*' 90 | - '**/spec/views/**/*' 91 | - 'spec/grape-swagger/entities/response_model_spec.rb' 92 | 93 | # Offense count: 4 94 | # Configuration parameters: CountAsOne. 95 | RSpec/ExampleLength: 96 | Max: 213 97 | 98 | # Offense count: 26 99 | RSpec/LeakyConstantDeclaration: 100 | Exclude: 101 | - 'spec/grape-swagger/entities/response_model_spec.rb' 102 | - 'spec/support/shared_contexts/inheritance_api.rb' 103 | - 'spec/support/shared_contexts/this_api.rb' 104 | 105 | # Offense count: 1 106 | RSpec/MultipleDescribes: 107 | Exclude: 108 | - 'spec/grape-swagger/entities/response_model_spec.rb' 109 | 110 | # Offense count: 5 111 | RSpec/MultipleExpectations: 112 | Max: 11 113 | 114 | # Offense count: 21 115 | # Configuration parameters: EnforcedStyle, IgnoreSharedExamples. 116 | # SupportedStyles: always, named_only 117 | RSpec/NamedSubject: 118 | Exclude: 119 | - 'spec/grape-swagger/entities/response_model_spec.rb' 120 | 121 | # Offense count: 46 122 | # Configuration parameters: AllowedGroups. 123 | RSpec/NestedGroups: 124 | Max: 5 125 | 126 | # Offense count: 3 127 | # Configuration parameters: Include, CustomTransform, IgnoreMethods, IgnoreMetadata. 128 | # Include: **/*_spec.rb 129 | RSpec/SpecFilePathFormat: 130 | Exclude: 131 | - '**/spec/routing/**/*' 132 | - 'spec/grape-swagger/entity/attribute_parser_spec.rb' 133 | - 'spec/grape-swagger/entity/parser_spec.rb' 134 | - 'spec/grape-swagger/entity_spec.rb' 135 | 136 | # Offense count: 4 137 | # Configuration parameters: AllowedConstants. 138 | Style/Documentation: 139 | Exclude: 140 | - 'spec/**/*' 141 | - 'test/**/*' 142 | - 'lib/grape-swagger/entity.rb' 143 | - 'lib/grape-swagger/entity/attribute_parser.rb' 144 | - 'lib/grape-swagger/entity/parser.rb' 145 | 146 | # Offense count: 4 147 | Style/OpenStructUse: 148 | Exclude: 149 | - 'spec/grape-swagger/entities/response_model_spec.rb' 150 | - 'spec/support/shared_contexts/this_api.rb' 151 | -------------------------------------------------------------------------------- /lib/grape-swagger/entity/attribute_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GrapeSwagger 4 | module Entity 5 | class AttributeParser 6 | attr_reader :endpoint 7 | 8 | def initialize(endpoint) 9 | @endpoint = endpoint 10 | end 11 | 12 | def call(entity_options) 13 | param = if (entity_model = model_from(entity_options)) 14 | name = GrapeSwagger::Entity::Helper.model_name(entity_model, endpoint) 15 | entity_model_type(name, entity_options) 16 | else 17 | data_type_from(entity_options) 18 | end 19 | 20 | documentation = entity_options[:documentation] 21 | return param if documentation.nil? 22 | 23 | add_array_documentation(param, documentation) if documentation[:is_array] 24 | 25 | add_attribute_sample(param, documentation, :default) 26 | add_attribute_sample(param, documentation, :example) 27 | 28 | add_attribute_documentation(param, documentation) 29 | 30 | add_extension_documentation(param, documentation) 31 | add_discriminator_extension(param, documentation) 32 | param 33 | end 34 | 35 | private 36 | 37 | def model_from(entity_options) 38 | model = entity_options[:using] if entity_options[:using].present? 39 | 40 | model ||= entity_options[:documentation][:type] if could_it_be_a_model?(entity_options[:documentation]) 41 | 42 | model 43 | end 44 | 45 | def could_it_be_a_model?(value) 46 | return false if value.nil? 47 | 48 | direct_model_type?(value[:type]) || ambiguous_model_type?(value[:type]) 49 | end 50 | 51 | def direct_model_type?(type) 52 | type.to_s.include?('Entity') || type.to_s.include?('Entities') 53 | end 54 | 55 | def ambiguous_model_type?(type) 56 | type&.is_a?(Class) && !primitive_type?(type) 57 | end 58 | 59 | def primitive_type?(type) 60 | data_type = GrapeSwagger::DocMethods::DataType.call(type) 61 | GrapeSwagger::DocMethods::DataType.request_primitive?(data_type) 62 | end 63 | 64 | def data_type_from(entity_options) 65 | documentation = entity_options[:documentation] || {} 66 | documented_type = entity_options[:type] || documentation[:type] 67 | 68 | data_type = GrapeSwagger::DocMethods::DataType.call(documented_type) 69 | 70 | documented_data_type = document_data_type(documentation, data_type) 71 | 72 | if documentation[:is_array] 73 | { 74 | type: :array, 75 | items: documented_data_type 76 | } 77 | else 78 | documented_data_type 79 | end 80 | end 81 | 82 | def document_data_type(documentation, data_type) 83 | type = if GrapeSwagger::DocMethods::DataType.primitive?(data_type) 84 | data = GrapeSwagger::DocMethods::DataType.mapping(data_type) 85 | { type: data.first, format: data.last } 86 | else 87 | { type: data_type } 88 | end 89 | 90 | type[:format] = documentation[:format] if documentation.key?(:format) 91 | 92 | values = documentation[:values] 93 | values = values.call if values.is_a?(Proc) 94 | type[:enum] = values if values.is_a?(Array) 95 | 96 | type 97 | end 98 | 99 | def entity_model_type(name, entity_options) 100 | if entity_options[:documentation] && entity_options[:documentation][:is_array] 101 | { 102 | 'type' => 'array', 103 | 'items' => { 104 | '$ref' => "#/definitions/#{name}" 105 | } 106 | } 107 | else 108 | { 109 | '$ref' => "#/definitions/#{name}" 110 | } 111 | end 112 | end 113 | 114 | def add_attribute_sample(attribute, hash, key) 115 | value = hash[key] 116 | return if value.nil? 117 | 118 | attribute[key] = value.is_a?(Proc) ? value.call : value 119 | end 120 | 121 | def add_attribute_documentation(param, documentation) 122 | param[:minimum] = documentation[:minimum] if documentation.key?(:minimum) 123 | param[:maximum] = documentation[:maximum] if documentation.key?(:maximum) 124 | 125 | values = documentation[:values] 126 | if values&.is_a?(Range) 127 | param[:minimum] = values.begin if values.begin.is_a?(Numeric) 128 | param[:maximum] = values.end if values.end.is_a?(Numeric) 129 | end 130 | 131 | param[:minLength] = documentation[:min_length] if documentation.key?(:min_length) 132 | param[:maxLength] = documentation[:max_length] if documentation.key?(:max_length) 133 | end 134 | 135 | def add_array_documentation(param, documentation) 136 | param[:minItems] = documentation[:min_items] if documentation.key?(:min_items) 137 | param[:maxItems] = documentation[:max_items] if documentation.key?(:max_items) 138 | param[:uniqueItems] = documentation[:unique_items] if documentation.key?(:unique_items) 139 | end 140 | 141 | def add_extension_documentation(param, documentation) 142 | GrapeSwagger::DocMethods::Extensions.add_extensions_to_root(documentation, param) 143 | end 144 | 145 | def add_discriminator_extension(param, documentation) 146 | param[:documentation] = { is_discriminator: true } if documentation.key?(:is_discriminator) 147 | end 148 | end 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /lib/grape-swagger/entity/parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GrapeSwagger 4 | module Entity 5 | class Parser 6 | attr_reader :model, :endpoint, :attribute_parser 7 | 8 | def initialize(model, endpoint) 9 | @model = model 10 | @endpoint = endpoint 11 | @attribute_parser = AttributeParser.new(endpoint) 12 | end 13 | 14 | def call 15 | parse_grape_entity_params(extract_params(model)) 16 | end 17 | 18 | private 19 | 20 | class Alias 21 | attr_reader :original, :renamed 22 | 23 | def initialize(original, renamed) 24 | @original = original 25 | @renamed = renamed 26 | end 27 | end 28 | 29 | def extract_params(exposure) 30 | GrapeSwagger::Entity::Helper.root_exposure_with_discriminator(exposure).each_with_object({}) do |value, memo| 31 | if value.for_merge && (value.respond_to?(:entity_class) || value.respond_to?(:using_class_name)) 32 | entity_class = value.respond_to?(:entity_class) ? value.entity_class : value.using_class_name 33 | 34 | extracted_params = extract_params(entity_class) 35 | memo.merge!(extracted_params) 36 | else 37 | opts = value.send(:options) 38 | opts[:as] ? memo[Alias.new(value.attribute, opts[:as])] = opts : memo[value.attribute] = opts 39 | end 40 | end 41 | end 42 | 43 | def parse_grape_entity_params(params, parent_model = nil) 44 | return unless params 45 | 46 | parsed = params.each_with_object({}) do |(entity_name, entity_options), memo| 47 | documentation_options = entity_options.fetch(:documentation, {}) 48 | in_option = documentation_options.fetch(:in, nil).to_s 49 | hidden_option = documentation_options.fetch(:hidden, nil) 50 | next if in_option == 'header' || hidden_option == true 51 | 52 | entity_name = entity_name.original if entity_name.is_a?(Alias) 53 | final_entity_name = entity_options.fetch(:as, entity_name) 54 | documentation = entity_options[:documentation] 55 | 56 | memo[final_entity_name] = if entity_options[:nesting] 57 | parse_nested(entity_name, entity_options, parent_model) 58 | else 59 | attribute_parser.call(entity_options) 60 | end 61 | 62 | next unless documentation 63 | 64 | memo[final_entity_name][:readOnly] = documentation[:read_only].to_s == 'true' if documentation[:read_only] 65 | memo[final_entity_name][:description] = documentation[:desc] if documentation[:desc] 66 | end 67 | 68 | discriminator = GrapeSwagger::Entity::Helper.discriminator(model) 69 | if discriminator 70 | respond_with_all_of(parsed, params, discriminator) 71 | else 72 | [parsed, required_params(params)] 73 | end 74 | end 75 | 76 | def respond_with_all_of(parsed, params, discriminator) 77 | parent_name = GrapeSwagger::Entity::Helper.model_name(model.superclass, endpoint) 78 | 79 | { 80 | allOf: [ 81 | { 82 | '$ref' => "#/definitions/#{parent_name}" 83 | }, 84 | [ 85 | add_discriminator(parsed, discriminator), 86 | required_params(params).push(discriminator.attribute) 87 | ] 88 | ] 89 | } 90 | end 91 | 92 | def add_discriminator(parsed, discriminator) 93 | model_name = GrapeSwagger::Entity::Helper.model_name(model, endpoint) 94 | 95 | parsed.merge( 96 | discriminator.attribute => { 97 | type: 'string', 98 | enum: [model_name] 99 | } 100 | ) 101 | end 102 | 103 | def parse_nested(entity_name, entity_options, parent_model = nil) 104 | nested_entities = if parent_model.nil? 105 | model.root_exposures.select_by(entity_name) 106 | else 107 | parent_model.nested_exposures.select_by(entity_name) 108 | end 109 | 110 | params = nested_entities 111 | .map(&:nested_exposures) 112 | .flatten 113 | .each_with_object({}) do |value, memo| 114 | memo[value.attribute] = value.send(:options) 115 | end 116 | 117 | properties, required = parse_grape_entity_params(params, nested_entities.last) 118 | documentation = entity_options[:documentation] 119 | is_a_collection = documentation.is_a?(Hash) && documentation[:type].to_s.casecmp('array').zero? 120 | 121 | if is_a_collection 122 | { 123 | type: :array, 124 | items: with_required({ 125 | type: :object, 126 | properties: properties 127 | }, required) 128 | } 129 | else 130 | with_required({ 131 | type: :object, 132 | properties: properties 133 | }, required) 134 | end 135 | end 136 | 137 | def required_params(params) 138 | params.each_with_object(Set.new) do |(key, options), accum| 139 | required = if options.fetch(:documentation, {}).key?(:required) 140 | options.dig(:documentation, :required) 141 | else 142 | !options.key?(:if) && !options.key?(:unless) && options[:expose_nil] != false 143 | end 144 | 145 | accum.add(options.fetch(:as, key)) if required 146 | end.to_a 147 | end 148 | 149 | def with_required(hash, required) 150 | return hash if required.empty? 151 | 152 | hash[:required] = required 153 | hash 154 | end 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 0.7.1 (Next) 2 | 3 | #### Features 4 | 5 | * Your contribution here. 6 | 7 | #### Fixes 8 | 9 | * Your contribution here. 10 | 11 | ### 0.7.0 (2025/08/02) 12 | 13 | #### Features 14 | 15 | * [#81](https://github.com/ruby-grape/grape-swagger-entity/pull/81): Make entity fields required by default - [@olivier-thatch](https://github.com/olivier-thatch). 16 | * [#85](https://github.com/ruby-grape/grape-swagger-entity/pull/85): Use rubocop plugins, use ruby 3.0 as minimum version everywhere - [@olivier-thatch](https://github.com/olivier-thatch). 17 | 18 | ### 0.6.2 (2025/05/10) 19 | 20 | #### Fixes 21 | 22 | * [#82](https://github.com/ruby-grape/grape-swagger-entity/pull/82): Fix: simplify primitive types recognition - [@numbata](https://github.com/numbata). 23 | 24 | ### 0.6.1 (2025/05/06) 25 | 26 | #### Fixes 27 | 28 | * [#79](https://github.com/ruby-grape/grape-swagger-entity/pull/79): Fix correctly handle class types like time in documentation - [@krororo](https://github.com/krororo). 29 | 30 | ### 0.6.0 (2025/04/26) 31 | 32 | #### Features 33 | 34 | * [#76](https://github.com/ruby-grape/grape-swagger-entity/pull/76): Update ci matrix and gemfile for multi-version grape testing - [@numbata](https://github.com/numbata). 35 | * [#77](https://github.com/ruby-grape/grape-swagger-entity/pull/77): Allow proc for enum values in documentation - [@krororo](https://github.com/krororo). 36 | 37 | #### Fixes 38 | 39 | * [#75](https://github.com/ruby-grape/grape-swagger-entity/pull/75): Fix handling of entity with class names without "entity" or "entities" - [@numbata](https://github.com/numbata). 40 | 41 | ### 0.5.5 (2024/09/09) 42 | 43 | #### Fixes 44 | 45 | * [#72](https://github.com/ruby-grape/grape-swagger-entity/pull/72): Ensure inherited nested exposures are merged - [@pirhoo](https://github.com/pirhoo). 46 | * [#71](https://github.com/ruby-grape/grape-swagger-entity/pull/71): Fix regression for enum values in array attributes - [@Jell](https://github.com/Jell). 47 | 48 | ### 0.5.4 (2024/04/19) 49 | 50 | #### Features 51 | 52 | * [#69](https://github.com/ruby-grape/grape-swagger-entity/pull/69): Add support for minimum and maximum - [@storey](https://github.com/storey). 53 | 54 | #### Fixes 55 | 56 | * [#67](https://github.com/ruby-grape/grape-swagger-entity/pull/67): Various build updates - [@mscrivo](https://github.com/mscrivo). 57 | * [#68](https://github.com/ruby-grape/grape-swagger-entity/pull/68): Properly render `example` for array exposure done using another entity - [@magni-](https://github.com/magni-). 58 | 59 | ### 0.5.3 (2024/02/02) 60 | 61 | #### Features 62 | 63 | * [#64](https://github.com/ruby-grape/grape-swagger-entity/pull/64): Pass extension documentation into schema - [@numbata](https://github.com/numbata). 64 | 65 | #### Fixes 66 | 67 | * [#66](https://github.com/ruby-grape/grape-swagger-entity/pull/66): Fix danger GHA - [@mscrivo](https://github.com/mscrivo). 68 | 69 | ### 0.5.2 (2023/07/07) 70 | 71 | #### Fixes 72 | 73 | * [#60](https://github.com/ruby-grape/grape-swagger-entity/pull/60): Examples on arrays should be directly on the property, not on the item - [@collinsauve](https://github.com/collinsauve). 74 | * [#61](https://github.com/ruby-grape/grape-swagger-entity/pull/61): Migrate from Travis to GHA for CI - [@mscrivo](https://github.com/mscrivo). 75 | 76 | ### 0.5.1 (2020/06/30) 77 | 78 | #### Features 79 | 80 | * [#50](https://github.com/ruby-grape/grape-swagger-entity/pull/50): Features/inheritance and discriminator - [@MaximeRDY](https://github.com/MaximeRDY). 81 | 82 | ### 0.4.0 (2020/05/30) 83 | 84 | #### Features 85 | 86 | * [#49](https://github.com/ruby-grape/grape-swagger-entity/pull/49): Bumped minimal required version of grape-swagger to 1.0.0 - [@Bugagazavr](https://github.com/Bugagazavr). 87 | * [#48](https://github.com/ruby-grape/grape-swagger-entity/pull/48): Add support for extensions - [@MaximeRDY](https://github.com/MaximeRDY). 88 | 89 | #### Fixes 90 | 91 | * [#46](https://github.com/ruby-grape/grape-swagger-entity/pull/46): Fixed issue where a boolean example value of false would not display in swagger file - [@drewnichols](https://github.com/drewnichols). 92 | 93 | ### 0.3.4 (2020/01/09) 94 | 95 | #### Features 96 | 97 | * [#40](https://github.com/ruby-grape/grape-swagger-entity/pull/40): Add support for minLength and maxLength - [@fotos](https://github.com/fotos). 98 | 99 | ### 0.3.3 (2019/02/22) 100 | 101 | #### Features 102 | 103 | * [#39](https://github.com/ruby-grape/grape-swagger-entity/pull/39): Fix to avoid conflict with polymorphic model - [@kinoppyd](https://github.com/kinoppyd). 104 | 105 | ### 0.3.2 (2019/01/15) 106 | 107 | #### Features 108 | 109 | * [#38](https://github.com/ruby-grape/grape-swagger-entity/pull/38): Added support for hidden option for documentation - [@vitoravelino](https://github.com/vitoravelino). 110 | 111 | ### 0.3.1 (2018/11/26) 112 | 113 | #### Features 114 | 115 | * [#37](https://github.com/ruby-grape/grape-swagger-entity/pull/37): Add support for minItems, maxItems and uniqueItems - [@fotos](https://github.com/fotos). 116 | 117 | ### 0.3.0 (2018/08/22) 118 | 119 | #### Features 120 | 121 | * [#35](https://github.com/ruby-grape/grape-swagger-entity/pull/35): Support for required attributes - [@Bugagazavr](https://github.com/Bugagazavr). 122 | 123 | ### 0.2.5 (2018/04/26) 124 | 125 | #### Features 126 | 127 | * [#33](https://github.com/ruby-grape/grape-swagger-entity/pull/33): Update parser to respect merge option for entities - [@b-boogaard](https://github.com/b-boogaard). 128 | 129 | ### 0.2.4 (2018/04/03) 130 | 131 | #### Fixes 132 | 133 | * [#32](https://github.com/ruby-grape/grape-swagger-entity/pull/32): Fix issue with read_only fields - [@mcfilib](https://github.com/mcfilib). 134 | 135 | ### 0.2.3 (2017/11/19) 136 | 137 | #### Fixes 138 | 139 | * [#30](https://github.com/ruby-grape/grape-swagger-entity/pull/30): Fix nested exposures with an alias - [@Kukunin](https://github.com/Kukunin). 140 | * [#31](https://github.com/ruby-grape/grape-swagger-entity/pull/31): Respect `required: true` for nested attributes as well - [@Kukunin](https://github.com/Kukunin). 141 | 142 | ### 0.2.2 (2017/11/3) 143 | 144 | #### Features 145 | 146 | * [#27](https://github.com/ruby-grape/grape-swagger-entity/pull/27): Add support for attribute examples - [@Kukunin](https://github.com/Kukunin). 147 | 148 | ### 0.2.1 (2017/07/05) 149 | 150 | #### Features 151 | 152 | * [#26](https://github.com/ruby-grape/grape-swagger-entity/pull/26): Add support for read only field - [@FChaack](https://github.com/FChaack). 153 | 154 | ### 0.2.0 (2017/03/02) 155 | 156 | #### Features 157 | 158 | * [#22](https://github.com/ruby-grape/grape-swagger-entity/pull/22): Nested exposures - [@Bugagazavr](https://github.com/Bugagazavr). 159 | 160 | ### 0.1.6 (2017/02/03) 161 | 162 | #### Features 163 | 164 | * [#21](https://github.com/ruby-grape/grape-swagger-entity/pull/21): Adds support for own format - [@LeFnord](https://github.com/LeFnord). 165 | * [#19](https://github.com/ruby-grape/grape-swagger-entity/pull/19): Adds support for default value - [@LeFnord](https://github.com/LeFnord). 166 | 167 | ### 0.1.5 (2016/11/21) 168 | 169 | #### Features 170 | 171 | * [#11](https://github.com/ruby-grape/grape-swagger-entity/pull/11): Use an automated PR linter, [danger.systems](http://danger.systems) - [@Bugagazavr](https://github.com/Bugagazavr). 172 | * [#12](https://github.com/ruby-grape/grape-swagger-entity/pull/12): Allow parsing Entity with no endpoint - [@lordnibbler](https://github.com/lordnibbler). 173 | 174 | #### Fixes 175 | 176 | * [#8](https://github.com/ruby-grape/grape-swagger-entity/pull/8): Generate enum property if values key is passed in documentation - [@lordnibbler](https://github.com/lordnibbler). 177 | * [#15](https://github.com/ruby-grape/grape-swagger-entity/pull/15): Support grape entity 0.6.x and later - [@Bugagazavr](https://github.com/Bugagazavr). 178 | 179 | ### 0.1.4 (2016/06/07) 180 | 181 | #### Fixes 182 | 183 | * [#7](https://github.com/ruby-grape/grape-swagger-entity/pull/7): Fixed array support for primitive types - [@Bugagazavr](https://github.com/Bugagazavr). 184 | -------------------------------------------------------------------------------- /spec/grape-swagger/entity/attribute_parser_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require_relative '../../../spec/support/shared_contexts/this_api' 5 | 6 | describe GrapeSwagger::Entity::AttributeParser do 7 | include_context 'this api' 8 | 9 | describe '#call' do 10 | subject { described_class.new(endpoint).call(entity_options) } 11 | 12 | let(:endpoint) {} 13 | 14 | context 'when the entity is a model' do 15 | context 'when it is exposed as an array' do 16 | let(:entity_options) { { using: ThisApi::Entities::Tag, documentation: { is_array: true } } } 17 | 18 | it { is_expected.to include('type' => 'array') } 19 | it { is_expected.to include('items' => { '$ref' => '#/definitions/Tag' }) } 20 | 21 | context 'when it contains example' do 22 | let(:entity_options) do 23 | { 24 | using: ThisApi::Entities::Tag, 25 | documentation: { 26 | is_array: true, 27 | example: [ 28 | { name: 'green' }, 29 | { name: 'blue' } 30 | ] 31 | } 32 | } 33 | end 34 | 35 | it { is_expected.to include(example: %w[green blue].map { { name: _1 } }) } 36 | end 37 | 38 | context 'when the entity is implicit Entity' do 39 | let(:entity_type) do 40 | Class.new(ThisApi::Entities::Tag) do 41 | def self.name 42 | 'ThisApi::Tag' 43 | end 44 | 45 | def self.to_s 46 | name 47 | end 48 | end 49 | end 50 | let(:entity_options) { { documentation: { type: entity_type, is_array: true, min_items: 1 } } } 51 | 52 | it { is_expected.to include('type' => 'array') } 53 | it { is_expected.to include('items' => { '$ref' => '#/definitions/Tag' }) } 54 | end 55 | 56 | context 'when it contains min_items' do 57 | let(:entity_options) { { using: ThisApi::Entities::Tag, documentation: { is_array: true, min_items: 1 } } } 58 | 59 | it { is_expected.to include(minItems: 1) } 60 | end 61 | 62 | context 'when it contains max_items' do 63 | let(:entity_options) { { using: ThisApi::Entities::Tag, documentation: { is_array: true, max_items: 1 } } } 64 | 65 | it { is_expected.to include(maxItems: 1) } 66 | end 67 | 68 | context 'when it contains unique_items' do 69 | let(:entity_options) do 70 | { using: ThisApi::Entities::Tag, documentation: { is_array: true, unique_items: true } } 71 | end 72 | 73 | it { is_expected.to include(uniqueItems: true) } 74 | end 75 | end 76 | 77 | context 'when it is not exposed as an array' do 78 | let(:entity_options) do 79 | { using: ThisApi::Entities::Kind, 80 | documentation: { type: 'ThisApi::Kind', desc: 'The kind of this something.' } } 81 | end 82 | 83 | it { is_expected.not_to include('type') } 84 | it { is_expected.to include('$ref' => '#/definitions/Kind') } 85 | end 86 | end 87 | 88 | context 'when the entity is not a model' do 89 | context 'when it is exposed as a string' do 90 | let(:entity_options) { { documentation: { type: 'string', desc: 'Colors' } } } 91 | 92 | it { is_expected.to include(type: 'string') } 93 | 94 | context 'when it contains min_length' do 95 | let(:entity_options) { { documentation: { type: 'string', desc: 'Colors', min_length: 1 } } } 96 | 97 | it { is_expected.to include(minLength: 1) } 98 | end 99 | 100 | context 'when it contains max_length' do 101 | let(:entity_options) { { documentation: { type: 'string', desc: 'Colors', max_length: 1 } } } 102 | 103 | it { is_expected.to include(maxLength: 1) } 104 | end 105 | 106 | context 'when it contains values array' do 107 | let(:entity_options) { { documentation: { type: 'string', desc: 'Colors', values: %w[red blue] } } } 108 | 109 | it { is_expected.not_to include('minimum') } 110 | it { is_expected.not_to include('maximum') } 111 | end 112 | 113 | context 'when it contains values range' do 114 | let(:entity_options) { { documentation: { type: 'string', desc: 'Colors', values: 'a'...'c' } } } 115 | 116 | it { is_expected.not_to include('minimum') } 117 | it { is_expected.not_to include('maximum') } 118 | end 119 | 120 | context 'when it contains extensions' do 121 | let(:entity_options) { { documentation: { type: 'string', desc: 'Colors', x: { some: 'stuff' } } } } 122 | 123 | it { is_expected.to include('x-some' => 'stuff') } 124 | end 125 | end 126 | 127 | context 'when it is exposed as a number' do 128 | let(:entity_options) { { documentation: { type: 'number', desc: 'Solution pH' } } } 129 | 130 | it { is_expected.to include(type: 'number') } 131 | 132 | context 'when it contains minimum' do 133 | let(:entity_options) { { documentation: { type: 'number', desc: 'Solution pH', minimum: 2.5 } } } 134 | 135 | it { is_expected.to include(minimum: 2.5) } 136 | end 137 | 138 | context 'when it contains maximum' do 139 | let(:entity_options) { { documentation: { type: 'number', desc: 'Solution pH', maximum: 9.1 } } } 140 | 141 | it { is_expected.to include(maximum: 9.1) } 142 | end 143 | 144 | context 'when it contains values array' do 145 | let(:entity_options) { { documentation: { type: 'number', desc: 'Solution pH', values: [6.0, 7.0, 8.0] } } } 146 | 147 | it { is_expected.not_to include('minimum') } 148 | it { is_expected.not_to include('maximum') } 149 | end 150 | 151 | context 'when it contains values range' do 152 | let(:entity_options) { { documentation: { type: 'number', desc: 'Solution pH', values: 0.0..14.0 } } } 153 | 154 | it { is_expected.to include(minimum: 0.0, maximum: 14.0) } 155 | end 156 | 157 | context 'when it contains values range with no minimum' do 158 | let(:entity_options) { { documentation: { type: 'number', desc: 'Solution pH', values: ..14.0 } } } 159 | 160 | it { is_expected.not_to include('minimum') } 161 | it { is_expected.to include(maximum: 14.0) } 162 | end 163 | 164 | context 'when it contains values range with no maximum' do 165 | let(:entity_options) { { documentation: { type: 'number', desc: 'Solution pH', values: 0.0.. } } } 166 | 167 | it { is_expected.not_to include('maximum') } 168 | it { is_expected.to include(minimum: 0.0) } 169 | end 170 | 171 | context 'when it contains extensions' do 172 | let(:entity_options) { { documentation: { type: 'number', desc: 'Solution pH', x: { some: 'stuff' } } } } 173 | 174 | it { is_expected.to include('x-some' => 'stuff') } 175 | end 176 | end 177 | 178 | context 'when it is exposed as an integer' do 179 | let(:entity_options) { { documentation: { type: 'integer', desc: 'Count' } } } 180 | 181 | it { is_expected.to include(type: 'integer') } 182 | 183 | context 'when it contains minimum' do 184 | let(:entity_options) { { documentation: { type: 'integer', desc: 'Count', minimum: 2 } } } 185 | 186 | it { is_expected.to include(minimum: 2) } 187 | end 188 | 189 | context 'when it contains maximum' do 190 | let(:entity_options) { { documentation: { type: 'integer', desc: 'Count', maximum: 100 } } } 191 | 192 | it { is_expected.to include(maximum: 100) } 193 | end 194 | 195 | context 'when it contains values array' do 196 | let(:entity_options) { { documentation: { type: 'integer', desc: 'Count', values: 1..10 } } } 197 | 198 | it { is_expected.not_to include('minimum') } 199 | it { is_expected.not_to include('maximum') } 200 | end 201 | 202 | context 'when it contains values range' do 203 | let(:entity_options) { { documentation: { type: 'integer', desc: 'Count', values: 1..10 } } } 204 | 205 | it { is_expected.to include(minimum: 1, maximum: 10) } 206 | end 207 | 208 | context 'when it contains extensions' do 209 | let(:entity_options) { { documentation: { type: 'integer', desc: 'Count', x: { some: 'stuff' } } } } 210 | 211 | it { is_expected.to include('x-some' => 'stuff') } 212 | end 213 | end 214 | 215 | context 'when it is exposed as an array' do 216 | let(:entity_options) { { documentation: { type: 'string', desc: 'Colors', is_array: true } } } 217 | 218 | it { is_expected.to include(type: :array) } 219 | it { is_expected.to include(items: { type: 'string' }) } 220 | 221 | context 'when it contains example' do 222 | let(:entity_options) do 223 | { documentation: { type: 'string', desc: 'Colors', is_array: true, example: %w[green blue] } } 224 | end 225 | 226 | it { is_expected.to include(example: %w[green blue]) } 227 | end 228 | 229 | context 'when it contains min_items' do 230 | let(:entity_options) { { documentation: { type: 'string', desc: 'Colors', is_array: true, min_items: 1 } } } 231 | 232 | it { is_expected.to include(minItems: 1) } 233 | end 234 | 235 | context 'when it contains max_items' do 236 | let(:entity_options) { { documentation: { type: 'string', desc: 'Colors', is_array: true, max_items: 1 } } } 237 | 238 | it { is_expected.to include(maxItems: 1) } 239 | end 240 | 241 | context 'when it contains unique_items' do 242 | let(:entity_options) do 243 | { documentation: { type: 'string', desc: 'Colors', is_array: true, unique_items: true } } 244 | end 245 | 246 | it { is_expected.to include(uniqueItems: true) } 247 | end 248 | 249 | context 'when it contains values array' do 250 | let(:entity_options) do 251 | { documentation: { type: 'string', desc: 'Colors', is_array: true, values: %w[red blue] } } 252 | end 253 | 254 | it { is_expected.to eq(type: :array, items: { type: 'string', enum: %w[red blue] }) } 255 | end 256 | end 257 | 258 | context 'when it is not exposed as an array' do 259 | let(:entity_options) { { documentation: { type: 'string', desc: 'Content of something.' } } } 260 | 261 | it { is_expected.to include(type: 'string') } 262 | it { is_expected.not_to include('$ref') } 263 | end 264 | 265 | context 'when it is exposed as a Boolean class' do 266 | let(:entity_options) do 267 | { documentation: { type: Grape::API::Boolean, example: example_value, default: example_value } } 268 | end 269 | 270 | context 'when the example value is true' do 271 | let(:example_value) { true } 272 | 273 | it { is_expected.to include(type: 'boolean', example: example_value, default: example_value) } 274 | end 275 | 276 | context 'when the example value is false' do 277 | let(:example_value) { false } 278 | 279 | it { is_expected.to include(type: 'boolean', example: example_value, default: example_value) } 280 | end 281 | end 282 | 283 | context 'when it is exposed as a boolean' do 284 | let(:entity_options) { { documentation: { type: 'boolean', example: example_value, default: example_value } } } 285 | 286 | context 'when the example value is true' do 287 | let(:example_value) { true } 288 | 289 | it { is_expected.to include(type: 'boolean', example: example_value, default: example_value) } 290 | end 291 | 292 | context 'when the example value is false' do 293 | let(:example_value) { false } 294 | 295 | it { is_expected.to include(type: 'boolean', example: example_value, default: example_value) } 296 | end 297 | end 298 | 299 | context 'when it is exposed as a hash' do 300 | let(:entity_options) { { documentation: { type: Hash, example: example_value, default: example_value } } } 301 | 302 | context 'when the example value is true' do 303 | let(:example_value) { true } 304 | 305 | it { is_expected.to include(type: 'object', example: example_value, default: example_value) } 306 | end 307 | 308 | context 'when the example value is false' do 309 | let(:example_value) { false } 310 | 311 | it { is_expected.to include(type: 'object', example: example_value, default: example_value) } 312 | end 313 | end 314 | end 315 | end 316 | end 317 | -------------------------------------------------------------------------------- /spec/grape-swagger/entities/response_model_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe 'responseModel' do 6 | subject do 7 | get '/swagger_doc/something' 8 | JSON.parse(last_response.body) 9 | end 10 | 11 | include_context 'this api' 12 | 13 | def app 14 | ThisApi::ResponseModelApi 15 | end 16 | 17 | it 'documents index action' do 18 | expect(subject['paths']['/something']['get']['responses']).to eq( 19 | '200' => { 20 | 'description' => 'OK', 21 | 'schema' => { 22 | 'type' => 'array', 23 | 'items' => { '$ref' => '#/definitions/ThisApi_Entities_Something' } 24 | } 25 | } 26 | ) 27 | end 28 | 29 | it 'documents specified models as show action' do 30 | expect(subject['paths']['/something/{id}']['get']['responses']).to eq( 31 | '200' => { 32 | 'description' => 'OK', 33 | 'schema' => { '$ref' => '#/definitions/ThisApi_Entities_Something' } 34 | }, 35 | '403' => { 36 | 'description' => 'Refused to return something', 37 | 'schema' => { '$ref' => '#/definitions/ThisApi_Entities_Error' } 38 | } 39 | ) 40 | expect(subject['definitions'].keys).to include 'ThisApi_Entities_Error' 41 | expect(subject['definitions']['ThisApi_Entities_Error']).to eq( 42 | 'type' => 'object', 43 | 'description' => 'ThisApi_Entities_Error model', 44 | 'properties' => { 45 | 'code' => { 'type' => 'string', 'description' => 'Error code' }, 46 | 'message' => { 'type' => 'string', 'description' => 'Error message' } 47 | }, 48 | 'required' => %w[code message] 49 | ) 50 | 51 | expect(subject['definitions'].keys).to include 'ThisApi_Entities_Something' 52 | expect(subject['definitions']['ThisApi_Entities_Something']).to eq( 53 | 'type' => 'object', 54 | 'description' => 'ThisApi_Entities_Something model', 55 | 'properties' => 56 | { 'text' => { 'type' => 'string', 'description' => 'Content of something.' }, 57 | 'colors' => { 'type' => 'array', 'items' => { 'type' => 'string' }, 'description' => 'Colors' }, 58 | 'created_at' => { 'type' => 'string', 'format' => 'date-time', 'description' => 'Created at the time.' }, 59 | 'kind' => { '$ref' => '#/definitions/ThisApi_Entities_Kind', 60 | 'description' => 'The kind of this something.' }, 61 | 'kind2' => { '$ref' => '#/definitions/ThisApi_Entities_Kind', 'description' => 'Secondary kind.' }, 62 | 'kind3' => { '$ref' => '#/definitions/ThisApi_Entities_Kind', 'description' => 'Tertiary kind.' }, 63 | 'tags' => { 'type' => 'array', 'items' => { '$ref' => '#/definitions/ThisApi_Entities_Tag' }, 64 | 'description' => 'Tags.' }, 65 | 'relation' => { '$ref' => '#/definitions/ThisApi_Entities_Relation', 'description' => 'A related model.', 66 | 'x-other' => 'stuff' }, 67 | 'code' => { 'type' => 'string', 'description' => 'Error code' }, 68 | 'message' => { 'type' => 'string', 'description' => 'Error message' }, 69 | 'attr' => { 'type' => 'string', 'description' => 'Attribute' } }, 70 | 'required' => %w[text colors hidden_attr created_at kind kind2 kind3 tags relation 71 | attr code message] 72 | ) 73 | 74 | expect(subject['definitions'].keys).to include 'ThisApi_Entities_Kind' 75 | expect(subject['definitions']['ThisApi_Entities_Kind']).to eq( 76 | 'type' => 'object', 77 | 'properties' => { 78 | 'title' => { 'type' => 'string', 'description' => 'Title of the kind.' }, 79 | 'content' => { 'type' => 'string', 'description' => 'Content', 'x-some' => 'stuff' } 80 | }, 81 | 'required' => %w[title content] 82 | ) 83 | 84 | expect(subject['definitions'].keys).to include 'ThisApi_Entities_Relation' 85 | expect(subject['definitions']['ThisApi_Entities_Relation']).to eq( 86 | 'type' => 'object', 87 | 'properties' => { 'name' => { 'type' => 'string', 'description' => 'Name' } }, 88 | 'required' => %w[name] 89 | ) 90 | 91 | expect(subject['definitions'].keys).to include 'ThisApi_Entities_Tag' 92 | expect(subject['definitions']['ThisApi_Entities_Tag']).to eq( 93 | 'type' => 'object', 94 | 'properties' => { 'name' => { 'type' => 'string', 'description' => 'Name' } }, 95 | 'required' => %w[name] 96 | ) 97 | end 98 | end 99 | 100 | describe 'building definitions from given entities' do 101 | subject do 102 | get '/swagger_doc' 103 | JSON.parse(last_response.body)['definitions'] 104 | end 105 | 106 | before :all do 107 | module TheseApi 108 | module Entities 109 | class Values < Grape::Entity 110 | expose :guid, documentation: { desc: 'Some values', values: %w[a b c], default: 'c' } 111 | expose :uuid, documentation: { desc: 'customer uuid', type: String, format: 'own', 112 | example: 'e3008fba-d53d-4bcc-a6ae-adc56dff8020' } 113 | expose :color, documentation: { desc: 'Color', type: String, values: -> { %w[red blue] } } 114 | end 115 | 116 | class Kind < Grape::Entity 117 | expose :id, documentation: { type: Integer, desc: 'id of the kind.', values: [1, 2], read_only: true } 118 | expose :title, documentation: { type: String, desc: 'Title of the kind.', read_only: 'false' } 119 | expose :type, documentation: { type: String, desc: 'Type of the kind.', read_only: 'true' } 120 | end 121 | 122 | class Relation < Grape::Entity 123 | expose :name, documentation: { type: String, desc: 'Name' } 124 | end 125 | 126 | class Tag < Grape::Entity 127 | expose :name, documentation: { type: 'string', desc: 'Name', 128 | example: -> { 'random_tag' } } 129 | end 130 | 131 | class Nested < Grape::Entity 132 | expose :nested, documentation: { type: Hash, desc: 'Nested entity' } do 133 | expose :some1, documentation: { type: 'String', desc: 'Nested some 1' } 134 | expose :some2, documentation: { type: 'String', desc: 'Nested some 2' } 135 | end 136 | expose :nested_with_alias, as: :aliased do 137 | expose :some1, documentation: { type: 'String', desc: 'Alias some 1' } 138 | end 139 | expose :deep_nested, documentation: { type: 'Object', desc: 'Deep nested entity' } do 140 | expose :level_1, documentation: { type: 'Object', desc: 'More deepest nested entity' } do 141 | expose :level_2, documentation: { type: 'String', desc: 'Level 2' } 142 | end 143 | end 144 | expose :nested_required do 145 | expose :some1, documentation: { required: true, desc: 'Required some 1' } 146 | expose :attr, as: :some2, documentation: { desc: 'Required some 2' } 147 | expose :some3, documentation: { required: false, desc: 'Optional some 3' } 148 | expose :some4, if: -> { true }, documentation: { desc: 'Optional some 4' } 149 | expose :some5, unless: -> { true }, documentation: { desc: 'Optional some 5' } 150 | expose :some6, expose_nil: false, documentation: { desc: 'Optional some 6' } 151 | end 152 | 153 | expose :nested_array, documentation: { type: 'Array', desc: 'Nested array' } do 154 | expose :id, documentation: { type: 'Integer', desc: 'Collection element id' } 155 | expose :name, documentation: { type: 'String', desc: 'Collection element name' } 156 | end 157 | end 158 | 159 | class NestedChild < Nested 160 | expose :nested, documentation: { type: Hash, desc: 'Nested entity' } do 161 | expose :some3, documentation: { type: 'String', desc: 'Nested some 3' } 162 | end 163 | 164 | expose :nested_with_alias, as: :aliased do 165 | expose :some2, documentation: { type: 'String', desc: 'Alias some 2' } 166 | end 167 | 168 | expose :deep_nested, documentation: { type: 'Object', desc: 'Deep nested entity' } do 169 | expose :level_1, documentation: { type: 'Object', desc: 'More deepest nested entity' } do 170 | expose :level_2, documentation: { type: 'String', desc: 'Level 2' } do 171 | expose :level_3, documentation: { type: 'String', desc: 'Level 3' } 172 | end 173 | end 174 | end 175 | 176 | expose :nested_array, documentation: { type: 'Array', desc: 'Nested array' } do 177 | expose :category, documentation: { type: 'String', desc: 'Collection element category' } 178 | end 179 | end 180 | 181 | class Polymorphic < Grape::Entity 182 | expose :obj, as: :kind, if: lambda { |instance, _| 183 | instance.type == 'kind' 184 | }, using: Kind, documentation: { desc: 'Polymorphic Kind' } 185 | expose :obj, as: :values, if: lambda { |instance, _| 186 | instance.type == 'values' 187 | }, using: Values, documentation: { desc: 'Polymorphic Values' } 188 | expose :not_using_obj, as: :str, if: lambda { |instance, _| 189 | instance.instance_of?(String) 190 | }, documentation: { desc: 'Polymorphic String' } 191 | expose :not_using_obj, as: :num, if: lambda { |instance, _| 192 | instance.instance_of?(Number) 193 | }, documentation: { desc: 'Polymorphic Number', type: 'Integer' } 194 | end 195 | 196 | class TagType < CustomType 197 | def tags 198 | %w[Cyan Magenta Yellow Key] 199 | end 200 | end 201 | 202 | class MixedType < Grape::Entity 203 | expose :tags, documentation: { type: TagType, desc: 'Tags', is_array: true } 204 | end 205 | 206 | class SomeEntity < Grape::Entity 207 | expose :text, documentation: { type: 'string', desc: 'Content of something.' } 208 | expose :kind, using: Kind, documentation: { type: 'TheseApi_Kind', desc: 'The kind of this something.' } 209 | expose :kind2, using: Kind, documentation: { desc: 'Secondary kind.' } 210 | expose :kind3, using: TheseApi::Entities::Kind, documentation: { desc: 'Tertiary kind.' } 211 | expose :tags, using: TheseApi::Entities::Tag, documentation: { desc: 'Tags.', is_array: true } 212 | expose :relation, using: TheseApi::Entities::Relation, 213 | documentation: { type: 'TheseApi_Relation', desc: 'A related model.' } 214 | expose :values, using: TheseApi::Entities::Values, documentation: { desc: 'Tertiary kind.' } 215 | expose :nested, using: TheseApi::Entities::Nested, documentation: { desc: 'Nested object.' } 216 | expose :nested_child, using: TheseApi::Entities::NestedChild, documentation: { desc: 'Nested child object.' } 217 | expose :polymorphic, using: TheseApi::Entities::Polymorphic, documentation: { desc: 'A polymorphic model.' } 218 | expose :mixed, using: TheseApi::Entities::MixedType, documentation: { desc: 'A model with mix of types.' } 219 | expose :merged_attribute, using: ThisApi::Entities::Nested, merge: true 220 | end 221 | end 222 | 223 | class ResponseEntityApi < Grape::API 224 | format :json 225 | desc 'This returns something', 226 | is_array: true, 227 | entity: Entities::SomeEntity 228 | get '/some_entity' do 229 | something = OpenStruct.new text: 'something' 230 | present something, with: Entities::SomeEntity 231 | end 232 | 233 | add_swagger_documentation 234 | end 235 | end 236 | end 237 | 238 | def app 239 | TheseApi::ResponseEntityApi 240 | end 241 | 242 | it 'prefers entity over other `using` values' do 243 | expect(subject['TheseApi_Entities_Values']).to eql( 244 | 'type' => 'object', 245 | 'properties' => { 246 | 'guid' => { 'type' => 'string', 'enum' => %w[a b c], 'default' => 'c', 'description' => 'Some values' }, 247 | 'uuid' => { 'type' => 'string', 'format' => 'own', 'description' => 'customer uuid', 248 | 'example' => 'e3008fba-d53d-4bcc-a6ae-adc56dff8020' }, 249 | 'color' => { 'type' => 'string', 'enum' => %w[red blue], 'description' => 'Color' } 250 | }, 251 | 'required' => %w[guid uuid color] 252 | ) 253 | expect(subject['TheseApi_Entities_Kind']).to eql( 254 | 'type' => 'object', 255 | 'properties' => { 256 | 'id' => { 'type' => 'integer', 'format' => 'int32', 'description' => 'id of the kind.', 'enum' => [1, 2], 257 | 'readOnly' => true }, 258 | 'title' => { 'type' => 'string', 'description' => 'Title of the kind.', 'readOnly' => false }, 259 | 'type' => { 'type' => 'string', 'description' => 'Type of the kind.', 'readOnly' => true } 260 | }, 261 | 'required' => %w[id title type] 262 | ) 263 | expect(subject['TheseApi_Entities_Tag']).to eql( 264 | 'type' => 'object', 265 | 'properties' => { 'name' => { 'type' => 'string', 'description' => 'Name', 'example' => 'random_tag' } }, 266 | 'required' => %w[name] 267 | ) 268 | expect(subject['TheseApi_Entities_Relation']).to eql( 269 | 'type' => 'object', 270 | 'properties' => { 'name' => { 'type' => 'string', 'description' => 'Name' } }, 271 | 'required' => %w[name] 272 | ) 273 | expect(subject['TheseApi_Entities_Nested']).to eq( 274 | 'type' => 'object', 275 | 'properties' => { 276 | 'nested' => { 277 | 'type' => 'object', 278 | 'properties' => { 279 | 'some1' => { 'type' => 'string', 'description' => 'Nested some 1' }, 280 | 'some2' => { 'type' => 'string', 'description' => 'Nested some 2' } 281 | }, 282 | 'description' => 'Nested entity', 283 | 'required' => %w[some1 some2] 284 | }, 285 | 'aliased' => { 286 | 'type' => 'object', 287 | 'properties' => { 288 | 'some1' => { 'type' => 'string', 'description' => 'Alias some 1' } 289 | }, 290 | 'required' => %w[some1] 291 | }, 292 | 'deep_nested' => { 293 | 'type' => 'object', 294 | 'properties' => { 295 | 'level_1' => { 296 | 'type' => 'object', 297 | 'properties' => { 298 | 'level_2' => { 'type' => 'string', 'description' => 'Level 2' } 299 | }, 300 | 'description' => 'More deepest nested entity', 301 | 'required' => %w[level_2] 302 | } 303 | }, 304 | 'description' => 'Deep nested entity', 305 | 'required' => %w[level_1] 306 | }, 307 | 'nested_required' => { 308 | 'type' => 'object', 309 | 'properties' => { 310 | 'some1' => { 'type' => 'string', 'description' => 'Required some 1' }, 311 | 'some2' => { 'type' => 'string', 'description' => 'Required some 2' }, 312 | 'some3' => { 'type' => 'string', 'description' => 'Optional some 3' }, 313 | 'some4' => { 'type' => 'string', 'description' => 'Optional some 4' }, 314 | 'some5' => { 'type' => 'string', 'description' => 'Optional some 5' }, 315 | 'some6' => { 'type' => 'string', 'description' => 'Optional some 6' } 316 | }, 317 | 'required' => %w[some1 some2] 318 | }, 319 | 'nested_array' => { 320 | 'type' => 'array', 321 | 'items' => { 322 | 'type' => 'object', 323 | 'properties' => { 324 | 'id' => { 'type' => 'integer', 'format' => 'int32', 'description' => 'Collection element id' }, 325 | 'name' => { 'type' => 'string', 'description' => 'Collection element name' } 326 | }, 327 | 'required' => %w[id name] 328 | }, 329 | 'description' => 'Nested array' 330 | } 331 | }, 332 | 'required' => %w[nested aliased deep_nested nested_required nested_array] 333 | ) 334 | expect(subject['TheseApi_Entities_NestedChild']).to eq( 335 | 'type' => 'object', 336 | 'properties' => { 337 | 'nested' => { 338 | 'type' => 'object', 339 | 'properties' => { 340 | 'some1' => { 'type' => 'string', 'description' => 'Nested some 1' }, 341 | 'some2' => { 'type' => 'string', 'description' => 'Nested some 2' }, 342 | 'some3' => { 'type' => 'string', 'description' => 'Nested some 3' } 343 | }, 344 | 'description' => 'Nested entity', 345 | 'required' => %w[some1 some2 some3] 346 | }, 347 | 'aliased' => { 348 | 'type' => 'object', 349 | 'properties' => { 350 | 'some1' => { 'type' => 'string', 'description' => 'Alias some 1' }, 351 | 'some2' => { 'type' => 'string', 'description' => 'Alias some 2' } 352 | }, 353 | 'required' => %w[some1 some2] 354 | }, 355 | 'deep_nested' => { 356 | 'type' => 'object', 357 | 'properties' => { 358 | 'level_1' => { 359 | 'type' => 'object', 360 | 'properties' => { 361 | 'level_2' => { 362 | 'type' => 'object', 363 | 'properties' => { 364 | 'level_3' => { 365 | 'type' => 'string', 366 | 'description' => 'Level 3' 367 | } 368 | }, 369 | 'description' => 'Level 2', 370 | 'required' => %w[level_3] 371 | } 372 | }, 373 | 'description' => 'More deepest nested entity', 374 | 'required' => %w[level_2] 375 | } 376 | }, 377 | 'description' => 'Deep nested entity', 378 | 'required' => %w[level_1] 379 | }, 380 | 'nested_required' => { 381 | 'type' => 'object', 382 | 'properties' => { 383 | 'some1' => { 'type' => 'string', 'description' => 'Required some 1' }, 384 | 'some2' => { 'type' => 'string', 'description' => 'Required some 2' }, 385 | 'some3' => { 'type' => 'string', 'description' => 'Optional some 3' }, 386 | 'some4' => { 'type' => 'string', 'description' => 'Optional some 4' }, 387 | 'some5' => { 'type' => 'string', 'description' => 'Optional some 5' }, 388 | 'some6' => { 'type' => 'string', 'description' => 'Optional some 6' } 389 | }, 390 | 'required' => %w[some1 some2] 391 | }, 392 | 'nested_array' => { 393 | 'type' => 'array', 394 | 'items' => { 395 | 'type' => 'object', 396 | 'properties' => { 397 | 'id' => { 'type' => 'integer', 'format' => 'int32', 'description' => 'Collection element id' }, 398 | 'name' => { 'type' => 'string', 'description' => 'Collection element name' }, 399 | 'category' => { 'type' => 'string', 'description' => 'Collection element category' } 400 | }, 401 | 'required' => %w[id name category] 402 | }, 403 | 'description' => 'Nested array' 404 | } 405 | }, 406 | 'required' => %w[nested aliased deep_nested nested_required nested_array] 407 | ) 408 | expect(subject['TheseApi_Entities_Polymorphic']).to eql( 409 | 'type' => 'object', 410 | 'properties' => { 411 | 'kind' => { '$ref' => '#/definitions/TheseApi_Entities_Kind', 'description' => 'Polymorphic Kind' }, 412 | 'values' => { '$ref' => '#/definitions/TheseApi_Entities_Values', 'description' => 'Polymorphic Values' }, 413 | 'str' => { 'type' => 'string', 'description' => 'Polymorphic String' }, 414 | 'num' => { 'type' => 'integer', 'format' => 'int32', 'description' => 'Polymorphic Number' } 415 | } 416 | ) 417 | 418 | expect(subject['TheseApi_Entities_MixedType']).to eql( 419 | 'type' => 'object', 420 | 'properties' => { 421 | 'tags' => { 422 | 'description' => 'Tags', 423 | 'items' => { '$ref' => '#/definitions/TheseApi_Entities_TagType' }, 424 | 'type' => 'array' 425 | } 426 | }, 427 | 'required' => %w[tags] 428 | ) 429 | 430 | expect(subject['TheseApi_Entities_SomeEntity']).to eql( 431 | 'type' => 'object', 432 | 'properties' => { 433 | 'text' => { 'type' => 'string', 'description' => 'Content of something.' }, 434 | 'kind' => { '$ref' => '#/definitions/TheseApi_Entities_Kind', 'description' => 'The kind of this something.' }, 435 | 'kind2' => { '$ref' => '#/definitions/TheseApi_Entities_Kind', 'description' => 'Secondary kind.' }, 436 | 'kind3' => { '$ref' => '#/definitions/TheseApi_Entities_Kind', 'description' => 'Tertiary kind.' }, 437 | 'tags' => { 'type' => 'array', 'items' => { '$ref' => '#/definitions/TheseApi_Entities_Tag' }, 438 | 'description' => 'Tags.' }, 439 | 'relation' => { '$ref' => '#/definitions/TheseApi_Entities_Relation', 'description' => 'A related model.' }, 440 | 'values' => { '$ref' => '#/definitions/TheseApi_Entities_Values', 'description' => 'Tertiary kind.' }, 441 | 'nested' => { '$ref' => '#/definitions/TheseApi_Entities_Nested', 'description' => 'Nested object.' }, 442 | 'nested_child' => { '$ref' => '#/definitions/TheseApi_Entities_NestedChild', 443 | 'description' => 'Nested child object.' }, 444 | 'code' => { 'type' => 'string', 'description' => 'Error code' }, 445 | 'message' => { 'type' => 'string', 'description' => 'Error message' }, 446 | 'polymorphic' => { '$ref' => '#/definitions/TheseApi_Entities_Polymorphic', 447 | 'description' => 'A polymorphic model.' }, 448 | 'mixed' => { 449 | '$ref' => '#/definitions/TheseApi_Entities_MixedType', 450 | 'description' => 'A model with mix of types.' 451 | }, 452 | 'attr' => { 'type' => 'string', 'description' => 'Attribute' } 453 | }, 454 | 'required' => %w[text kind kind2 kind3 tags relation values nested nested_child 455 | polymorphic mixed attr code message], 456 | 'description' => 'TheseApi_Entities_SomeEntity model' 457 | ) 458 | end 459 | end 460 | --------------------------------------------------------------------------------