From 328dff2832ea1efe27342192cdd3eba3c9962632 Mon Sep 17 00:00:00 2001 From: Aleksei <9ceb2990-3744-4629-82f3-19bc5c80b3a2@mackevich.addymail.com> Date: Thu, 4 Apr 2024 15:32:56 +0500 Subject: [PATCH 01/12] Add base support hanami router --- lib/rspec/openapi.rb | 4 + lib/rspec/openapi/extractors.rb | 4 + lib/rspec/openapi/extractors/hanami.rb | 87 ++ lib/rspec/openapi/extractors/rack.rb | 30 + lib/rspec/openapi/extractors/rails.rb | 57 ++ lib/rspec/openapi/record_builder.rb | 71 +- lib/rspec/openapi/rspec_hooks.rb | 14 +- spec/apps/hanami/doc/openapi.json | 1007 ------------------------ spec/apps/hanami/doc/openapi.yaml | 255 +++--- spec/requests/hanami_spec.rb | 1 - 10 files changed, 335 insertions(+), 1195 deletions(-) create mode 100644 lib/rspec/openapi/extractors.rb create mode 100644 lib/rspec/openapi/extractors/hanami.rb create mode 100644 lib/rspec/openapi/extractors/rack.rb create mode 100644 lib/rspec/openapi/extractors/rails.rb delete mode 100644 spec/apps/hanami/doc/openapi.json diff --git a/lib/rspec/openapi.rb b/lib/rspec/openapi.rb index 995093b..aba3348 100644 --- a/lib/rspec/openapi.rb +++ b/lib/rspec/openapi.rb @@ -11,6 +11,10 @@ require 'rspec/openapi/schema_cleaner' require 'rspec/openapi/schema_sorter' require 'rspec/openapi/key_transformer' +require 'rspec/openapi/extractors' +require 'rspec/openapi/extractors/rack' +require 'rspec/openapi/extractors/rails' +require 'rspec/openapi/extractors/hanami' require 'rspec/openapi/minitest_hooks' if Object.const_defined?('Minitest') require 'rspec/openapi/rspec_hooks' if ENV['OPENAPI'] && Object.const_defined?('RSpec') diff --git a/lib/rspec/openapi/extractors.rb b/lib/rspec/openapi/extractors.rb new file mode 100644 index 0000000..f117f13 --- /dev/null +++ b/lib/rspec/openapi/extractors.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +module RSpec::OpenAPI::Extractors +end diff --git a/lib/rspec/openapi/extractors/hanami.rb b/lib/rspec/openapi/extractors/hanami.rb new file mode 100644 index 0000000..952beae --- /dev/null +++ b/lib/rspec/openapi/extractors/hanami.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true +require 'dry/inflector' + +class << RSpec::OpenAPI::Extractors::Hanami = Object.new + + # @param [RSpec::ExampleGroups::*] context + # @param [RSpec::Core::Example] example + # @return Array + def request_attributes(request, example) + metadata = example.metadata[:openapi] || {} + summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example) + tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example) + operation_id = metadata[:operation_id] + required_request_params = metadata[:required_request_params] || [] + security = metadata[:security] + description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example) + deprecated = metadata[:deprecated] + raw_path_params = request.path_parameters + path = request.path + route = Hanami.app.router.recognize(request.path, method: request.method) + + path = add_id(path, route) + result = generate_summary_and_tag(path, request.method) + summary ||= result[0] + tags ||= result[1] + + [path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated] + end + + # @param [RSpec::ExampleGroups::*] context + def request_response(context) + request = ActionDispatch::Request.new(context.last_request.env) + request.body.rewind if request.body.respond_to?(:rewind) + response = ActionDispatch::TestResponse.new(*context.last_response.to_a) + + [request, response] + end + + def add_id(path, route) + return path if route.params.empty? + + route.params.each_pair do |key, value| + next unless number_or_nil(value) + + path = path.sub("/#{value}", "/{#{key}}") + end + + path + end + + def generate_summary_and_tag(path, method) + case path + when ->(path) { path.end_with?('{id}/edit') && method == 'GET' } + ['edit', extract_tag(path, '/{id}/edit')] + when ->(path) { path.end_with?('{id}') && method == 'GET' } + ['show', extract_tag(path, '/{id}')] + when ->(path) { path.end_with?('{id}') && %w[PATCH PUT POST].include?(method) } + ['update', extract_tag(path, '/{id}')] + when ->(path) { path.end_with?('{id}') && method == 'DELETE' } + ['destroy', extract_tag(path, '/{id}')] + when ->(path) { path.end_with?('/new') && method == 'GET' } + ['new', extract_tag(path, '/new')] + when ->(_path) { method == 'GET' } + ['index', extract_tag(path)] + when ->(_path) { method == 'POST' } + ['create', extract_tag(path)] + else + ["#{method} #{path}", []] + end + end + + def extract_tag(path, prefix = nil) + path = path.delete_suffix(prefix) if prefix + + [inflector.classify(path.split(%r{/+}).last)] + end + + def inflector + @inflector ||= Dry::Inflector.new + end + + def number_or_nil(string) + Integer(string || '') + rescue ArgumentError + nil + end +end diff --git a/lib/rspec/openapi/extractors/rack.rb b/lib/rspec/openapi/extractors/rack.rb new file mode 100644 index 0000000..4209c6a --- /dev/null +++ b/lib/rspec/openapi/extractors/rack.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class << RSpec::OpenAPI::Extractors::Rack = Object.new + # @param [RSpec::ExampleGroups::*] context + # @param [RSpec::Core::Example] example + # @return Array + def request_attributes(request, example) + metadata = example.metadata[:openapi] || {} + summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example) + tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example) + operation_id = metadata[:operation_id] + required_request_params = metadata[:required_request_params] || [] + security = metadata[:security] + description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example) + deprecated = metadata[:deprecated] + raw_path_params = request.path_parameters + path = request.path + summary ||= "#{request.method} #{path}" + [path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated] + end + + # @param [RSpec::ExampleGroups::*] context + def request_response(context) + request = ActionDispatch::Request.new(context.last_request.env) + request.body.rewind if request.body.respond_to?(:rewind) + response = ActionDispatch::TestResponse.new(*context.last_response.to_a) + + [request, response] + end +end diff --git a/lib/rspec/openapi/extractors/rails.rb b/lib/rspec/openapi/extractors/rails.rb new file mode 100644 index 0000000..5222bcd --- /dev/null +++ b/lib/rspec/openapi/extractors/rails.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class << RSpec::OpenAPI::Extractors::Rails = Object.new + # @param [RSpec::ExampleGroups::*] context + # @param [RSpec::Core::Example] example + # @return Array + def request_attributes(request, example) + metadata = example.metadata[:openapi] || {} + summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example) + tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example) + operation_id = metadata[:operation_id] + required_request_params = metadata[:required_request_params] || [] + security = metadata[:security] + description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example) + deprecated = metadata[:deprecated] + raw_path_params = request.path_parameters + + # Reverse the destructive modification by Rails https://github.com/rails/rails/blob/v6.0.3.4/actionpack/lib/action_dispatch/journey/router.rb#L33-L41 + fixed_request = request.dup + fixed_request.path_info = File.join(request.script_name, request.path_info) if request.script_name.present? + + route, path = find_rails_route(fixed_request) + raise "No route matched for #{fixed_request.request_method} #{fixed_request.path_info}" if route.nil? + + path = path.delete_suffix('(.:format)') + summary ||= route.requirements[:action] + tags ||= [route.requirements[:controller]&.classify].compact + # :controller and :action always exist. :format is added when routes is configured as such. + # TODO: Use .except(:controller, :action, :format) when we drop support for Ruby 2.x + raw_path_params = raw_path_params.slice(*(raw_path_params.keys - RSpec::OpenAPI.ignored_path_params)) + + summary ||= "#{request.method} #{path}" + + [path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated] + end + + # @param [RSpec::ExampleGroups::*] context + def request_response(context) + [context.request, context.response] + end + + # @param [ActionDispatch::Request] request + def find_rails_route(request, app: Rails.application, path_prefix: '') + app.routes.router.recognize(request) do |route| + path = route.path.spec.to_s + if route.app.matches?(request) + if route.app.engine? + route, path = find_rails_route(request, app: route.app.app, path_prefix: path) + next if route.nil? + end + return [route, path_prefix + path] + end + end + + nil + end +end diff --git a/lib/rspec/openapi/record_builder.rb b/lib/rspec/openapi/record_builder.rb index 31e7adf..32adf97 100644 --- a/lib/rspec/openapi/record_builder.rb +++ b/lib/rspec/openapi/record_builder.rb @@ -7,12 +7,12 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new # @param [RSpec::ExampleGroups::*] context # @param [RSpec::Core::Example] example # @return [RSpec::OpenAPI::Record,nil] - def build(context, example:) - request, response = extract_request_response(context) + def build(context, example:, extractor:) + request, response = extractor.request_response(context) return if request.nil? path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated = - extract_request_attributes(request, example) + extractor.request_attributes(request, example) return if RSpec::OpenAPI.ignored_paths.any? { |ignored_path| path.match?(ignored_path) } @@ -69,71 +69,6 @@ def extract_headers(request, response) [request_headers, response_headers] end - def extract_request_attributes(request, example) - metadata = example.metadata[:openapi] || {} - summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example) - tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example) - operation_id = metadata[:operation_id] - required_request_params = metadata[:required_request_params] || [] - security = metadata[:security] - description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example) - deprecated = metadata[:deprecated] - raw_path_params = request.path_parameters - path = request.path - if rails? - # Reverse the destructive modification by Rails https://github.com/rails/rails/blob/v6.0.3.4/actionpack/lib/action_dispatch/journey/router.rb#L33-L41 - fixed_request = request.dup - fixed_request.path_info = File.join(request.script_name, request.path_info) if request.script_name.present? - - route, path = find_rails_route(fixed_request) - raise "No route matched for #{fixed_request.request_method} #{fixed_request.path_info}" if route.nil? - - path = path.delete_suffix('(.:format)') - summary ||= route.requirements[:action] - tags ||= [route.requirements[:controller]&.classify].compact - # :controller and :action always exist. :format is added when routes is configured as such. - # TODO: Use .except(:controller, :action, :format) when we drop support for Ruby 2.x - raw_path_params = raw_path_params.slice(*(raw_path_params.keys - RSpec::OpenAPI.ignored_path_params)) - end - summary ||= "#{request.method} #{path}" - [path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated] - end - - def extract_request_response(context) - if rack_test?(context) - request = ActionDispatch::Request.new(context.last_request.env) - request.body.rewind if request.body.respond_to?(:rewind) - response = ActionDispatch::TestResponse.new(*context.last_response.to_a) - else - request = context.request - response = context.response - end - [request, response] - end - - def rails? - defined?(Rails) && Rails.respond_to?(:application) && Rails.application - end - - def rack_test?(context) - defined?(Rack::Test::Methods) && context.class < Rack::Test::Methods - end - - # @param [ActionDispatch::Request] request - def find_rails_route(request, app: Rails.application, path_prefix: '') - app.routes.router.recognize(request) do |route| - path = route.path.spec.to_s - if route.app.matches?(request) - if route.app.engine? - route, path = find_rails_route(request, app: route.app.app, path_prefix: path) - next if route.nil? - end - return [route, path_prefix + path] - end - end - nil - end - # workaround to get real request parameters # because ActionController::ParamsWrapper overwrites request_parameters def raw_request_params(request) diff --git a/lib/rspec/openapi/rspec_hooks.rb b/lib/rspec/openapi/rspec_hooks.rb index 499c15e..a9cd679 100644 --- a/lib/rspec/openapi/rspec_hooks.rb +++ b/lib/rspec/openapi/rspec_hooks.rb @@ -5,7 +5,7 @@ RSpec.configuration.after(:each) do |example| if RSpec::OpenAPI.example_types.include?(example.metadata[:type]) && example.metadata[:openapi] != false path = RSpec::OpenAPI.path.then { |p| p.is_a?(Proc) ? p.call(example) : p } - record = RSpec::OpenAPI::RecordBuilder.build(self, example: example) + record = RSpec::OpenAPI::RecordBuilder.build(self, example: example, extractor: find_extractor) RSpec::OpenAPI.path_records[path] << record if record end end @@ -19,3 +19,15 @@ RSpec.configuration.reporter.message colorizer.wrap(error_message, :failure) end end + +def find_extractor + if defined?(Rails) && Rails.respond_to?(:application) && Rails.application + RSpec::OpenAPI::Extractors::Rails + elsif defined?(Hanami) + RSpec::OpenAPI::Extractors::Hanami + # elsif defined?(Roda) + # some Roda extractor + else + RSpec::OpenAPI::Extractors::Rack + end +end diff --git a/spec/apps/hanami/doc/openapi.json b/spec/apps/hanami/doc/openapi.json deleted file mode 100644 index 9d62894..0000000 --- a/spec/apps/hanami/doc/openapi.json +++ /dev/null @@ -1,1007 +0,0 @@ -{ - "openapi": "3.0.3", - "info": { - "title": "OpenAPI Documentation", - "version": "1.0.0", - "description": "My beautiful hanami API", - "license": { - "name": "Apache 2.0", - "url": "https://www.apache.org/licenses/LICENSE-2.0.html" - } - }, - "servers": [ - { - "url": "http://localhost:3000" - } - ], - "paths": { - "/images": { - "get": { - "summary": "GET /images", - "responses": { - "200": { - "description": "can return an object with an attribute of empty array", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - } - } - }, - "required": [ - "name", - "tags" - ] - } - }, - "example": [ - { - "name": "file.png", - "tags": [ - - ] - } - ] - } - } - } - } - } - }, - "/images/1": { - "get": { - "summary": "GET /images/1", - "responses": { - "200": { - "description": "returns a image payload", - "content": { - "image/png": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } - }, - "/images/upload": { - "post": { - "summary": "POST /images/upload", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "image": { - "type": "string", - "format": "binary" - } - }, - "required": [ - "image" - ] - }, - "example": { - "image": "test.png" - } - } - } - }, - "responses": { - "200": { - "description": "returns a image payload with upload", - "content": { - "image/png": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } - }, - "/images/upload_multiple": { - "post": { - "summary": "POST /images/upload_multiple", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "images": { - "type": "array", - "items": { - "type": "string", - "format": "binary" - } - } - }, - "required": [ - "images" - ] - }, - "example": { - "images": [ - "test.png", - "test.png" - ] - } - } - } - }, - "responses": { - "200": { - "description": "returns a image payload with upload multiple", - "content": { - "image/png": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } - }, - "/images/upload_multiple_nested": { - "post": { - "summary": "POST /images/upload_multiple_nested", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "images": { - "type": "array", - "items": { - "type": "object", - "properties": { - "image": { - "type": "string", - "format": "binary" - } - }, - "required": [ - "image" - ] - } - } - }, - "required": [ - "images" - ] - }, - "example": { - "images": [ - { - "image": "test.png" - }, - { - "image": "test.png" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "returns a image payload with upload multiple nested", - "content": { - "image/png": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } - }, - "/images/upload_nested": { - "post": { - "summary": "POST /images/upload_nested", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "nested_image": { - "type": "object", - "properties": { - "image": { - "type": "string", - "format": "binary" - }, - "caption": { - "type": "string" - } - }, - "required": [ - "image", - "caption" - ] - } - }, - "required": [ - "nested_image" - ] - }, - "example": { - "nested_image": { - "image": "test.png", - "caption": "Some caption" - } - } - } - } - }, - "responses": { - "200": { - "description": "returns a image payload with upload nested", - "content": { - "image/png": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } - }, - "/my_engine/eng/example": { - "get": { - "summary": "GET /my_engine/eng/example", - "responses": { - "200": { - "description": "returns the block content", - "content": { - "text/plain": { - "schema": { - "type": "string" - }, - "example": "AN ENGINE TEST" - } - } - } - } - } - }, - "/my_engine/test": { - "get": { - "summary": "GET /my_engine/test", - "responses": { - "200": { - "description": "returns some content from the engine", - "content": { - "text/plain": { - "schema": { - "type": "string" - }, - "example": "ANOTHER TEST" - } - } - } - } - } - }, - "/secret_items": { - "get": { - "summary": "GET /secret_items", - "security": [ - { - "SecretApiKeyAuth": [ - - ] - } - ], - "responses": { - "200": { - "description": "authorizes with secret key", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "items" - ] - }, - "example": { - "items": [ - "secrets" - ] - } - } - } - } - } - } - }, - "/tables": { - "get": { - "summary": "GET /tables", - "responses": { - "200": { - "description": "with different deep query parameters", - "headers": { - "X-Cursor": { - "schema": { - "type": "integer" - } - } - }, - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "database": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ] - }, - "null_sample": { - "nullable": true - }, - "storage_size": { - "type": "number", - "format": "float" - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "description", - "database", - "null_sample", - "storage_size", - "created_at", - "updated_at" - ] - } - }, - "example": [ - { - "id": 1, - "name": "access", - "description": "logs", - "database": { - "id": 2, - "name": "production" - }, - "null_sample": null, - "storage_size": 12.3, - "created_at": "2020-07-17T00:00:00+00:00", - "updated_at": "2020-07-17T00:00:00+00:00" - } - ] - } - } - }, - "401": { - "description": "does not return tables if unauthorized", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": [ - "message" - ] - }, - "example": { - "message": "Unauthorized" - } - } - } - } - }, - "parameters": [ - { - "name": "X-Authorization-Token", - "in": "header", - "required": true, - "schema": { - "type": "string" - }, - "example": "token" - }, - { - "name": "filter[name]", - "in": "query", - "required": false, - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": [ - "name" - ] - }, - "example": { - "name": "Example Table" - } - }, - { - "name": "filter[price]", - "in": "query", - "required": false, - "schema": { - "type": "object", - "properties": { - "price": { - "type": "string" - } - }, - "required": [ - "price" - ] - }, - "example": { - "price": "0" - } - }, - { - "name": "page", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "example": 1 - }, - { - "name": "per", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "example": 10 - } - ] - }, - "post": { - "summary": "POST /tables", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "database_id": { - "type": "integer" - } - }, - "required": [ - "name", - "description", - "database_id" - ] - }, - "example": { - "name": "k0kubun", - "description": "description", - "database_id": 2 - } - } - } - }, - "responses": { - "201": { - "description": "returns a table", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "database": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ] - }, - "null_sample": { - "nullable": true - }, - "storage_size": { - "type": "number", - "format": "float" - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "description", - "database", - "null_sample", - "storage_size", - "created_at", - "updated_at" - ] - }, - "example": { - "id": 1, - "name": "access", - "description": "logs", - "database": { - "id": 2, - "name": "production" - }, - "null_sample": null, - "storage_size": 12.3, - "created_at": "2020-07-17T00:00:00+00:00", - "updated_at": "2020-07-17T00:00:00+00:00" - } - } - } - }, - "422": { - "description": "fails to create a table (2)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": [ - "error" - ] - }, - "example": { - "error": "invalid name parameter" - } - } - } - } - } - } - }, - "/tables/1": { - "delete": { - "summary": "DELETE /tables/1", - "responses": { - "200": { - "description": "returns a table", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "database": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ] - }, - "null_sample": { - "nullable": true - }, - "storage_size": { - "type": "number", - "format": "float" - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "description", - "database", - "null_sample", - "storage_size", - "created_at", - "updated_at" - ] - }, - "example": { - "id": 1, - "name": "access", - "description": "logs", - "database": { - "id": 2, - "name": "production" - }, - "null_sample": null, - "storage_size": 12.3, - "created_at": "2020-07-17T00:00:00+00:00", - "updated_at": "2020-07-17T00:00:00+00:00" - } - } - } - }, - "202": { - "description": "returns no content if specified" - } - }, - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "type": "object", - "properties": { - "no_content": { - "type": "string" - } - } - }, - "example": { - "no_content": "true" - } - } - } - } - }, - "get": { - "summary": "GET /tables/1", - "responses": { - "200": { - "description": "returns a table", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "database": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ] - }, - "null_sample": { - "nullable": true - }, - "storage_size": { - "type": "number", - "format": "float" - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "description", - "database", - "null_sample", - "storage_size", - "created_at", - "updated_at" - ] - }, - "example": { - "id": 1, - "name": "access", - "description": "logs", - "database": { - "id": 2, - "name": "production" - }, - "null_sample": null, - "storage_size": 12.3, - "created_at": "2020-07-17T00:00:00+00:00", - "updated_at": "2020-07-17T00:00:00+00:00" - } - } - } - }, - "401": { - "description": "does not return a table if unauthorized", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": [ - "message" - ] - }, - "example": { - "message": "Unauthorized" - } - } - } - } - } - }, - "patch": { - "summary": "PATCH /tables/1", - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": [ - "name" - ] - }, - "example": { - "name": "test" - } - } - } - }, - "responses": { - "200": { - "description": "returns a table", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "database": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ] - }, - "null_sample": { - "nullable": true - }, - "storage_size": { - "type": "number", - "format": "float" - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "description", - "database", - "null_sample", - "storage_size", - "created_at", - "updated_at" - ] - }, - "example": { - "id": 1, - "name": "access", - "description": "logs", - "database": { - "id": 2, - "name": "production" - }, - "null_sample": null, - "storage_size": 12.3, - "created_at": "2020-07-17T00:00:00+00:00", - "updated_at": "2020-07-17T00:00:00+00:00" - } - } - } - } - } - } - }, - "/tables/2": { - "get": { - "summary": "GET /tables/2", - "responses": { - "404": { - "description": "does not return a table if not found", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": [ - "message" - ] - }, - "example": { - "message": "not found" - } - } - } - } - } - } - }, - "/test_block": { - "get": { - "summary": "GET /test_block", - "responses": { - "200": { - "description": "returns the block content", - "content": { - "text/plain": { - "schema": { - "type": "string" - }, - "example": "A TEST" - } - } - } - }, - "deprecated": true - } - } - }, - "components": { - "securitySchemes": { - "SecretApiKeyAuth": { - "type": "apiKey", - "in": "header", - "name": "Secret-Key" - } - } - } -} \ No newline at end of file diff --git a/spec/apps/hanami/doc/openapi.yaml b/spec/apps/hanami/doc/openapi.yaml index f26243a..173ea54 100644 --- a/spec/apps/hanami/doc/openapi.yaml +++ b/spec/apps/hanami/doc/openapi.yaml @@ -16,7 +16,9 @@ servers: paths: "/images": get: - summary: GET /images + summary: index + tags: + - Image responses: '200': description: can return an object with an attribute of empty array @@ -38,20 +40,11 @@ paths: example: - name: file.png tags: [] - "/images/1": - get: - summary: GET /images/1 - responses: - '200': - description: returns a image payload - content: - image/png: - schema: - type: string - format: binary "/images/upload": post: - summary: POST /images/upload + summary: create + tags: + - Upload requestBody: content: multipart/form-data: @@ -75,7 +68,9 @@ paths: format: binary "/images/upload_multiple": post: - summary: POST /images/upload_multiple + summary: create + tags: + - UploadMultiple requestBody: content: multipart/form-data: @@ -103,7 +98,9 @@ paths: format: binary "/images/upload_multiple_nested": post: - summary: POST /images/upload_multiple_nested + summary: create + tags: + - UploadMultipleNested requestBody: content: multipart/form-data: @@ -136,7 +133,9 @@ paths: format: binary "/images/upload_nested": post: - summary: POST /images/upload_nested + summary: create + tags: + - UploadNested requestBody: content: multipart/form-data: @@ -168,9 +167,24 @@ paths: schema: type: string format: binary + "/images/{id}": + get: + summary: show + tags: + - Image + responses: + '200': + description: returns a image payload + content: + image/png: + schema: + type: string + format: binary "/my_engine/eng/example": get: - summary: GET /my_engine/eng/example + summary: index + tags: + - Example responses: '200': description: returns the block content @@ -181,7 +195,9 @@ paths: example: AN ENGINE TEST "/my_engine/test": get: - summary: GET /my_engine/test + summary: index + tags: + - Test responses: '200': description: returns some content from the engine @@ -192,7 +208,9 @@ paths: example: ANOTHER TEST "/secret_items": get: - summary: GET /secret_items + summary: index + tags: + - SecretItem security: - SecretApiKeyAuth: [] responses: @@ -212,59 +230,11 @@ paths: example: items: - secrets - '401': - description: authorizes with secret key - content: - text/html: - schema: - type: string - example: '' "/tables": get: - summary: GET /tables - parameters: - - name: X-Authorization-Token - in: header - required: true - schema: - type: string - example: token - - name: filter[name] - in: query - required: false - schema: - type: object - properties: - name: - type: string - required: - - name - example: - name: Example Table - - name: filter[price] - in: query - required: false - schema: - type: object - properties: - price: - type: string - required: - - price - example: - price: '0' - - name: page - in: query - required: false - schema: - type: integer - example: 1 - - name: per - in: query - required: false - schema: - type: integer - example: 10 + summary: index + tags: + - Table responses: '200': description: with different deep query parameters @@ -337,8 +307,73 @@ paths: - message example: message: Unauthorized + parameters: + - name: X-Authorization-Token + in: header + required: true + schema: + type: string + example: token + - name: filter[name] + in: query + required: false + schema: + type: object + properties: + name: + type: string + required: + - name + example: + name: Example Table + - name: filter[price] + in: query + required: false + schema: + type: object + properties: + price: + type: string + required: + - price + example: + price: '0' + - name: page + in: query + required: false + schema: + type: integer + example: 1 + - name: per + in: query + required: false + schema: + type: integer + example: 10 post: - summary: POST /tables + summary: create + tags: + - Table + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + database_id: + type: integer + required: + - name + - description + - database_id + example: + name: k0kubun + description: description + database_id: 2 responses: '201': description: returns a table @@ -405,29 +440,21 @@ paths: - error example: error: invalid name parameter + "/tables/{id}": + delete: + summary: destroy + tags: + - Table requestBody: content: - application/json: + application/x-www-form-urlencoded: schema: type: object properties: - name: - type: string - description: + no_content: type: string - database_id: - type: integer - required: - - name - - description - - database_id example: - name: k0kubun - description: description - database_id: 2 - "/tables/1": - delete: - summary: DELETE /tables/1 + no_content: 'true' responses: '200': description: returns a table @@ -483,18 +510,10 @@ paths: updated_at: '2020-07-17T00:00:00+00:00' '202': description: returns no content if specified - requestBody: - content: - application/x-www-form-urlencoded: - schema: - type: object - properties: - no_content: - type: string - example: - no_content: 'true' get: - summary: GET /tables/1 + summary: show + tags: + - Table responses: '200': description: returns a table @@ -561,8 +580,23 @@ paths: - message example: message: Unauthorized + '404': + description: does not return a table if not found + content: + application/json: + schema: + type: object + properties: + message: + type: string + required: + - message + example: + message: not found patch: - summary: PATCH /tables/1 + summary: update + tags: + - Table requestBody: content: application/x-www-form-urlencoded: @@ -628,26 +662,12 @@ paths: storage_size: 12.3 created_at: '2020-07-17T00:00:00+00:00' updated_at: '2020-07-17T00:00:00+00:00' - "/tables/2": - get: - summary: GET /tables/2 - responses: - '404': - description: does not return a table if not found - content: - application/json: - schema: - type: object - properties: - message: - type: string - required: - - message - example: - message: not found "/test_block": get: - summary: GET /test_block + summary: index + tags: + - TestBlock + deprecated: true responses: '200': description: returns the block content @@ -656,7 +676,6 @@ paths: schema: type: string example: A TEST - deprecated: true components: securitySchemes: SecretApiKeyAuth: diff --git a/spec/requests/hanami_spec.rb b/spec/requests/hanami_spec.rb index 1dadb4b..144332c 100644 --- a/spec/requests/hanami_spec.rb +++ b/spec/requests/hanami_spec.rb @@ -54,7 +54,6 @@ context 'returns a list of tables' do it 'with flat query parameters' do get '/tables', { page: '1', per: '10' }, { 'AUTHORIZATION' => 'k0kubun', 'X_AUTHORIZATION_TOKEN' => 'token' } - # binding.irb expect(last_response.status).to eq(200) end From db5fe3d6e044206bebcf442231140280572cdf79 Mon Sep 17 00:00:00 2001 From: Aleksei <9ceb2990-3744-4629-82f3-19bc5c80b3a2@mackevich.addymail.com> Date: Tue, 9 Apr 2024 23:51:18 +0500 Subject: [PATCH 02/12] Generate tags and summary from hanami routes --- lib/rspec/openapi/extractors/hanami.rb | 96 ++++++++----- spec/apps/hanami/doc/openapi.yaml | 184 ++++++++++++------------- 2 files changed, 156 insertions(+), 124 deletions(-) diff --git a/lib/rspec/openapi/extractors/hanami.rb b/lib/rspec/openapi/extractors/hanami.rb index 952beae..80ccfb5 100644 --- a/lib/rspec/openapi/extractors/hanami.rb +++ b/lib/rspec/openapi/extractors/hanami.rb @@ -1,5 +1,56 @@ # frozen_string_literal: true require 'dry/inflector' +require 'hanami' +require 'hanami/router/inspector' + +# Hanami::Router::Inspector original code +class Inspector + attr_accessor :routes, :inflector + + def initialize(routes: []) + @routes = routes + @inflector = Dry::Inflector.new + end + + def add_route(route) + routes.push(route) + end + + def call(verb, path) + route = routes.find { |r| r.http_method == verb && r.path == path } + + if route.to.is_a?(Proc) + { + tags: [], + summary: "#{verb} #{path}", + } + else + data = route.to.split('.') + + { + tags: [inflector.classify(data[0])], + summary: data[1], + } + end + end +end + +InspectorAnalyzer = Inspector.new + +# Monkey-patch hanami-router +module Hanami + class Slice + module ClassMethods + def router(inspector: InspectorAnalyzer) + raise SliceLoadError, "#{self} must be prepared before loading the router" unless prepared? + + @_mutex.synchronize do + @_router ||= load_router(inspector: inspector) + end + end + end + end +end class << RSpec::OpenAPI::Extractors::Hanami = Object.new @@ -17,12 +68,14 @@ def request_attributes(request, example) deprecated = metadata[:deprecated] raw_path_params = request.path_parameters path = request.path + route = Hanami.app.router.recognize(request.path, method: request.method) - path = add_id(path, route) - result = generate_summary_and_tag(path, request.method) - summary ||= result[0] - tags ||= result[1] + result = InspectorAnalyzer.call(request.method, add_id(path, route)) + + summary ||= result[:summary] + tags ||= result[:tags] + path = add_openapi_id(path, route) [path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated] end @@ -42,41 +95,22 @@ def add_id(path, route) route.params.each_pair do |key, value| next unless number_or_nil(value) - path = path.sub("/#{value}", "/{#{key}}") + path = path.sub("/#{value}", "/:#{key}") end path end - def generate_summary_and_tag(path, method) - case path - when ->(path) { path.end_with?('{id}/edit') && method == 'GET' } - ['edit', extract_tag(path, '/{id}/edit')] - when ->(path) { path.end_with?('{id}') && method == 'GET' } - ['show', extract_tag(path, '/{id}')] - when ->(path) { path.end_with?('{id}') && %w[PATCH PUT POST].include?(method) } - ['update', extract_tag(path, '/{id}')] - when ->(path) { path.end_with?('{id}') && method == 'DELETE' } - ['destroy', extract_tag(path, '/{id}')] - when ->(path) { path.end_with?('/new') && method == 'GET' } - ['new', extract_tag(path, '/new')] - when ->(_path) { method == 'GET' } - ['index', extract_tag(path)] - when ->(_path) { method == 'POST' } - ['create', extract_tag(path)] - else - ["#{method} #{path}", []] - end - end + def add_openapi_id(path, route) + return path if route.params.empty? - def extract_tag(path, prefix = nil) - path = path.delete_suffix(prefix) if prefix + route.params.each_pair do |key, value| + next unless number_or_nil(value) - [inflector.classify(path.split(%r{/+}).last)] - end + path = path.sub("/#{value}", "/{#{key}}") + end - def inflector - @inflector ||= Dry::Inflector.new + path end def number_or_nil(string) diff --git a/spec/apps/hanami/doc/openapi.yaml b/spec/apps/hanami/doc/openapi.yaml index 173ea54..f2521bf 100644 --- a/spec/apps/hanami/doc/openapi.yaml +++ b/spec/apps/hanami/doc/openapi.yaml @@ -42,9 +42,9 @@ paths: tags: [] "/images/upload": post: - summary: create + summary: upload tags: - - Upload + - Image requestBody: content: multipart/form-data: @@ -68,9 +68,9 @@ paths: format: binary "/images/upload_multiple": post: - summary: create + summary: upload_multiple tags: - - UploadMultiple + - Image requestBody: content: multipart/form-data: @@ -98,9 +98,9 @@ paths: format: binary "/images/upload_multiple_nested": post: - summary: create + summary: upload_multiple_nested tags: - - UploadMultipleNested + - Image requestBody: content: multipart/form-data: @@ -133,9 +133,9 @@ paths: format: binary "/images/upload_nested": post: - summary: create + summary: upload_nested tags: - - UploadNested + - Image requestBody: content: multipart/form-data: @@ -182,9 +182,9 @@ paths: format: binary "/my_engine/eng/example": get: - summary: index + summary: example tags: - - Example + - Eng responses: '200': description: returns the block content @@ -195,9 +195,8 @@ paths: example: AN ENGINE TEST "/my_engine/test": get: - summary: index - tags: - - Test + summary: GET /my_engine/test + tags: [] responses: '200': description: returns some content from the engine @@ -235,6 +234,49 @@ paths: summary: index tags: - Table + parameters: + - name: X-Authorization-Token + in: header + required: true + schema: + type: string + example: token + - name: filter[name] + in: query + required: false + schema: + type: object + properties: + name: + type: string + required: + - name + example: + name: Example Table + - name: filter[price] + in: query + required: false + schema: + type: object + properties: + price: + type: string + required: + - price + example: + price: '0' + - name: page + in: query + required: false + schema: + type: integer + example: 1 + - name: per + in: query + required: false + schema: + type: integer + example: 10 responses: '200': description: with different deep query parameters @@ -290,7 +332,7 @@ paths: database: id: 2 name: production - null_sample: + null_sample: storage_size: 12.3 created_at: '2020-07-17T00:00:00+00:00' updated_at: '2020-07-17T00:00:00+00:00' @@ -307,73 +349,10 @@ paths: - message example: message: Unauthorized - parameters: - - name: X-Authorization-Token - in: header - required: true - schema: - type: string - example: token - - name: filter[name] - in: query - required: false - schema: - type: object - properties: - name: - type: string - required: - - name - example: - name: Example Table - - name: filter[price] - in: query - required: false - schema: - type: object - properties: - price: - type: string - required: - - price - example: - price: '0' - - name: page - in: query - required: false - schema: - type: integer - example: 1 - - name: per - in: query - required: false - schema: - type: integer - example: 10 post: summary: create tags: - Table - requestBody: - content: - application/json: - schema: - type: object - properties: - name: - type: string - description: - type: string - database_id: - type: integer - required: - - name - - description - - database_id - example: - name: k0kubun - description: description - database_id: 2 responses: '201': description: returns a table @@ -423,7 +402,7 @@ paths: database: id: 2 name: production - null_sample: + null_sample: storage_size: 12.3 created_at: '2020-07-17T00:00:00+00:00' updated_at: '2020-07-17T00:00:00+00:00' @@ -440,21 +419,31 @@ paths: - error example: error: invalid name parameter - "/tables/{id}": - delete: - summary: destroy - tags: - - Table requestBody: content: - application/x-www-form-urlencoded: + application/json: schema: type: object properties: - no_content: + name: + type: string + description: type: string + database_id: + type: integer + required: + - name + - description + - database_id example: - no_content: 'true' + name: k0kubun + description: description + database_id: 2 + "/tables/{id}": + delete: + summary: destroy + tags: + - Table responses: '200': description: returns a table @@ -504,12 +493,22 @@ paths: database: id: 2 name: production - null_sample: + null_sample: storage_size: 12.3 created_at: '2020-07-17T00:00:00+00:00' updated_at: '2020-07-17T00:00:00+00:00' '202': description: returns no content if specified + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + no_content: + type: string + example: + no_content: 'true' get: summary: show tags: @@ -563,7 +562,7 @@ paths: database: id: 2 name: production - null_sample: + null_sample: storage_size: 12.3 created_at: '2020-07-17T00:00:00+00:00' updated_at: '2020-07-17T00:00:00+00:00' @@ -658,16 +657,14 @@ paths: database: id: 2 name: production - null_sample: + null_sample: storage_size: 12.3 created_at: '2020-07-17T00:00:00+00:00' updated_at: '2020-07-17T00:00:00+00:00' "/test_block": get: - summary: index - tags: - - TestBlock - deprecated: true + summary: GET /test_block + tags: [] responses: '200': description: returns the block content @@ -676,6 +673,7 @@ paths: schema: type: string example: A TEST + deprecated: true components: securitySchemes: SecretApiKeyAuth: From ec94086109a045746ea8bbb650439769d2e13783 Mon Sep 17 00:00:00 2001 From: Aleksei <9ceb2990-3744-4629-82f3-19bc5c80b3a2@mackevich.addymail.com> Date: Wed, 10 Apr 2024 12:39:17 +0500 Subject: [PATCH 03/12] Add support raw path params --- lib/rspec/openapi/extractors/hanami.rb | 5 ++++- spec/apps/hanami/doc/openapi.yaml | 28 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/lib/rspec/openapi/extractors/hanami.rb b/lib/rspec/openapi/extractors/hanami.rb index 80ccfb5..7ff5d9e 100644 --- a/lib/rspec/openapi/extractors/hanami.rb +++ b/lib/rspec/openapi/extractors/hanami.rb @@ -66,10 +66,11 @@ def request_attributes(request, example) security = metadata[:security] description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example) deprecated = metadata[:deprecated] - raw_path_params = request.path_parameters path = request.path route = Hanami.app.router.recognize(request.path, method: request.method) + # binding.irb unless route.params.empty? + raw_path_params = route.params.filter { |key, value| number_or_nil(value) } result = InspectorAnalyzer.call(request.method, add_id(path, route)) @@ -77,6 +78,8 @@ def request_attributes(request, example) tags ||= result[:tags] path = add_openapi_id(path, route) + raw_path_params = raw_path_params.slice(*(raw_path_params.keys - RSpec::OpenAPI.ignored_path_params)) + [path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated] end diff --git a/spec/apps/hanami/doc/openapi.yaml b/spec/apps/hanami/doc/openapi.yaml index f2521bf..974f536 100644 --- a/spec/apps/hanami/doc/openapi.yaml +++ b/spec/apps/hanami/doc/openapi.yaml @@ -172,6 +172,13 @@ paths: summary: show tags: - Image + parameters: + - name: id + in: path + required: true + schema: + type: integer + example: 1 responses: '200': description: returns a image payload @@ -444,6 +451,13 @@ paths: summary: destroy tags: - Table + parameters: + - name: id + in: path + required: true + schema: + type: integer + example: 1 responses: '200': description: returns a table @@ -513,6 +527,13 @@ paths: summary: show tags: - Table + parameters: + - name: id + in: path + required: true + schema: + type: integer + example: 2 responses: '200': description: returns a table @@ -596,6 +617,13 @@ paths: summary: update tags: - Table + parameters: + - name: id + in: path + required: true + schema: + type: integer + example: 1 requestBody: content: application/x-www-form-urlencoded: From dcf0d90265c231b62474996d98cc97ef4475d596 Mon Sep 17 00:00:00 2001 From: Aleksei <9ceb2990-3744-4629-82f3-19bc5c80b3a2@mackevich.addymail.com> Date: Wed, 10 Apr 2024 16:27:57 +0500 Subject: [PATCH 04/12] Loading extractor and dependencies into dependencies from the environment * Make work minitest with new extractors * Apply rubocop offences --- lib/rspec/openapi.rb | 6 +- lib/rspec/openapi/extractors.rb | 1 + lib/rspec/openapi/extractors/hanami.rb | 24 +- lib/rspec/openapi/extractors/rack.rb | 1 + lib/rspec/openapi/extractors/rails.rb | 1 + lib/rspec/openapi/minitest_hooks.rb | 16 +- lib/rspec/openapi/rspec_hooks.rb | 6 +- spec/apps/hanami/doc/openapi.json | 1089 ++++++++++++++++++++++++ 8 files changed, 1125 insertions(+), 19 deletions(-) create mode 100644 spec/apps/hanami/doc/openapi.json diff --git a/lib/rspec/openapi.rb b/lib/rspec/openapi.rb index aba3348..bbd42de 100644 --- a/lib/rspec/openapi.rb +++ b/lib/rspec/openapi.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'bundler/setup' require 'rspec/openapi/version' require 'rspec/openapi/components_updater' require 'rspec/openapi/default_schema' @@ -13,8 +14,9 @@ require 'rspec/openapi/key_transformer' require 'rspec/openapi/extractors' require 'rspec/openapi/extractors/rack' -require 'rspec/openapi/extractors/rails' -require 'rspec/openapi/extractors/hanami' + +require 'rspec/openapi/extractors/hanami' if Bundler.load.specs.map(&:name).include?('hanami') +require 'rspec/openapi/extractors/rails' if Bundler.load.specs.map(&:name).include?('rails') require 'rspec/openapi/minitest_hooks' if Object.const_defined?('Minitest') require 'rspec/openapi/rspec_hooks' if ENV['OPENAPI'] && Object.const_defined?('RSpec') diff --git a/lib/rspec/openapi/extractors.rb b/lib/rspec/openapi/extractors.rb index f117f13..c8322ae 100644 --- a/lib/rspec/openapi/extractors.rb +++ b/lib/rspec/openapi/extractors.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +# Create namespace module RSpec::OpenAPI::Extractors end diff --git a/lib/rspec/openapi/extractors/hanami.rb b/lib/rspec/openapi/extractors/hanami.rb index 7ff5d9e..d45238c 100644 --- a/lib/rspec/openapi/extractors/hanami.rb +++ b/lib/rspec/openapi/extractors/hanami.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true + require 'dry/inflector' require 'hanami' -require 'hanami/router/inspector' # Hanami::Router::Inspector original code class Inspector @@ -38,22 +38,18 @@ def call(verb, path) InspectorAnalyzer = Inspector.new # Monkey-patch hanami-router -module Hanami - class Slice - module ClassMethods - def router(inspector: InspectorAnalyzer) - raise SliceLoadError, "#{self} must be prepared before loading the router" unless prepared? - - @_mutex.synchronize do - @_router ||= load_router(inspector: inspector) - end - end +module Hanami::Slice::ClassMethods + def router(inspector: InspectorAnalyzer) + raise SliceLoadError, "#{self} must be prepared before loading the router" unless prepared? + + @_mutex.synchronize do + @_router ||= load_router(inspector: inspector) end end end +# Extractor for hanami class << RSpec::OpenAPI::Extractors::Hanami = Object.new - # @param [RSpec::ExampleGroups::*] context # @param [RSpec::Core::Example] example # @return Array @@ -69,8 +65,8 @@ def request_attributes(request, example) path = request.path route = Hanami.app.router.recognize(request.path, method: request.method) - # binding.irb unless route.params.empty? - raw_path_params = route.params.filter { |key, value| number_or_nil(value) } + + raw_path_params = route.params.filter { |_key, value| number_or_nil(value) } result = InspectorAnalyzer.call(request.method, add_id(path, route)) diff --git a/lib/rspec/openapi/extractors/rack.rb b/lib/rspec/openapi/extractors/rack.rb index 4209c6a..92d20f5 100644 --- a/lib/rspec/openapi/extractors/rack.rb +++ b/lib/rspec/openapi/extractors/rack.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Extractor for rack class << RSpec::OpenAPI::Extractors::Rack = Object.new # @param [RSpec::ExampleGroups::*] context # @param [RSpec::Core::Example] example diff --git a/lib/rspec/openapi/extractors/rails.rb b/lib/rspec/openapi/extractors/rails.rb index 5222bcd..ef269fb 100644 --- a/lib/rspec/openapi/extractors/rails.rb +++ b/lib/rspec/openapi/extractors/rails.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Extractor for rails class << RSpec::OpenAPI::Extractors::Rails = Object.new # @param [RSpec::ExampleGroups::*] context # @param [RSpec::Core::Example] example diff --git a/lib/rspec/openapi/minitest_hooks.rb b/lib/rspec/openapi/minitest_hooks.rb index 7f00f61..53b7ade 100644 --- a/lib/rspec/openapi/minitest_hooks.rb +++ b/lib/rspec/openapi/minitest_hooks.rb @@ -13,11 +13,25 @@ def run(*args) human_name = name.sub(/^test_/, '').gsub('_', ' ') example = Example.new(self, human_name, {}, file_path) path = RSpec::OpenAPI.path.then { |p| p.is_a?(Proc) ? p.call(example) : p } - record = RSpec::OpenAPI::RecordBuilder.build(self, example: example) + record = RSpec::OpenAPI::RecordBuilder.build(self, example: example, extractor: find_extractor) RSpec::OpenAPI.path_records[path] << record if record end result end + + def find_extractor + if Bundler.load.specs.map(&:name).include?('rails') && defined?(Rails) && + Rails.respond_to?(:application) && Rails.application + RSpec::OpenAPI::Extractors::Rails + elsif Bundler.load.specs.map(&:name).include?('hanami') && defined?(Hanami) && + Hanami.respond_to?(:app) && Hanami.app? + RSpec::OpenAPI::Extractors::Hanami + # elsif defined?(Roda) + # some Roda extractor + else + RSpec::OpenAPI::Extractors::Rack + end + end end module ActivateOpenApiClassMethods diff --git a/lib/rspec/openapi/rspec_hooks.rb b/lib/rspec/openapi/rspec_hooks.rb index a9cd679..f3c5202 100644 --- a/lib/rspec/openapi/rspec_hooks.rb +++ b/lib/rspec/openapi/rspec_hooks.rb @@ -21,9 +21,11 @@ end def find_extractor - if defined?(Rails) && Rails.respond_to?(:application) && Rails.application + if Bundler.load.specs.map(&:name).include?('rails') && defined?(Rails) && + Rails.respond_to?(:application) && Rails.application RSpec::OpenAPI::Extractors::Rails - elsif defined?(Hanami) + elsif Bundler.load.specs.map(&:name).include?('hanami') && defined?(Hanami) && + Hanami.respond_to?(:app) && Hanami.app? RSpec::OpenAPI::Extractors::Hanami # elsif defined?(Roda) # some Roda extractor diff --git a/spec/apps/hanami/doc/openapi.json b/spec/apps/hanami/doc/openapi.json new file mode 100644 index 0000000..2da807b --- /dev/null +++ b/spec/apps/hanami/doc/openapi.json @@ -0,0 +1,1089 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "OpenAPI Documentation", + "version": "1.0.0", + "description": "My beautiful hanami API", + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "http://localhost:3000" + } + ], + "paths": { + "/images": { + "get": { + "summary": "index", + "tags": [ + "Image" + ], + "responses": { + "200": { + "description": "can return an object with an attribute of empty array", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + } + } + }, + "required": [ + "name", + "tags" + ] + } + }, + "example": [ + { + "name": "file.png", + "tags": [ + + ] + } + ] + } + } + } + } + } + }, + "/images/upload": { + "post": { + "summary": "upload", + "tags": [ + "Image" + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "image": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "image" + ] + }, + "example": { + "image": "test.png" + } + } + } + }, + "responses": { + "200": { + "description": "returns a image payload with upload", + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/images/upload_multiple": { + "post": { + "summary": "upload_multiple", + "tags": [ + "Image" + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "images": { + "type": "array", + "items": { + "type": "string", + "format": "binary" + } + } + }, + "required": [ + "images" + ] + }, + "example": { + "images": [ + "test.png", + "test.png" + ] + } + } + } + }, + "responses": { + "200": { + "description": "returns a image payload with upload multiple", + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/images/upload_multiple_nested": { + "post": { + "summary": "upload_multiple_nested", + "tags": [ + "Image" + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "images": { + "type": "array", + "items": { + "type": "object", + "properties": { + "image": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "image" + ] + } + } + }, + "required": [ + "images" + ] + }, + "example": { + "images": [ + { + "image": "test.png" + }, + { + "image": "test.png" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "returns a image payload with upload multiple nested", + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/images/upload_nested": { + "post": { + "summary": "upload_nested", + "tags": [ + "Image" + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "nested_image": { + "type": "object", + "properties": { + "image": { + "type": "string", + "format": "binary" + }, + "caption": { + "type": "string" + } + }, + "required": [ + "image", + "caption" + ] + } + }, + "required": [ + "nested_image" + ] + }, + "example": { + "nested_image": { + "image": "test.png", + "caption": "Some caption" + } + } + } + } + }, + "responses": { + "200": { + "description": "returns a image payload with upload nested", + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/images/{id}": { + "get": { + "summary": "show", + "tags": [ + "Image" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "example": 1 + } + ], + "responses": { + "200": { + "description": "returns a image payload", + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/my_engine/eng/example": { + "get": { + "summary": "example", + "tags": [ + "Eng" + ], + "responses": { + "200": { + "description": "returns the block content", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "AN ENGINE TEST" + } + } + } + } + } + }, + "/my_engine/test": { + "get": { + "summary": "GET /my_engine/test", + "tags": [ + + ], + "responses": { + "200": { + "description": "returns some content from the engine", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "ANOTHER TEST" + } + } + } + } + } + }, + "/secret_items": { + "get": { + "summary": "index", + "tags": [ + "SecretItem" + ], + "security": [ + { + "SecretApiKeyAuth": [ + + ] + } + ], + "responses": { + "200": { + "description": "authorizes with secret key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [ + "secrets" + ] + } + } + } + } + } + } + }, + "/tables": { + "get": { + "summary": "index", + "tags": [ + "Table" + ], + "parameters": [ + { + "name": "X-Authorization-Token", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "example": "token" + }, + { + "name": "filter[name]", + "in": "query", + "required": false, + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "Example Table" + } + }, + { + "name": "filter[price]", + "in": "query", + "required": false, + "schema": { + "type": "object", + "properties": { + "price": { + "type": "string" + } + }, + "required": [ + "price" + ] + }, + "example": { + "price": "0" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "example": 1 + }, + { + "name": "per", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "example": 10 + } + ], + "responses": { + "200": { + "description": "with different deep query parameters", + "headers": { + "X-Cursor": { + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "database": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "null_sample": { + "nullable": true + }, + "storage_size": { + "type": "number", + "format": "float" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "description", + "database", + "null_sample", + "storage_size", + "created_at", + "updated_at" + ] + } + }, + "example": [ + { + "id": 1, + "name": "access", + "description": "logs", + "database": { + "id": 2, + "name": "production" + }, + "null_sample": null, + "storage_size": 12.3, + "created_at": "2020-07-17T00:00:00+00:00", + "updated_at": "2020-07-17T00:00:00+00:00" + } + ] + } + } + }, + "401": { + "description": "does not return tables if unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + }, + "example": { + "message": "Unauthorized" + } + } + } + } + } + }, + "post": { + "summary": "create", + "tags": [ + "Table" + ], + "responses": { + "201": { + "description": "returns a table", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "database": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "null_sample": { + "nullable": true + }, + "storage_size": { + "type": "number", + "format": "float" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "description", + "database", + "null_sample", + "storage_size", + "created_at", + "updated_at" + ] + }, + "example": { + "id": 1, + "name": "access", + "description": "logs", + "database": { + "id": 2, + "name": "production" + }, + "null_sample": null, + "storage_size": 12.3, + "created_at": "2020-07-17T00:00:00+00:00", + "updated_at": "2020-07-17T00:00:00+00:00" + } + } + } + }, + "422": { + "description": "fails to create a table (2)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + }, + "example": { + "error": "invalid name parameter" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "database_id": { + "type": "integer" + } + }, + "required": [ + "name", + "description", + "database_id" + ] + }, + "example": { + "name": "k0kubun", + "description": "description", + "database_id": 2 + } + } + } + } + } + }, + "/tables/{id}": { + "delete": { + "summary": "destroy", + "tags": [ + "Table" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "example": 1 + } + ], + "responses": { + "200": { + "description": "returns a table", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "database": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "null_sample": { + "nullable": true + }, + "storage_size": { + "type": "number", + "format": "float" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "description", + "database", + "null_sample", + "storage_size", + "created_at", + "updated_at" + ] + }, + "example": { + "id": 1, + "name": "access", + "description": "logs", + "database": { + "id": 2, + "name": "production" + }, + "null_sample": null, + "storage_size": 12.3, + "created_at": "2020-07-17T00:00:00+00:00", + "updated_at": "2020-07-17T00:00:00+00:00" + } + } + } + }, + "202": { + "description": "returns no content if specified" + } + }, + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "no_content": { + "type": "string" + } + } + }, + "example": { + "no_content": "true" + } + } + } + } + }, + "get": { + "summary": "show", + "tags": [ + "Table" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "example": 2 + } + ], + "responses": { + "200": { + "description": "returns a table", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "database": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "null_sample": { + "nullable": true + }, + "storage_size": { + "type": "number", + "format": "float" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "description", + "database", + "null_sample", + "storage_size", + "created_at", + "updated_at" + ] + }, + "example": { + "id": 1, + "name": "access", + "description": "logs", + "database": { + "id": 2, + "name": "production" + }, + "null_sample": null, + "storage_size": 12.3, + "created_at": "2020-07-17T00:00:00+00:00", + "updated_at": "2020-07-17T00:00:00+00:00" + } + } + } + }, + "401": { + "description": "does not return a table if unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + }, + "example": { + "message": "Unauthorized" + } + } + } + }, + "404": { + "description": "does not return a table if not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + }, + "example": { + "message": "not found" + } + } + } + } + } + }, + "patch": { + "summary": "update", + "tags": [ + "Table" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "example": 1 + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "test" + } + } + } + }, + "responses": { + "200": { + "description": "returns a table", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "database": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "null_sample": { + "nullable": true + }, + "storage_size": { + "type": "number", + "format": "float" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "description", + "database", + "null_sample", + "storage_size", + "created_at", + "updated_at" + ] + }, + "example": { + "id": 1, + "name": "access", + "description": "logs", + "database": { + "id": 2, + "name": "production" + }, + "null_sample": null, + "storage_size": 12.3, + "created_at": "2020-07-17T00:00:00+00:00", + "updated_at": "2020-07-17T00:00:00+00:00" + } + } + } + } + } + } + }, + "/test_block": { + "get": { + "summary": "GET /test_block", + "tags": [ + + ], + "responses": { + "200": { + "description": "returns the block content", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "A TEST" + } + } + } + }, + "deprecated": true + } + } + }, + "components": { + "securitySchemes": { + "SecretApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "Secret-Key" + } + } + } +} \ No newline at end of file From 25fa2ff4da9cc387cdb645a51fe6f00cbd85231b Mon Sep 17 00:00:00 2001 From: Aleksei <9ceb2990-3744-4629-82f3-19bc5c80b3a2@mackevich.addymail.com> Date: Wed, 10 Apr 2024 19:22:37 +0500 Subject: [PATCH 05/12] Extract map iterator --- lib/rspec/openapi/minitest_hooks.rb | 6 ++++-- lib/rspec/openapi/rspec_hooks.rb | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/rspec/openapi/minitest_hooks.rb b/lib/rspec/openapi/minitest_hooks.rb index 53b7ade..f3c4bdc 100644 --- a/lib/rspec/openapi/minitest_hooks.rb +++ b/lib/rspec/openapi/minitest_hooks.rb @@ -20,10 +20,12 @@ def run(*args) end def find_extractor - if Bundler.load.specs.map(&:name).include?('rails') && defined?(Rails) && + names = Bundler.load.specs.map(&:name) + + if names.include?('rails') && defined?(Rails) && Rails.respond_to?(:application) && Rails.application RSpec::OpenAPI::Extractors::Rails - elsif Bundler.load.specs.map(&:name).include?('hanami') && defined?(Hanami) && + elsif names.include?('hanami') && defined?(Hanami) && Hanami.respond_to?(:app) && Hanami.app? RSpec::OpenAPI::Extractors::Hanami # elsif defined?(Roda) diff --git a/lib/rspec/openapi/rspec_hooks.rb b/lib/rspec/openapi/rspec_hooks.rb index f3c5202..494c1b5 100644 --- a/lib/rspec/openapi/rspec_hooks.rb +++ b/lib/rspec/openapi/rspec_hooks.rb @@ -21,10 +21,12 @@ end def find_extractor - if Bundler.load.specs.map(&:name).include?('rails') && defined?(Rails) && + names = Bundler.load.specs.map(&:name) + + if names.include?('rails') && defined?(Rails) && Rails.respond_to?(:application) && Rails.application RSpec::OpenAPI::Extractors::Rails - elsif Bundler.load.specs.map(&:name).include?('hanami') && defined?(Hanami) && + elsif names.include?('hanami') && defined?(Hanami) && Hanami.respond_to?(:app) && Hanami.app? RSpec::OpenAPI::Extractors::Hanami # elsif defined?(Roda) From defdac3e38a4a69d989ef2467f43b8899a84adad Mon Sep 17 00:00:00 2001 From: Aleksei <9ceb2990-3744-4629-82f3-19bc5c80b3a2@mackevich.addymail.com> Date: Wed, 10 Apr 2024 19:40:17 +0500 Subject: [PATCH 06/12] Don't use monkey patching --- lib/rspec/openapi/extractors/hanami.rb | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/rspec/openapi/extractors/hanami.rb b/lib/rspec/openapi/extractors/hanami.rb index d45238c..3db8921 100644 --- a/lib/rspec/openapi/extractors/hanami.rb +++ b/lib/rspec/openapi/extractors/hanami.rb @@ -37,17 +37,14 @@ def call(verb, path) InspectorAnalyzer = Inspector.new -# Monkey-patch hanami-router -module Hanami::Slice::ClassMethods +module InspectorAnalyzerPrepender def router(inspector: InspectorAnalyzer) - raise SliceLoadError, "#{self} must be prepared before loading the router" unless prepared? - - @_mutex.synchronize do - @_router ||= load_router(inspector: inspector) - end + super end end +Hanami::Slice::ClassMethods.prepend(InspectorAnalyzerPrepender) + # Extractor for hanami class << RSpec::OpenAPI::Extractors::Hanami = Object.new # @param [RSpec::ExampleGroups::*] context From 52052d6a4b07eb11a19e93202d3f7ae428f70ecf Mon Sep 17 00:00:00 2001 From: Aleksei <9ceb2990-3744-4629-82f3-19bc5c80b3a2@mackevich.addymail.com> Date: Wed, 10 Apr 2024 19:51:43 +0500 Subject: [PATCH 07/12] Apply rubocop offenses --- lib/rspec/openapi/extractors/hanami.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/rspec/openapi/extractors/hanami.rb b/lib/rspec/openapi/extractors/hanami.rb index 3db8921..a8ae992 100644 --- a/lib/rspec/openapi/extractors/hanami.rb +++ b/lib/rspec/openapi/extractors/hanami.rb @@ -37,6 +37,7 @@ def call(verb, path) InspectorAnalyzer = Inspector.new +# Add default parameter to load inspector before test cases run module InspectorAnalyzerPrepender def router(inspector: InspectorAnalyzer) super From 2527d79d8ec351d32ab04c56662f7802120fa3c4 Mon Sep 17 00:00:00 2001 From: Aleksei <9ceb2990-3744-4629-82f3-19bc5c80b3a2@mackevich.addymail.com> Date: Thu, 11 Apr 2024 15:38:55 +0500 Subject: [PATCH 08/12] Extract find_extractor --- lib/rspec/openapi.rb | 1 + lib/rspec/openapi/minitest_hooks.rb | 18 +----------------- lib/rspec/openapi/rspec_hooks.rb | 18 +----------------- lib/rspec/openapi/shared_hooks.rb | 17 +++++++++++++++++ 4 files changed, 20 insertions(+), 34 deletions(-) create mode 100644 lib/rspec/openapi/shared_hooks.rb diff --git a/lib/rspec/openapi.rb b/lib/rspec/openapi.rb index bbd42de..ad60760 100644 --- a/lib/rspec/openapi.rb +++ b/lib/rspec/openapi.rb @@ -12,6 +12,7 @@ require 'rspec/openapi/schema_cleaner' require 'rspec/openapi/schema_sorter' require 'rspec/openapi/key_transformer' +require 'rspec/openapi/shared_hooks' require 'rspec/openapi/extractors' require 'rspec/openapi/extractors/rack' diff --git a/lib/rspec/openapi/minitest_hooks.rb b/lib/rspec/openapi/minitest_hooks.rb index f3c4bdc..15682d5 100644 --- a/lib/rspec/openapi/minitest_hooks.rb +++ b/lib/rspec/openapi/minitest_hooks.rb @@ -13,27 +13,11 @@ def run(*args) human_name = name.sub(/^test_/, '').gsub('_', ' ') example = Example.new(self, human_name, {}, file_path) path = RSpec::OpenAPI.path.then { |p| p.is_a?(Proc) ? p.call(example) : p } - record = RSpec::OpenAPI::RecordBuilder.build(self, example: example, extractor: find_extractor) + record = RSpec::OpenAPI::RecordBuilder.build(self, example: example, extractor: SharedHooks.find_extractor) RSpec::OpenAPI.path_records[path] << record if record end result end - - def find_extractor - names = Bundler.load.specs.map(&:name) - - if names.include?('rails') && defined?(Rails) && - Rails.respond_to?(:application) && Rails.application - RSpec::OpenAPI::Extractors::Rails - elsif names.include?('hanami') && defined?(Hanami) && - Hanami.respond_to?(:app) && Hanami.app? - RSpec::OpenAPI::Extractors::Hanami - # elsif defined?(Roda) - # some Roda extractor - else - RSpec::OpenAPI::Extractors::Rack - end - end end module ActivateOpenApiClassMethods diff --git a/lib/rspec/openapi/rspec_hooks.rb b/lib/rspec/openapi/rspec_hooks.rb index 494c1b5..753f345 100644 --- a/lib/rspec/openapi/rspec_hooks.rb +++ b/lib/rspec/openapi/rspec_hooks.rb @@ -5,7 +5,7 @@ RSpec.configuration.after(:each) do |example| if RSpec::OpenAPI.example_types.include?(example.metadata[:type]) && example.metadata[:openapi] != false path = RSpec::OpenAPI.path.then { |p| p.is_a?(Proc) ? p.call(example) : p } - record = RSpec::OpenAPI::RecordBuilder.build(self, example: example, extractor: find_extractor) + record = RSpec::OpenAPI::RecordBuilder.build(self, example: example, extractor: SharedHooks.find_extractor) RSpec::OpenAPI.path_records[path] << record if record end end @@ -19,19 +19,3 @@ RSpec.configuration.reporter.message colorizer.wrap(error_message, :failure) end end - -def find_extractor - names = Bundler.load.specs.map(&:name) - - if names.include?('rails') && defined?(Rails) && - Rails.respond_to?(:application) && Rails.application - RSpec::OpenAPI::Extractors::Rails - elsif names.include?('hanami') && defined?(Hanami) && - Hanami.respond_to?(:app) && Hanami.app? - RSpec::OpenAPI::Extractors::Hanami - # elsif defined?(Roda) - # some Roda extractor - else - RSpec::OpenAPI::Extractors::Rack - end -end diff --git a/lib/rspec/openapi/shared_hooks.rb b/lib/rspec/openapi/shared_hooks.rb new file mode 100644 index 0000000..d3496b9 --- /dev/null +++ b/lib/rspec/openapi/shared_hooks.rb @@ -0,0 +1,17 @@ +module SharedHooks + def self.find_extractor + names = Bundler.load.specs.map(&:name) + + if names.include?('rails') && defined?(Rails) && + Rails.respond_to?(:application) && Rails.application + RSpec::OpenAPI::Extractors::Rails + elsif names.include?('hanami') && defined?(Hanami) && + Hanami.respond_to?(:app) && Hanami.app? + RSpec::OpenAPI::Extractors::Hanami + # elsif defined?(Roda) + # some Roda extractor + else + RSpec::OpenAPI::Extractors::Rack + end + end +end From 19788016c97cf2b63e18a42fc42cd60d3a129a5d Mon Sep 17 00:00:00 2001 From: Aleksei <9ceb2990-3744-4629-82f3-19bc5c80b3a2@mackevich.addymail.com> Date: Thu, 11 Apr 2024 15:39:05 +0500 Subject: [PATCH 09/12] Add link to code --- lib/rspec/openapi/extractors/hanami.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rspec/openapi/extractors/hanami.rb b/lib/rspec/openapi/extractors/hanami.rb index a8ae992..ef3a9fd 100644 --- a/lib/rspec/openapi/extractors/hanami.rb +++ b/lib/rspec/openapi/extractors/hanami.rb @@ -3,7 +3,7 @@ require 'dry/inflector' require 'hanami' -# Hanami::Router::Inspector original code +# https://github.com/hanami/router/blob/97f75b8529574bd4ff23165460e82a6587bc323c/lib/hanami/router/inspector.rb#L13 class Inspector attr_accessor :routes, :inflector From e834fea38b89eeb1b806302a08eabde65101715c Mon Sep 17 00:00:00 2001 From: Aleksei <9ceb2990-3744-4629-82f3-19bc5c80b3a2@mackevich.addymail.com> Date: Thu, 11 Apr 2024 17:45:40 +0500 Subject: [PATCH 10/12] Added documentation to check manual editing of openapi --- spec/apps/hanami/doc/openapi.json | 11 +++++++++++ spec/apps/hanami/doc/openapi.yaml | 7 +++++++ 2 files changed, 18 insertions(+) diff --git a/spec/apps/hanami/doc/openapi.json b/spec/apps/hanami/doc/openapi.json index 2da807b..cf4e326 100644 --- a/spec/apps/hanami/doc/openapi.json +++ b/spec/apps/hanami/doc/openapi.json @@ -380,6 +380,17 @@ } } } + }, + "401": { + "description": "authorizes with secret key", + "content": { + "text/html": { + "schema": { + "type": "string" + }, + "example": "" + } + } } } } diff --git a/spec/apps/hanami/doc/openapi.yaml b/spec/apps/hanami/doc/openapi.yaml index 974f536..8042346 100644 --- a/spec/apps/hanami/doc/openapi.yaml +++ b/spec/apps/hanami/doc/openapi.yaml @@ -236,6 +236,13 @@ paths: example: items: - secrets + '401': + description: authorizes with secret key + content: + text/html: + schema: + type: string + example: '' "/tables": get: summary: index From 1f8270b5a82dcf4c512acf5fee72bb9bf543bdd0 Mon Sep 17 00:00:00 2001 From: Aleksei <9ceb2990-3744-4629-82f3-19bc5c80b3a2@mackevich.addymail.com> Date: Thu, 11 Apr 2024 17:47:49 +0500 Subject: [PATCH 11/12] Apply rubocop offences and remove bundler usage in runtime --- lib/rspec/openapi/shared_hooks.rb | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/rspec/openapi/shared_hooks.rb b/lib/rspec/openapi/shared_hooks.rb index d3496b9..2069cea 100644 --- a/lib/rspec/openapi/shared_hooks.rb +++ b/lib/rspec/openapi/shared_hooks.rb @@ -1,12 +1,8 @@ module SharedHooks def self.find_extractor - names = Bundler.load.specs.map(&:name) - - if names.include?('rails') && defined?(Rails) && - Rails.respond_to?(:application) && Rails.application + if defined?(Rails) && Rails.respond_to?(:application) && Rails.application RSpec::OpenAPI::Extractors::Rails - elsif names.include?('hanami') && defined?(Hanami) && - Hanami.respond_to?(:app) && Hanami.app? + elsif defined?(Hanami) && Hanami.respond_to?(:app) && Hanami.app? RSpec::OpenAPI::Extractors::Hanami # elsif defined?(Roda) # some Roda extractor From 32307784363d674fdc0aaa88920a0675cf158a0c Mon Sep 17 00:00:00 2001 From: Aleksei <9ceb2990-3744-4629-82f3-19bc5c80b3a2@mackevich.addymail.com> Date: Thu, 11 Apr 2024 20:03:44 +0500 Subject: [PATCH 12/12] Refactor require system for extractor --- lib/rspec/openapi.rb | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/rspec/openapi.rb b/lib/rspec/openapi.rb index ad60760..69fce72 100644 --- a/lib/rspec/openapi.rb +++ b/lib/rspec/openapi.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require 'bundler/setup' require 'rspec/openapi/version' require 'rspec/openapi/components_updater' require 'rspec/openapi/default_schema' @@ -16,8 +15,19 @@ require 'rspec/openapi/extractors' require 'rspec/openapi/extractors/rack' -require 'rspec/openapi/extractors/hanami' if Bundler.load.specs.map(&:name).include?('hanami') -require 'rspec/openapi/extractors/rails' if Bundler.load.specs.map(&:name).include?('rails') +begin + require 'hanami' + require 'rspec/openapi/extractors/hanami' +rescue LoadError + puts 'Hanami not detected' +end + +begin + require 'rails' + require 'rspec/openapi/extractors/rails' +rescue LoadError + puts 'Rails not detected' +end require 'rspec/openapi/minitest_hooks' if Object.const_defined?('Minitest') require 'rspec/openapi/rspec_hooks' if ENV['OPENAPI'] && Object.const_defined?('RSpec')