diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b93c609..a6620fd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,13 +4,19 @@ jobs: test: strategy: matrix: - ruby: ["3.0", "3.1", "3.2"] + ruby: ["3.3", "3.4", "4.0"] gemfile: [Gemfile] include: - - ruby: "3.2" + - ruby: "3.3" + gemfile: "gemfiles/rails-8.0.gemfile" + - ruby: "3.3" + gemfile: "gemfiles/rails-7.2.gemfile" + - ruby: "3.3" + gemfile: "gemfiles/rails-7.1.gemfile" + - ruby: "3.3" + gemfile: "gemfiles/rails-7.0.gemfile" + - ruby: "4.0" gemfile: "gemfiles/rails-7.0.gemfile" - - ruby: "3.2" - gemfile: "gemfiles/rails-6.1.gemfile" env: BUNDLE_GEMFILE: "${{ matrix.gemfile }}" @@ -23,7 +29,3 @@ jobs: ruby-version: ${{ matrix.ruby }} bundler-cache: true - run: bundle exec rake - - name: Send coverage - uses: paambaati/codeclimate-action@v5 - env: - CC_TEST_REPORTER_ID: 921d046db793707b7d0c0d1305970a137fdf98fbc6ceeb6c355c44757040244f diff --git a/.ruby-version b/.ruby-version index be94e6f..7919852 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.2 +4.0.9 diff --git a/CHANGELOG.md b/CHANGELOG.md index 03ed53c..9e4e3ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ ## Not released yet +## 0.6.2 + +- Fix `rake api_spec` raising `NoMethodError` on Rails 7.0 or later https://github.com/wantedly/open_api_annotator/pull/20 +- Fix `$ref` of `belongs_to` with `serializer:` option being an array like `#/components/schemas/["User"]` https://github.com/wantedly/open_api_annotator/pull/20 +- Skip `has_many` annotated with a non-array type (e.g. `type: User`) or an empty array with a warning, instead of raising an error or emitting a broken `$ref` https://github.com/wantedly/open_api_annotator/pull/20 +- Require open_api >= 0.6.3 https://github.com/wantedly/open_api_annotator/pull/20 +- Bump Ruby and Rails https://github.com/wantedly/open_api_annotator/pull/20 + - Supported Rubies: 3.3, 3.4, 4.0 + - Supported Rails: 7.0, 7.1, 7.2, 8.0, 8.1 + ## 0.6.1 - Reflect ownership transfer https://github.com/wantedly/open_api_annotator/pull/18 diff --git a/README.md b/README.md index 0f0b560..7ebbaff 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# OpenApiAnnotator [![Gem Version](https://badge.fury.io/rb/open_api_annotator.svg)](https://badge.fury.io/rb/open_api_annotator) [![Build Status](https://travis-ci.org/wantedly/open_api_annotator.svg?branch=master)](https://travis-ci.org/wantedly/open_api_annotator) [![Maintainability](https://api.codeclimate.com/v1/badges/8be7a273496459c62190/maintainability)](https://codeclimate.com/github/wantedly/open_api_annotator/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/8be7a273496459c62190/test_coverage)](https://codeclimate.com/github/wantedly/open_api_annotator/test_coverage) +# OpenApiAnnotator [![Gem Version](https://badge.fury.io/rb/open_api_annotator.svg)](https://badge.fury.io/rb/open_api_annotator) [![Build Status](https://travis-ci.org/wantedly/open_api_annotator.svg?branch=master)](https://travis-ci.org/wantedly/open_api_annotator) OpenApiAnnotator realizes to generate OpenAPI spec by annotating to controllers and serializers. If you use ActiveModelSerializer, this is the best way to generate OpenAPI spec. diff --git a/gemfiles/rails-6.1.gemfile b/gemfiles/rails-7.1.gemfile similarity index 87% rename from gemfiles/rails-6.1.gemfile rename to gemfiles/rails-7.1.gemfile index 5fec9e5..76058de 100644 --- a/gemfiles/rails-6.1.gemfile +++ b/gemfiles/rails-7.1.gemfile @@ -5,4 +5,4 @@ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } # Specify your gem's dependencies in open_api_annotator.gemspec gemspec path: '..' -gem 'railties', '~> 6.1.0' +gem 'railties', '~> 7.1.0' diff --git a/gemfiles/rails-7.2.gemfile b/gemfiles/rails-7.2.gemfile new file mode 100644 index 0000000..527e70e --- /dev/null +++ b/gemfiles/rails-7.2.gemfile @@ -0,0 +1,8 @@ +source "https://rubygems.org" + +git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } + +# Specify your gem's dependencies in open_api_annotator.gemspec +gemspec path: '..' + +gem 'railties', '~> 7.2.0' diff --git a/gemfiles/rails-8.0.gemfile b/gemfiles/rails-8.0.gemfile new file mode 100644 index 0000000..e5e63e5 --- /dev/null +++ b/gemfiles/rails-8.0.gemfile @@ -0,0 +1,8 @@ +source "https://rubygems.org" + +git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } + +# Specify your gem's dependencies in open_api_annotator.gemspec +gemspec path: '..' + +gem 'railties', '~> 8.0.0' diff --git a/lib/open_api_annotator/components_builder.rb b/lib/open_api_annotator/components_builder.rb index c43a2ce..cbe020d 100644 --- a/lib/open_api_annotator/components_builder.rb +++ b/lib/open_api_annotator/components_builder.rb @@ -56,8 +56,12 @@ def build_has_many_association_properties(serializer) properties = {} serializer.open_api_has_many_associations.each do |association| next unless association.valid? - content = association.type.first - return unless content + # Calling `first` on a non-array type (e.g. an ActiveRecord model) would run a query + content = association.type.first if association.type.is_a?(Array) + unless content + warn "Skipped #{serializer.name}##{association.name}: type of has_many should be an array with one element, but got #{association.type}." + next + end content_name = content.try(:name) || content.to_s properties[association.name.to_sym] = OpenApi::Schema.new( type: "array", diff --git a/lib/open_api_annotator/paths_builder.rb b/lib/open_api_annotator/paths_builder.rb index 3508f3b..98cccd0 100644 --- a/lib/open_api_annotator/paths_builder.rb +++ b/lib/open_api_annotator/paths_builder.rb @@ -140,7 +140,7 @@ class RoutesFinder def find_all @routes ||= Rails.application.routes.routes.routes.map do |route| parameters = [] - path = PathResolver.new.resolve(route.path.ast, parameters) + path = PathResolver.new.resolve(route.path.spec, parameters) controller = route.requirements[:controller] action = route.requirements[:action] Route.new(http_verb: route.verb, path: path, controller_name: controller, action_name: action, parameters: parameters) diff --git a/lib/open_api_annotator/serializer_annotatable.rb b/lib/open_api_annotator/serializer_annotatable.rb index be0b2c9..e2a5a4e 100644 --- a/lib/open_api_annotator/serializer_annotatable.rb +++ b/lib/open_api_annotator/serializer_annotatable.rb @@ -77,7 +77,7 @@ def open_api_belongs_to_associations reflection.is_a?(ActiveModel::Serializer::BelongsToReflection) }.map do |reflection| serializer_class = reflection.options[:serializer] - type = serializer_class ? [serializer_class.open_api_resource_name] : reflection.options[:type] + type = serializer_class ? serializer_class.open_api_resource_name : reflection.options[:type] Association.new(reflection.name.to_sym, type, reflection.options[:nullable]) end end diff --git a/lib/open_api_annotator/version.rb b/lib/open_api_annotator/version.rb index 56574ab..f0e5f3b 100644 --- a/lib/open_api_annotator/version.rb +++ b/lib/open_api_annotator/version.rb @@ -1,3 +1,3 @@ module OpenApiAnnotator - VERSION = "0.6.1" + VERSION = "0.6.2" end diff --git a/open_api_annotator.gemspec b/open_api_annotator.gemspec index 5420347..691855f 100644 --- a/open_api_annotator.gemspec +++ b/open_api_annotator.gemspec @@ -21,14 +21,13 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_dependency "open_api", ">= 0.5.0" + spec.add_dependency "open_api", ">= 0.6.3" spec.add_dependency "active_model_serializers", "~> 0.10.0" - rails_versions = ['>= 5.0', '< 7.2'] + rails_versions = ['>= 5.0', '< 8.2'] spec.add_dependency "actionpack", rails_versions spec.add_dependency "railties", rails_versions - spec.add_development_dependency "bundler", "~> 2.0" spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "rspec", "~> 3.0" spec.add_development_dependency "pry" diff --git a/spec/open_api_annotator/components_builder_spec.rb b/spec/open_api_annotator/components_builder_spec.rb index cfc6345..d0610d2 100644 --- a/spec/open_api_annotator/components_builder_spec.rb +++ b/spec/open_api_annotator/components_builder_spec.rb @@ -89,6 +89,35 @@ ) end end + + context "when has_many associations are annotated with non-array types" do + let(:class_has_many_association) { OpenApiAnnotator::Association.new("logs", Log, true) } + let(:string_has_many_association) { OpenApiAnnotator::Association.new("notes", "Note", true) } + + before do + # Like an ActiveRecord model, `first` would run a query + stub_const("Log", Class.new { def self.first; raise "Log.first must not be called"; end }) + allow(BookSerializer).to receive(:open_api_has_many_associations).and_return([class_has_many_association, string_has_many_association, has_many_association]) + end + + it "skips only the associations with warnings" do + expect { subject }.to output(/Skipped BookSerializer#logs: .* but got Log\..*Skipped BookSerializer#notes: .* but got Note\./m).to_stderr + expect(subject.schemas["Book"].properties.keys).to contain_exactly(:id, :published_at, :tags, :authors, :cover_image, :publisher) + end + end + + context "when a has_many association is annotated with an empty array" do + let(:empty_array_has_many_association) { OpenApiAnnotator::Association.new("logs", [], true) } + + before do + allow(BookSerializer).to receive(:open_api_has_many_associations).and_return([empty_array_has_many_association, has_many_association]) + end + + it "skips only the association with a warning" do + expect { subject }.to output(/Skipped BookSerializer#logs/).to_stderr + expect(subject.schemas["Book"].properties.keys).to contain_exactly(:id, :published_at, :tags, :authors, :cover_image, :publisher) + end + end end end diff --git a/spec/open_api_annotator/routes_finder_spec.rb b/spec/open_api_annotator/routes_finder_spec.rb index f83eb47..bab5026 100644 --- a/spec/open_api_annotator/routes_finder_spec.rb +++ b/spec/open_api_annotator/routes_finder_spec.rb @@ -12,7 +12,7 @@ let(:journey_route) { double(:journey_route) } before do - allow(path).to receive(:ast).and_return(double(:ast)) + allow(path).to receive(:spec).and_return(double(:spec)) allow(journey_route).to receive(:path).and_return(path) allow(journey_route).to receive(:verb).and_return("GET") allow(journey_route).to receive(:requirements).and_return({ controller: "/api/v1/books", action: "show" }) @@ -36,6 +36,40 @@ ) end end + + describe "#find_all with routes drawn by Rails" do + subject { described_class.new.find_all } + + let(:rails_application) { double(:rails_application) } + let(:route_set) do + ActionDispatch::Routing::RouteSet.new.tap do |route_set| + route_set.draw do + get "/api/v1/books/:book_id/authors/:id", to: "api/v1/authors#show" + end + # Rails 7.0+ wraps Pattern#ast in Journey::Ast and clears it on eager_load!, while Pattern#spec survives both + route_set.eager_load! + end + end + + before do + allow(Rails).to receive(:application).and_return(rails_application) + allow(rails_application).to receive(:routes).and_return(route_set) + end + + it "resolves path and parameters from the route" do + is_expected.to match( + [ + OpenApiAnnotator::Route.new( + http_verb: "GET", + path: "/api/v1/books/{book_id}/authors/{id}", + controller_name: "api/v1/authors", + action_name: "show", + parameters: [{ name: "book_id" }, { name: "id" }] + ) + ] + ) + end + end end RSpec.describe OpenApiAnnotator::PathResolver do diff --git a/spec/open_api_annotator/serializer_annotatable_spec.rb b/spec/open_api_annotator/serializer_annotatable_spec.rb new file mode 100644 index 0000000..5310b0a --- /dev/null +++ b/spec/open_api_annotator/serializer_annotatable_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' +require 'active_model_serializers' + +RSpec.describe OpenApiAnnotator::SerializerAnnotatable do + before do + # Annotations without `type:` are warned via Rails.logger + allow(Rails).to receive(:logger).and_return(Logger.new(nil)) + stub_const("AuthorSerializer", Class.new(ActiveModel::Serializer) { include OpenApiAnnotator::SerializerAnnotatable }) + stub_const("BookSerializer", Class.new(ActiveModel::Serializer) { include OpenApiAnnotator::SerializerAnnotatable }) + end + + describe ".open_api_has_many_associations" do + subject { BookSerializer.open_api_has_many_associations } + + before { BookSerializer.has_many :authors, nullable: false, serializer: AuthorSerializer } + + it "returns the resource name of the serializer as an array type" do + is_expected.to eq [OpenApiAnnotator::Association.new(:authors, ["Author"], false)] + end + end + + describe ".open_api_has_one_associations" do + subject { BookSerializer.open_api_has_one_associations } + + before { BookSerializer.has_one :author, nullable: false, serializer: AuthorSerializer } + + it "returns the resource name of the serializer as a single type" do + is_expected.to eq [OpenApiAnnotator::Association.new(:author, "Author", false)] + end + end + + describe ".open_api_belongs_to_associations" do + subject { BookSerializer.open_api_belongs_to_associations } + + before { BookSerializer.belongs_to :author, nullable: false, serializer: AuthorSerializer } + + it "returns the resource name of the serializer as a single type" do + is_expected.to eq [OpenApiAnnotator::Association.new(:author, "Author", false)] + end + end +end