diff --git a/lib/ioki/model/driver/base.rb b/lib/ioki/model/driver/base.rb index 3bcb4f59..232ea569 100644 --- a/lib/ioki/model/driver/base.rb +++ b/lib/ioki/model/driver/base.rb @@ -4,9 +4,6 @@ module Ioki module Model module Driver class Base < Ioki::Model::Base - def self.specification_scope - 'driver_api' - end end end end diff --git a/lib/ioki/model/passenger/base.rb b/lib/ioki/model/passenger/base.rb index 75eaab43..65595f83 100644 --- a/lib/ioki/model/passenger/base.rb +++ b/lib/ioki/model/passenger/base.rb @@ -4,9 +4,6 @@ module Ioki module Model module Passenger class Base < Ioki::Model::Base - def self.specification_scope - 'passenger_api' - end end end end diff --git a/lib/ioki/model/platform/base.rb b/lib/ioki/model/platform/base.rb index e13bf604..00f7524f 100644 --- a/lib/ioki/model/platform/base.rb +++ b/lib/ioki/model/platform/base.rb @@ -4,9 +4,6 @@ module Ioki module Model module Platform class Base < Ioki::Model::Base - def self.specification_scope - 'platform_api--v20210101' - end end end end diff --git a/lib/ioki/model/webhooks/base.rb b/lib/ioki/model/webhooks/base.rb index aa363c28..2c05e2e3 100644 --- a/lib/ioki/model/webhooks/base.rb +++ b/lib/ioki/model/webhooks/base.rb @@ -4,10 +4,6 @@ module Ioki module Model module Webhooks class Base < Ioki::Model::Base - def self.specification_scope - 'webhooks--v20201201' - end - def self.valid_definition?(_definition) true end diff --git a/lib/ioki/support.rb b/lib/ioki/support.rb index 1e566eef..e28c1c3b 100644 --- a/lib/ioki/support.rb +++ b/lib/ioki/support.rb @@ -15,12 +15,22 @@ def camelize(term) string end + def underscore(camel_cased_word) + camel_cased_word + .to_s + .gsub('::', '/') + .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .tr('-', '_') + .downcase + end + # activesupport/lib/active_support/core_ext/object/blank.rb def blank?(value) value.respond_to?(:empty?) ? !!value.empty? : !value end - module_function :camelize, :blank? + module_function :camelize, :underscore, :blank? module ModuleMixins # activesupport/lib/active_support/core_ext/module/introspection.rb diff --git a/lib/open_api.rb b/lib/open_api.rb new file mode 100644 index 00000000..66c5ede4 --- /dev/null +++ b/lib/open_api.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +# This is the helper module to compare (and repair) the models with the OpenAPI definitions +module OpenApi + require 'ioki' + require 'json' + + class Model + attr_accessor :specification, :ioki_model + + def initialize(specification, ioki_model) + @specification = specification + @ioki_model = ioki_model + end + + def repair + # puts model + changed = false + unspecified_model_attributes.each do |unspecified_model_attribute| + comment_attribute_definition(unspecified_model_attribute) + changed = true + end + undefined_schema_attributes.each do |undefined_schema_attribute| + add_attribute_definition(undefined_schema_attribute) + changed = true + end + if changed + save! + # puts file_lines.join + end + end + + def comment_attribute_definition(attribute_name) + # puts "Removing #{model}##{attribute_name} which is present in the model but not in the OpenAPI-specification" + matched_trailing_comma = false + @file_lines = file_lines.map do |line| + matched = line.match(Regexp.new(":#{attribute_name},")) + if matched || matched_trailing_comma + line.insert(8, '# ') + matched_trailing_comma = line.match(/,\n/) + # puts line + end + line + end + end + + def add_attribute_definition(attribute_name) + # puts "Adding #{model}##{attribute_name} which is present in the OpenAPI-specification but not in the model" + attribute_definition = schema.fetch('properties')[attribute_name.to_s] + + if attribute_definition.key? 'type' + type = if attribute_definition['type'].is_a?(Array) + attribute_definition['type'].reject { |t| t == 'null' }.first + elsif attribute_definition['type'].is_a?(Hash) + attribute_definition['type']['type'] + else + attribute_definition['type'] + end + class_name = nil + elsif attribute_definition.key? '$ref' + type = 'object' + class_name = ref_to_class_name attribute_definition['$ref'] + elsif attribute_definition.key? 'oneOf' + type = 'object' + refs = attribute_definition['oneOf'].reject { |r| r['type'] == 'null' } + class_name = if refs.count == 1 + ref_to_class_name(refs.first['$ref']) + elsif refs.count > 1 + "[#{refs.map { |ref| ref_to_class_name(ref['$ref']) }.join(', ')}]" + else + nil + end + end + + line = " attribute :#{attribute_name}, type: :#{type}, on: [:create, :read, :update]" + if class_name.is_a?(String) + line += ", class_name: #{class_name}" + end + line += "\n" + after = file_lines.index { |line| line.match(/\s*attribute\s\:/) } + # puts line + file_lines.insert(after, line) + end + + def ref_to_class_name(ref) + class_name = Ioki::Support.camelize(ref.split('--').last.split('/').last) + "'#{class_name}'" + end + + def save! + File.write(file_path, file_lines.join) + end + + def unspecified_model_attributes + defined_model_attributes - specified_schema_attributes + end + + def undefined_schema_attributes + specified_schema_attributes - defined_model_attributes + end + + def specified_schema_attributes + schema.fetch('properties').reject do |_name, attributes| + attributes['deprecated'] + end.keys.map(&:to_sym) + end + + def deprecated_schema_attributes + schema.fetch('properties').select do |_name, attributes| + attributes['deprecated'] + end.keys.map(&:to_sym) + end + + # Attributes present in the Ioki::Model class + def defined_model_attributes + ioki_model + .attribute_definitions + .reject { |_key, definition| definition[:unvalidated] } + .keys + end + + def schema + specification.schemas[schema_path] + end + + def schema_path + ioki_model.schema_path || "#{specification.scope}--#{model_name}" + end + + def file_lines + @file_lines ||= model_file.readlines.to_a + end + + def model_file + File.open file_path + end + + def file_path + "lib/ioki/model/#{specification.name}/#{model_name}.rb" + end + + def model_name + Ioki::Support.underscore ioki_model.to_s.split('::').last + end + end + + class Specification + attr_accessor :name, :scope + + def self.all + [ + new('platform', 'platform_api--v20210101'), + new('passenger', 'passenger_api'), + new('driver', 'driver_api'), + new('webhooks', 'webhooks--v20201201') + ] + end + + def initialize(name, scope) + @name = name + @scope = scope + end + + def repair + valid? + + models.each(&:repair) + end + + def valid? + raise "File not found: #{definition_file_path}" unless File.file?(definition_file_path) + end + + def models + @models ||= base_model.descendants.reject(&:unvalidated?).map do |model| + Model.new(self, model) + end + end + + def base_model + Ioki::Model.const_get(Ioki::Support.camelize(name)).const_get('Base') + end + + def schemas + definition_json.dig('components', 'schemas') + end + + def definition_json + JSON.parse(File.read(definition_file_path)) + end + + def file_exists? + File.file?(definition_file_path) + end + + def definition_file_path + "spec/fixtures/open_api_definitions/#{name}_api.json" + end + end +end diff --git a/lib/tasks/ioki.rake b/lib/tasks/ioki.rake index 5b8fe097..9d0e7567 100644 --- a/lib/tasks/ioki.rake +++ b/lib/tasks/ioki.rake @@ -3,160 +3,9 @@ namespace :ioki do namespace :openapi do desc 'Remove unspecified and add missing attributes from OpenAPI specification' - task :fix do - require 'ioki' - require 'json' - require 'debug' - - @api_specification_paths = { - platform_api: 'spec/fixtures/open_api_definitions/platform_api.json', - passenger_api: 'spec/fixtures/open_api_definitions/passenger_api.json', - driver_api: 'spec/fixtures/open_api_definitions/driver_api.json', - webhooks_api: 'spec/fixtures/open_api_definitions/webhooks_api.json' - } - - @api_specifications = @api_specification_paths.transform_values do |path| - File.file?(path) ? JSON.parse(File.read(path)) : nil - end - - def underscore(camel_cased_word) - camel_cased_word - .to_s - .gsub('::', '/') - .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') - .gsub(/([a-z\d])([A-Z])/, '\1_\2') - .tr('-', '_') - .downcase - end - - def camelize(word) - word - .to_s - .gsub(/\/(.?)/) { "::#{Regexp.last_match(1).upcase}" } - .gsub(/(?:^|_)(.)/) { Regexp.last_match(1).upcase } - end - - def schemas - @api_specifications.fetch((@api + '_api').to_sym).fetch('components').fetch('schemas') - end - - def model_name - underscore @model.to_s.split('::').last - end - - def file_path - "lib/ioki/model/#{@api}/#{model_name}.rb" - end - - def model_file - File.open file_path - end - - def model_file_lines - model_file.readlines.to_a - end - - def schema_path - @model.schema_path || "#{@model.specification_scope}--#{model_name}" - end - - def model_node - schemas[schema_path] - end - - def model_properties - raise "Unknown schema_path: #{schema_path}" if schemas[schema_path].nil? - - model_node.fetch('properties') - end - - def specified_attributes - model_properties.keys.map(&:to_sym) - - unvalidated_attributes - - deprecated_attributes - end - - def deprecated_attributes - model_node.fetch('properties').select do |_name, attributes| - attributes['deprecated'] - end.keys.map(&:to_sym) - end - - def defined_attributes - @model.attribute_definitions.keys - unvalidated_attributes - end - - def unvalidated_attributes - @model - .attribute_definitions - .select { |_key, definition| definition[:unvalidated] } - .keys - end - - def add_attribute_line(name:, type:, class_name:) - after = @lines.index { |line| line.match(/\s*attribute\s\:/) } - line = " attribute :#{name}, type: :#{type}, on: [:create, :read, :update]" - if class_name - line += ", class_name: '#{class_name}'" - end - line += "\n" - puts line - @lines.insert(after, line) - end - - { - platform: Ioki::Model::Platform::Base, - passenger: Ioki::Model::Passenger::Base, - driver: Ioki::Model::Driver::Base, - webhooks: Ioki::Model::Webhooks::Base - }.each do |api, base_model| - @api = api.to_s - base_model.descendants.reject(&:unvalidated?).each do |model| - puts model - @model = model - @lines = model_file_lines - changed = false - (defined_attributes - specified_attributes).each do |unspecified_attribute| - puts "Removing #{model}##{unspecified_attribute} which is not present in OpenAPI-specification" - @model.attribute_definitions - matched_trailing_comma = false - @lines = @lines.map do |line| - matched = line.match(Regexp.new(":#{unspecified_attribute},")) - if matched || matched_trailing_comma - line.insert(8, '# ') - matched_trailing_comma = line.match(/,\n/) - end - line - end - changed = true - end - - (specified_attributes - defined_attributes).each do |undefined_attribute| - puts "OpenAPI-specification attribute which is missing from #{model}: #{undefined_attribute}" - attribute_definition = model_properties[undefined_attribute.to_s] - if attribute_definition.key? 'type' - type = if attribute_definition['type'].is_a?(Array) - attribute_definition['type'].reject { |t| t == 'null' }.first - else - attribute_definition['type'] - end - class_name = nil - elsif attribute_definition.key? 'oneOf' - type = 'object' - refs = attribute_definition['oneOf'].reject { |r| r['type'] == 'null' } - class_name = if refs.count == 1 - camelize(refs.first['$ref'].split('--').last.split('/').last) - else - nil - end - end - add_attribute_line name: undefined_attribute, type: type, class_name: class_name - changed = true - end - - File.write(file_path, @lines.join) if changed - end - end + task :repair do + require 'open_api' + OpenApi::Specification.all.map(&:repair) end end end diff --git a/spec/helper/openapi_matcher.rb b/spec/helper/openapi_matcher.rb deleted file mode 100644 index 80d9bc25..00000000 --- a/spec/helper/openapi_matcher.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -require 'rspec/expectations' - -API_SPECIFICATION_PATHS = { - platform_api: 'spec/fixtures/open_api_definitions/platform_api.json', - passenger_api: 'spec/fixtures/open_api_definitions/passenger_api.json', - driver_api: 'spec/fixtures/open_api_definitions/driver_api.json', - webhooks_api: 'spec/fixtures/open_api_definitions/webhooks_api.json' -}.freeze - -API_SPECIFICATIONS = - API_SPECIFICATION_PATHS.transform_values do |path| - File.file?(path) ? JSON.parse(File.read(path)) : nil - end.freeze - -RSpec::Matchers.define :match_open_api_definition do |scope, model| - define_method :schemas do - API_SPECIFICATIONS.fetch(scope.to_sym).fetch('components').fetch('schemas') - end - - define_method :schema_path do |actual_model| - actual_model.schema_path || - "#{actual_model.specification_scope}--#{StringHelper.underscore(model.to_s.split('::').last)}" - end - - define_method :model_node do |actual_model| - schemas[schema_path(actual_model)] - end - - define_method :specified_attributes do |actual_model| - model_node(actual_model).fetch('properties').keys.map(&:to_sym) - - unvalidated_attributes(actual_model) - - deprecated_attributes(actual_model) - end - - define_method :deprecated_attributes do |actual_model| - model_node(actual_model).fetch('properties').select do |_name, attributes| - attributes['deprecated'] - end.keys.map(&:to_sym) - end - - define_method :defined_attributes do |actual_model| - actual_model.attribute_definitions.keys - unvalidated_attributes(actual_model) - end - - define_method :unvalidated_attributes do |actual_model| - actual_model - .attribute_definitions - .select { |_key, definition| definition[:unvalidated] } - .keys - end - - match do |actual_model| - return false if model_node(actual_model).nil? - - unless (defined_attributes(actual_model) & deprecated_attributes(actual_model)).empty? - deprecated_attrs = defined_attributes(actual_model) | deprecated_attributes(actual_model) - warn "The following attributes on #{actual_model} are deprecated: #{deprecated_attrs}." - end - - specified_attributes(actual_model).sort == defined_attributes(actual_model).sort - end - - failure_message do |actual_model| - if model_node(actual_model).nil? - <<~MESSAGE - Specification not found for #{actual_model}. I was looking for: #{schema_path(actual_model)}. Available specifications: - #{schemas.keys} - MESSAGE - else - <<~MESSAGE - expected that the model #{actual_model} would match the OpenAPI-specification, but there are differences. - The attributes which are used in the model but which are not in the OpenAPI-specification are: - #{(defined_attributes(actual_model) - specified_attributes(actual_model)).sort} - The attributes which are not used in the model but which are in the OpenAPI-specification are: - #{(specified_attributes(actual_model) - defined_attributes(actual_model)).sort} - The attributes defined in the model are: - #{defined_attributes(actual_model).sort} - The attributes defined in the OpenAPI-specification are: - #{specified_attributes(actual_model).sort} - MESSAGE - end - end -end diff --git a/spec/ioki/model/openapi_spec.rb b/spec/ioki/model/openapi_spec.rb index eb28ea08..b4538441 100644 --- a/spec/ioki/model/openapi_spec.rb +++ b/spec/ioki/model/openapi_spec.rb @@ -1,44 +1,36 @@ # frozen_string_literal: true require 'spec_helper' +require 'open_api' RSpec.describe 'OpenApi schema definitions' do - if API_SPECIFICATIONS[:platform_api] - describe 'the platform_api definition' do - Ioki::Model::Platform::Base.descendants.reject(&:unvalidated?).each do |model| - it "ensures #{model} matches the published API specifications" do - expect(model).to match_open_api_definition('platform_api', model) - end - end - end - end + OpenApi::Specification.all.each do |specification| + describe specification.name do + if specification.file_exists? + specification.models.each do |model| + describe model.ioki_model do + it 'has an OpenApi schema' do + expect(model.schema).not_to be_nil, "expected to find schema definition: #{model.schema_path}" + end - if API_SPECIFICATIONS[:passenger_api] - describe 'the passenger_api definition' do - Ioki::Model::Passenger::Base.descendants.reject(&:unvalidated?).each do |model| - it "ensures #{model} matches the published API specifications" do - expect(model).to match_open_api_definition('passenger_api', model) - end - end - end - end + it 'has all specified OpenApi attributes' do + expect(model.unspecified_model_attributes).to be_empty, + "expected to have the following attributes: #{model.unspecified_model_attributes}" + end - if API_SPECIFICATIONS[:driver_api] - describe 'the driver_api definition' do - Ioki::Model::Driver::Base.descendants.reject(&:unvalidated?).each do |model| - it "ensures #{model} matches the published API specifications" do - expect(model).to match_open_api_definition('driver_api', model) - end - end - end - end + it 'has no deprecated OpenApi attributes' do + expect(model.undefined_schema_attributes).to be_empty, + "expected not to have the following attributes: #{model.undefined_schema_attributes}" + end - if API_SPECIFICATIONS[:webhooks_api] - describe 'the webhooks_api definition' do - Ioki::Model::Webhooks::Base.descendants.each do |model| - it "ensures #{model} matches the published API specifications" do - expect(model).to match_open_api_definition('webhooks_api', model) + it 'has no attributes which are not in the OpenApi specification' do + expect(model.deprecated_schema_attributes).to be_empty, + "expected not to have the following deprecated attributes: #{model.deprecated_schema_attributes}" + end + end end + else + skip 'OpenApi specification file not found' end end end