diff --git a/.gitignore b/.gitignore index e47af66..b627be2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ template/Gemfile.lock grape-starter.md tmp api/ +.vscode +spec/fixtures/pmm.json diff --git a/.rubocop.yml b/.rubocop.yml index c587b5d..3c34135 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -26,6 +26,9 @@ Layout/IndentationWidth: Layout/LineLength: Max: 120 +Lint/MissingSuper: + Enabled: false + Metrics/BlockLength: Exclude: - 'spec/**/*' @@ -33,6 +36,9 @@ Metrics/BlockLength: Metrics/AbcSize: Max: 20 +Metrics/ClassLength: + Max: 120 + Metrics/MethodLength: Max: 20 diff --git a/lib/starter/importer/namespace.rb b/lib/starter/importer/namespace.rb index 2f92593..4733f85 100644 --- a/lib/starter/importer/namespace.rb +++ b/lib/starter/importer/namespace.rb @@ -30,7 +30,7 @@ class #{@naming.klass_name} < Grape::API private def namespace - naming.version_klass ? "'#{naming.origin}'" : ":#{naming.resource.downcase}" + @namespace ||= naming.version_klass ? "'#{naming.origin}'" : ":#{naming.resource.downcase}" end def endpoints diff --git a/lib/starter/importer/nested_params.rb b/lib/starter/importer/nested_params.rb new file mode 100644 index 0000000..17ec5dc --- /dev/null +++ b/lib/starter/importer/nested_params.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: false + +module Starter + module Importer + class NestedParams < Parameter + def initialize(name:, definition:) + @name = name + @definition = definition + end + end + end +end diff --git a/lib/starter/importer/parameter.rb b/lib/starter/importer/parameter.rb index c7a4e61..ad2bc95 100644 --- a/lib/starter/importer/parameter.rb +++ b/lib/starter/importer/parameter.rb @@ -1,3 +1,4 @@ +# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity # frozen_string_literal: false module Starter @@ -5,34 +6,37 @@ module Importer class Parameter class Error < StandardError; end - attr_accessor :kind, :name, :definition + attr_accessor :kind, :name, :definition, :nested def initialize(definition:, components: {}) + @nested = [] @kind = validate_parameters(definition:, components:) prepare_attributes(definition:, components:) end def to_s - entry = definition['required'] ? 'requires' : 'optional' - entry << " :#{name}" - entry << ", type: #{definition['schema']['type'].capitalize}" + return serialized_object if nested.present? - doc = documentation - entry << ", #{doc}" if doc + serialized + end - entry + def nested? + @nested.present? end private + # initialize helper + # def validate_parameters(definition:, components:) return :direct if definition.key?('name') return :ref if definition.key?('$ref') && components.key?('parameters') + return :body if definition.key?('content') raise Error, 'no valid combination given' end - def prepare_attributes(definition:, components:) + def prepare_attributes(definition:, components:) # rubocop:disable Metrics/MethodLength case kind when :direct @name = definition['name'] @@ -45,26 +49,111 @@ def prepare_attributes(definition:, components:) if (value = @definition.dig('schema', '$ref').presence) @definition['schema'] = components.dig(*value.split('/')[2..]) end + when :body + definition['in'] = 'body' + schema = definition['content'].values.first['schema'] + if schema.key?('$ref') + path = schema['$ref'].split('/')[2..] + + @name, @definition = handle_body(definition:, properties: components.dig(*path)) + @name ||= path.last + else + @name, @definition = handle_body(definition:, properties: schema) + @name = nested.map(&:name).join('_') if @name.nil? && nested? + end end end - def documentation - tmp = {} - tmp['desc'] = definition['description'] if definition.key?('description') - tmp['in'] = definition['in'] if definition.key?('in') - - return nil if tmp.empty? - - documentation = 'documentation:' - documentation.tap do |doc| - doc << ' { ' - content = tmp.map { |k, v| "#{k}: '#{v}'" } - doc << content.join(', ') - doc << ' }' + def handle_body(definition:, properties:) + if simple_object?(properties:) + name = properties['properties'].keys.first + type = properties.dig('properties', name, 'type') || 'array' + subtype = properties.dig('properties', name, 'items', 'type') + definition['type'] = subtype.nil? ? type : "#{type}[#{subtype}]" + + properties.dig('properties', name).except('type').each { |k, v| definition[k] = v } + definition['type'] = 'file' if definition['format'].presence == 'binary' + + [name, definition] + elsif object?(definition:) # a nested object -> JSON + definition['type'] = properties['type'].presence || 'JSON' + return [nil, definition] if properties.nil? || properties['properties'].nil? + + properties['properties'].each do |nested_name, definition| + definition['required'] = properties['required']&.include?(nested_name) || false + @nested << NestedParams.new(name: nested_name, definition:) + end + [nil, definition] + else # others + [nil, definition.merge(properties)] + end + end + + # handle_body helper, check/find/define types + # + def object?(definition:) + definition['content'].keys.first.include?('application/json') + end + + def simple_object?(properties:) + properties.key?('properties') && + properties['properties'].length == 1 + end + + # to_s helper + # + def serialized_object + definition.tap do |foo| + foo['type'] = foo['type'] == 'object' ? 'JSON' : foo['type'] end - documentation + parent = NestedParams.new(name: name, definition: definition) + + entry = "#{parent} do\n" + nested.each { |n| entry << " #{n}\n" } + entry << ' end' + end + + def serialized + type = definition['type'] || definition['schema']['type'] + type.scan(/\w+/).each { |x| type.match?('JSON') ? type : type.sub!(x, x.capitalize) } + + if type == 'Array' && definition.key?('items') + sub = definition.dig('items', 'type').to_s.capitalize + type = "#{type}[#{sub}]" + end + + entry = definition['required'] ? 'requires' : 'optional' + entry << " :#{name}" + entry << ", type: #{type}" + doc = documentation + entry << ", #{doc}" if doc + + entry + end + + def documentation + @documentation ||= begin + tmp = {} + tmp['desc'] = definition['description'] if definition.key?('description') + tmp['in'] = definition['in'] if definition.key?('in') + + if definition.key?('format') + tmp['format'] = definition['format'] + tmp['type'] = 'File' if definition['format'] == 'binary' + end + + documentation = 'documentation:' + documentation.tap do |doc| + doc << ' { ' + content = tmp.map { |k, v| "#{k}: '#{v}'" } + doc << content.join(', ') + doc << ' }' + end + end end end end end + +# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity diff --git a/lib/starter/importer/specification.rb b/lib/starter/importer/specification.rb index 87fd8d5..ad56211 100644 --- a/lib/starter/importer/specification.rb +++ b/lib/starter/importer/specification.rb @@ -47,7 +47,7 @@ def segmentize(path) [rest.shift, rest.empty? ? '/' : "/#{rest.join('/')}"] end - def prepare_verbs(spec) + def prepare_verbs(spec) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity path_params = nil spec.each_with_object({}) do |(verb, content), memo| if verb == 'parameters' @@ -56,9 +56,9 @@ def prepare_verbs(spec) end memo[verb] = content - next unless content.key?('parameters') || path_params + next unless content.key?('parameters') || content.key?('requestBody') || path_params - parameters = content['parameters'] || path_params + parameters = ((content['parameters'] || path_params || []) + [content['requestBody']]).compact memo[verb]['parameters'] = parameters.each_with_object({}) do |definition, para| parameter = Parameter.new(definition:, components:) diff --git a/spec/lib/importer/parameter_spec.rb b/spec/lib/importer/parameter_spec.rb index 4e6c860..d90ef1b 100644 --- a/spec/lib/importer/parameter_spec.rb +++ b/spec/lib/importer/parameter_spec.rb @@ -1,8 +1,52 @@ # frozen_string_literal: false +# rubocop:disable Layout/LineLength RSpec.describe Starter::Importer::Parameter do + let(:components) do + { + 'parameters' => { + 'rowParam' => { + 'description' => 'Board row (vertical coordinate)', + 'name' => 'row', + 'in' => 'path', + 'required' => true, + 'schema' => { '$ref' => '#/components/schemas/coordinate' } + }, + 'columnParam' => { + 'description' => 'Board column (horizontal coordinate)', + 'name' => 'column', + 'in' => 'path', + 'required' => true, + 'schema' => { '$ref' => '#/components/schemas/coordinate' } + } + }, + 'schemas' => { + 'errorMessage' => { 'type' => 'string', 'maxLength' => 256, 'description' => 'A text message describing an error' }, + 'coordinate' => { 'type' => 'integer', 'minimum' => 1, 'maximum' => 3, 'example' => 1 }, + 'mark' => { + 'type' => 'string', + 'enum' => ['.', 'X', 'O'], + 'description' => 'Possible values for a board square. `.` means empty square.', + 'example' => '.' + }, + 'board' => { + 'type' => 'array', + 'maxItems' => 3, + 'minItems' => 3, + 'items' => { 'type' => 'array', 'maxItems' => 3, 'minItems' => 3, 'items' => { '$ref' => '#/components/schemas/mark' } } + }, + 'winner' => { + 'type' => 'string', 'enum' => ['.', 'X', 'O'], 'description' => 'Winner of the game. `.` means nobody has won yet.', 'example' => '.' + }, + 'status' => { + 'type' => 'object', 'properties' => { 'winner' => { '$ref' => '#/components/schemas/winner' }, 'board' => { '$ref' => '#/components/schemas/board' } } + } + } + } + end + describe 'valid parameters -> sets kind' do - describe 'name in definition' do + describe 'name in definition -> :direct' do subject { described_class.new(definition:) } let(:definition) { { 'name' => 'limit' } } @@ -13,7 +57,7 @@ end end - describe 'ref and components/parameters given' do + describe 'ref and components/parameters given -> :ref' do subject { described_class.new(definition:, components:) } let(:definition) { { '$ref' => '#/components/parameters/rowParam' } } @@ -24,6 +68,35 @@ expect(subject.kind).to eql :ref end end + + describe 'content given -> body' do + subject { described_class.new(definition:, components:) } + + let(:definition) do + { + 'content' => { 'application/json' => { + 'schema' => { + '$ref' => '#/components/schemas/some_name' + } + } } + } + end + let(:components) do + { + 'schemas' => { + 'some_name' => { + 'required' => %w[crop name], + 'type' => 'object', + 'properties' => {} + } + } + } + end + specify do + expect { subject }.not_to raise_error + expect(subject.kind).to eql :body + end + end end describe 'invalid parameters' do @@ -49,104 +122,217 @@ end end - describe 'local params' do - subject { described_class.new(definition:) } + subject { described_class.new(definition:, components:) } - describe 'atomic' do - let(:definition) do - { - 'name' => 'limit', - 'in' => 'query', - 'description' => 'maximum number of results to return', - 'required' => false, - 'schema' => { 'type' => 'integer', 'format' => 'int32' } - } - end + describe 'atomic parameter' do + let(:definition) do + { + 'name' => 'limit', + 'in' => 'query', + 'description' => 'maximum number of results to return', + 'required' => false, + 'schema' => { 'type' => 'integer', 'format' => 'int32' } + } + end - specify do - expect(subject.name).to eql 'limit' - expect(subject.definition.keys).to match_array %w[ - description in required schema - ] - expect(subject.to_s).to eql( - "optional :limit, type: Integer, documentation: { desc: 'maximum number of results to return', in: 'query' }" - ) - end + specify do + expect(subject.name).to eql 'limit' + expect(subject.definition.keys).to match_array %w[ + description in required schema + ] + expect(subject.to_s).to eql( + "optional :limit, type: Integer, documentation: { desc: 'maximum number of results to return', in: 'query' }" + ) end + end - describe 'array' do - let(:definition) do - { - 'name' => 'tags', - 'in' => 'query', - 'description' => 'tags to filter by', - 'required' => false, - 'style' => 'form', - 'schema' => { 'type' => 'array', 'items' => { 'type' => 'string' } } - } - end + describe 'reference atomic parameter' do + let(:definition) do + { '$ref' => '#/components/parameters/rowParam' } + end - specify do - expect(subject.name).to eql 'tags' - expect(subject.definition.keys).to match_array %w[ - description in required style schema - ] - expect(subject.to_s).to eql( - "optional :tags, type: Array, documentation: { desc: 'tags to filter by', in: 'query' }" - ) - end + specify do + expect(subject.name).to eql 'row' + expect(subject.definition.keys).to match_array %w[ + description in required schema + ] + expect(subject.definition['schema']['type']).to eql 'integer' + expect(subject.to_s).to eql( + "requires :row, type: Integer, documentation: { desc: 'Board row (vertical coordinate)', in: 'path' }" + ) end end - describe 'references' do - subject { described_class.new(definition:, components:) } - + describe 'array' do let(:definition) do - { '$ref' => '#/components/parameters/rowParam' } + { + 'name' => 'tags', + 'in' => 'query', + 'description' => 'tags to filter by', + 'required' => false, + 'style' => 'form', + 'schema' => { 'type' => 'array', 'items' => { 'type' => 'string' } } + } end - let(:components) do + specify do + expect(subject.name).to eql 'tags' + expect(subject.definition.keys).to match_array %w[ + description in required style schema + ] + expect(subject.to_s).to eql( + "optional :tags, type: Array, documentation: { desc: 'tags to filter by', in: 'query' }" + ) + end + end + + describe 'request body: simple array' do + let(:definition) do { - 'parameters' => { - 'rowParam' => { - 'description' => 'Board row (vertical coordinate)', - 'name' => 'row', - 'in' => 'path', - 'required' => true, + 'content' => { + 'multipart/form-data' => { 'schema' => { - '$ref' => '#/components/schemas/coordinate' + 'required' => ['ids'], + 'properties' => { + 'ids' => { 'type' => 'array', 'items' => { 'type' => 'integer', 'format' => 'int32' } } + } } - }, - 'columnParam' => { - 'description' => 'Board column (horizontal coordinate)', - 'name' => 'column', - 'in' => 'path', - 'required' => true, + } + }, + 'required' => true, + 'in' => 'body' + } + end + + specify do + expect(subject.name).to eql 'ids' + expect(subject.definition.keys).to match_array %w[ + content in items required type + ] + expect(subject.to_s).to eql( + "requires :ids, type: Array[Integer], documentation: { in: 'body' }" + ) + end + end + + describe 'request body: other' do + let(:definition) do + { + 'required' => true, + 'content' => { + 'application/form_data' => { + 'schema' => { '$ref' => '#/components/schemas/mark' } + } + } + } + end + + specify do + expect(subject.name).to eql 'mark' + expect(subject.definition.keys).to match_array %w[ + required content in type enum description example + ] + expect(subject.to_s).to eql( + "requires :mark, type: String, documentation: { desc: 'Possible values for a board square. `.` means empty square.', in: 'body' }" + ) + end + end + + describe 'request body: object' do + let(:definition) do + { + 'content' => { + 'application/json' => { 'schema' => { - '$ref' => '#/components/schemas/coordinate' + '$ref' => '#/components/schemas/postApiV1CalibrationsCreating' } } }, + 'required' => true, + 'in' => 'body' + } + end + + let(:components) do + { 'schemas' => { - 'coordinate' => { - 'type' => 'integer', - 'minimum' => 1, - 'maximum' => 3, - 'example' => 1 + 'postApiV1CalibrationsCreating' => { + 'required' => ['crop', 'name'], # rubocop:disable Style/WordArray + 'type' => 'object', + 'properties' => { + 'crop' => { 'type' => 'string', 'description' => 'The Crop of it.' }, + 'name' => { 'type' => 'string', 'description' => 'The Name of it.' }, + 'projects' => { 'type' => 'array', 'description' => 'The project(s) of it.', 'items' => { 'type' => 'string' } }, + 'pools' => { 'type' => 'array', 'description' => 'The pool(s) of it.', 'items' => { 'type' => 'string' } }, + 'material_level' => { 'type' => 'array', 'description' => 'The material level(s) of it.', 'items' => { 'type' => 'string' } }, + 'material_groups' => { 'type' => 'array', 'description' => 'The material group(s) of it.', 'items' => { 'type' => 'string' } }, + 'path' => { 'type' => 'string', 'description' => 'The path of it.' }, + 'testers' => { 'type' => 'array', 'description' => 'The tester(s) of it.', 'items' => { 'type' => 'string' } }, + 'locations' => { 'type' => 'array', 'description' => 'The location(s) of it.', 'items' => { 'type' => 'string' } }, + 'years' => { 'type' => 'array', 'description' => 'The year(s) of it.', 'items' => { 'type' => 'integer', 'format' => 'int32' } }, + 'tags' => { 'type' => 'array', 'description' => 'The tag(s) of it.', 'items' => { 'type' => 'string' } }, + 'treatments' => { 'type' => 'array', 'description' => 'The treatment(s) of it.', 'items' => { 'type' => 'string' } }, + 'id' => { 'type' => 'integer', 'description' => 'The ID of it (possible on back step, so editing is possible).', 'format' => 'int32' } + }, + 'description' => 'Creating Calibration' } } } end specify do - expect(subject.name).to eql 'row' + expect(subject.name).to eql 'postApiV1CalibrationsCreating' expect(subject.definition.keys).to match_array %w[ - description in required schema + required content in type ] - expect(subject.definition['schema']['type']).to eql 'integer' - expect(subject.to_s).to eql( - "requires :row, type: Integer, documentation: { desc: 'Board row (vertical coordinate)', in: 'path' }" + expect(subject.definition['type']).to eql 'object' + + expect(subject.to_s).to include( + "requires :postApiV1CalibrationsCreating, type: JSON, documentation: { in: 'body' } do" + ) + expect(subject.to_s).to include( + "requires :crop, type: String, documentation: { desc: 'The Crop of it.' }" + ) + expect(subject.to_s).to include( + "requires :name, type: String, documentation: { desc: 'The Name of it.' }" + ) + expect(subject.to_s).to include( + "optional :projects, type: Array[String], documentation: { desc: 'The project(s) of it.' }" + ) + expect(subject.to_s).to include( + "optional :pools, type: Array[String], documentation: { desc: 'The pool(s) of it.' }" + ) + expect(subject.to_s).to include( + "optional :material_level, type: Array[String], documentation: { desc: 'The material level(s) of it.' }" + ) + expect(subject.to_s).to include( + "optional :material_groups, type: Array[String], documentation: { desc: 'The material group(s) of it.' }" + ) + expect(subject.to_s).to include( + "optional :path, type: String, documentation: { desc: 'The path of it.' }" + ) + expect(subject.to_s).to include( + "optional :testers, type: Array[String], documentation: { desc: 'The tester(s) of it.' }" + ) + expect(subject.to_s).to include( + "optional :locations, type: Array[String], documentation: { desc: 'The location(s) of it.' }" + ) + expect(subject.to_s).to include( + "optional :years, type: Array[Integer], documentation: { desc: 'The year(s) of it.' }" + ) + expect(subject.to_s).to include( + "optional :tags, type: Array[String], documentation: { desc: 'The tag(s) of it.' }" + ) + expect(subject.to_s).to include( + "optional :treatments, type: Array[String], documentation: { desc: 'The treatment(s) of it.' }" + ) + expect(subject.to_s).to include( + "optional :id, type: Integer, documentation: { desc: 'The ID of it (possible on back step, so editing is possible).', format: 'int32' }" + ) + expect(subject.to_s).to include( + 'end' ) end end end +# rubocop:enable Layout/LineLength