diff --git a/.rubocop.yml b/.rubocop.yml index 8d2ddb3..9460bc1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,8 +1,8 @@ -require: +plugins: - rubocop-rspec AllCops: - TargetRubyVersion: 3.1 + TargetRubyVersion: 3.2 NewCops: enable Exclude: - bin/**/* @@ -21,13 +21,18 @@ Metrics/BlockLength: - spec/**/* Metrics/MethodLength: - CountAsOne: [hash, array, heredoc] + CountAsOne: + - hash + - array + - heredoc RSpec/AnyInstance: Enabled: false RSpec/ExampleLength: - CountAsOne: [hash, array] + CountAsOne: + - hash + - array RSpec/LeadingSubject: Enabled: false diff --git a/Dockerfile b/Dockerfile index bb3da4b..783783a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:3.1-alpine +FROM ruby:3.2-alpine RUN apk add --no-cache --update git build-base diff --git a/Gemfile b/Gemfile index fa6f2c7..9e82957 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,9 @@ source "https://rubygems.org" # Specify your gem's dependencies in scheemer.gemspec gemspec -gem "rake", "~> 13.0" -gem "rspec", "~> 3.0" -gem "rubocop", "~> 1.21" -gem "rubocop-rspec", "~> 2.12.1" +gem "rake", "~> 13.4" +gem "rspec", "~> 3.13" +gem "rubocop", "~> 1.88" +gem "rubocop-rspec", "~> 3.10" + +gem "pry-nav", "~> 1.0" diff --git a/Gemfile.lock b/Gemfile.lock index d3112e7..69fe52e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,88 +1,111 @@ PATH remote: . specs: - scheemer (3.0.0) - dry-schema (~> 1.13) + scheemer (3.5.0) + dry-schema (~> 1.16) GEM remote: https://rubygems.org/ specs: - ast (2.4.2) - concurrent-ruby (1.2.2) - diff-lcs (1.5.0) - dry-configurable (1.0.1) - dry-core (~> 1.0, < 2) + ast (2.4.3) + bigdecimal (4.1.2) + coderay (1.1.3) + concurrent-ruby (1.3.7) + diff-lcs (1.6.2) + dry-configurable (1.3.0) + dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-core (1.0.0) + dry-core (1.2.0) concurrent-ruby (~> 1.0) + logger zeitwerk (~> 2.6) - dry-inflector (1.0.0) - dry-initializer (3.1.1) - dry-logic (1.5.0) + dry-inflector (1.3.1) + dry-initializer (3.2.0) + dry-logic (1.6.0) + bigdecimal concurrent-ruby (~> 1.0) - dry-core (~> 1.0, < 2) + dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-schema (1.13.1) + dry-schema (1.16.0) concurrent-ruby (~> 1.0) dry-configurable (~> 1.0, >= 1.0.1) - dry-core (~> 1.0, < 2) - dry-initializer (~> 3.0) - dry-logic (>= 1.4, < 2) - dry-types (>= 1.7, < 2) + dry-core (~> 1.1) + dry-initializer (~> 3.2) + dry-logic (~> 1.6) + dry-types (~> 1.9, >= 1.9.1) zeitwerk (~> 2.6) - dry-types (1.7.1) + dry-types (1.9.1) + bigdecimal (>= 3.0) concurrent-ruby (~> 1.0) dry-core (~> 1.0) dry-inflector (~> 1.0) dry-logic (~> 1.4) zeitwerk (~> 2.6) - json (2.6.3) - parallel (1.23.0) - parser (3.2.2.1) + json (2.19.9) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + method_source (1.1.0) + parallel (1.28.0) + parser (3.3.11.1) ast (~> 2.4.1) + racc + prism (1.9.0) + pry (0.14.2) + coderay (~> 1.1) + method_source (~> 1.0) + pry-nav (1.0.0) + pry (>= 0.9.10, < 0.15) + racc (1.8.1) rainbow (3.1.1) - rake (13.0.6) - regexp_parser (2.8.0) - rexml (3.2.5) - rspec (3.12.0) - rspec-core (~> 3.12.0) - rspec-expectations (~> 3.12.0) - rspec-mocks (~> 3.12.0) - rspec-core (3.12.2) - rspec-support (~> 3.12.0) - rspec-expectations (3.12.3) + rake (13.4.2) + regexp_parser (2.12.0) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-mocks (3.12.5) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-support (3.12.0) - rubocop (1.51.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.88.0) json (~> 2.3) - parallel (~> 1.10) - parser (>= 3.2.0.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.28.0, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.28.1) - parser (>= 3.2.1.0) - rubocop-rspec (2.12.1) - rubocop (~> 1.31) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-rspec (3.10.2) + lint_roller (~> 1.1) + regexp_parser (>= 2.0) + rubocop (~> 1.86, >= 1.86.2) ruby-progressbar (1.13.0) - unicode-display_width (2.4.2) - zeitwerk (2.6.8) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + zeitwerk (2.8.2) PLATFORMS x86_64-linux-musl DEPENDENCIES - rake (~> 13.0) - rspec (~> 3.0) - rubocop (~> 1.21) - rubocop-rspec (~> 2.12.1) + pry-nav (~> 1.0) + rake (~> 13.4) + rspec (~> 3.13) + rubocop (~> 1.88) + rubocop-rspec (~> 3.10) scheemer! BUNDLED WITH diff --git a/docker-compose.yml b/docker-compose.yml index 3682b04..8b92daa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: "3" services: scheemer: image: "scheemer:latest" diff --git a/lib/scheemer.rb b/lib/scheemer.rb index cfd5d3f..97ffe37 100644 --- a/lib/scheemer.rb +++ b/lib/scheemer.rb @@ -16,7 +16,7 @@ def initialize(params, data = {}) root_node = permitted.to_h.values.first - super root_node, data.to_h + super(root_node, data.to_h) end end end diff --git a/lib/scheemer/errors.rb b/lib/scheemer/errors.rb index 10a4d43..ed4c3f0 100644 --- a/lib/scheemer/errors.rb +++ b/lib/scheemer/errors.rb @@ -13,8 +13,18 @@ def message end class InvalidSchemaError < Error + def initialize(result) + super + + @result = result + end + def message - "#{title}: #{super}" + "#{title}: #{violations}" + end + + def violations + @result.errors.to_h end private diff --git a/lib/scheemer/extensions/string.rb b/lib/scheemer/extensions/string.rb index 3d14769..91ea5cc 100644 --- a/lib/scheemer/extensions/string.rb +++ b/lib/scheemer/extensions/string.rb @@ -8,30 +8,30 @@ module CaseModifier [first, rest.collect(&:capitalize)].join end UNDERSCORER = lambda do |value| - value.gsub(/::/, '/'). - gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2'). - gsub(/([a-z\d])([A-Z])/,'\1_\2'). - tr("-", "_"). - downcase + value.gsub("::", "/") + .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .tr("-", "_") + .downcase end refine Symbol do def camelcase - CAMELCASER.(self).to_sym + CAMELCASER.call(self).to_sym end def underscore - UNDERSCORER.(self).to_sym + UNDERSCORER.call(to_s).to_sym end end refine String do def camelcase - CAMELCASER.(self) + CAMELCASER.call(self) end def underscore - UNDERSCORER.(self) + UNDERSCORER.call(self) end end end diff --git a/lib/scheemer/fallbacker.rb b/lib/scheemer/fallbacker.rb index a539a39..ba8647f 100644 --- a/lib/scheemer/fallbacker.rb +++ b/lib/scheemer/fallbacker.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "./extensions/hash" +require_relative "extensions/hash" module Scheemer module Fallbacker @@ -8,7 +8,7 @@ module Fallbacker using Extensions::Bury - def apply(params, fallbacks) + def apply(params, fallbacks) # rubocop:todo Metrics/MethodLength cloned_params = params.dup fallbacks.each do |(path, value)| diff --git a/lib/scheemer/params.rb b/lib/scheemer/params.rb index d548602..e3c47a5 100644 --- a/lib/scheemer/params.rb +++ b/lib/scheemer/params.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require_relative "./fallbacker" +require_relative "fallbacker" -require_relative "./extensions/string" +require_relative "extensions/string" module Scheemer # This handles the conversion from the HTTP linguo (camelCase) @@ -27,6 +27,8 @@ def params_fallbacks end module InstanceMethods + include Enumerable + def initialize(params, data = {}) @params = Fallbacker.apply(params, self.class.params_fallbacks) @@ -37,16 +39,36 @@ def to_h @params.to_h.transform_keys { |key| key.to_s.underscore } end + def each(&) + return enum_for(:each) unless block_given? + + @params.each(&) + end + + def multi_slice(key) + return unless @params.is_a?(Hash) + + slices = [ + lambda(&:underscore), + lambda(&:camelcase), + ->(name) { name }, + ].map { |a| @params.slice(a.call(key)) } + .reject(&:empty?) + + return if slices.empty? + + slices.first + end + def method_missing(name, *args, &) - key_name = name.to_sym.camelcase - return @params.fetch(key_name) if @params.key?(key_name) + slice = multi_slice(name.to_sym) + return slice.values.first if slice&.any? super end def respond_to_missing?(name, include_private = false) - key_name = name.camelcase - @params.key?(key_name) || super + multi_slice(name.to_sym)&.any? || super end end end diff --git a/lib/scheemer/schema.rb b/lib/scheemer/schema.rb index 2cf2f91..2561358 100644 --- a/lib/scheemer/schema.rb +++ b/lib/scheemer/schema.rb @@ -4,7 +4,7 @@ Dry::Schema.load_extensions(:hints, :json_schema) -require_relative "./errors" +require_relative "errors" module Scheemer class Schema @@ -25,8 +25,8 @@ def validate_schema!(params) @schema.validate!(params) end - def json_schema - @schema.json_schema + def json_schema(loose: false) + @schema.json_schema(loose:) end private @@ -38,8 +38,21 @@ def check_schema_exists! end end + module Types + include Dry::Types() + + # rubocop:todo Layout/LineLength + UUID_V7 = Strict::String.constrained(format: /^[0-9(a-f|A-F)]{8}-[0-9(a-f|A-F)]{4}-7[0-9(a-f|A-F)]{3}-[89ab][0-9(a-f|A-F)]{3}-[0-9(a-f|A-F)]{12}$/) + # rubocop:enable Layout/LineLength + end + + TypeContainer = ::Dry::Schema::TypeContainer.new + TypeContainer.register("params.uuid_v7", Types::UUID_V7) + def initialize(&) @definitions = ::Dry::Schema.Params do + config.types = TypeContainer + instance_eval(&) end end @@ -52,12 +65,12 @@ def validate!(params) validate(params).tap do |result| next if result.success? - raise InvalidSchemaError, result.messages.to_h + raise InvalidSchemaError, result end end - def json_schema - @definitions.json_schema + def json_schema(loose: false) + @definitions.json_schema(loose:) end end end diff --git a/lib/scheemer/version.rb b/lib/scheemer/version.rb index 164ff42..379aeb7 100644 --- a/lib/scheemer/version.rb +++ b/lib/scheemer/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Scheemer - VERSION = "3.0.0" + VERSION = "3.5.0" end diff --git a/scheemer.gemspec b/scheemer.gemspec index eceae48..73bae8f 100644 --- a/scheemer.gemspec +++ b/scheemer.gemspec @@ -11,7 +11,7 @@ Gem::Specification.new do |spec| spec.summary = "Request Parameter schema enforment for Assembly Voting systems" spec.homepage = "https://github.com/aion-dk/scheemer" spec.license = "MIT" - spec.required_ruby_version = ">= 3.1" + spec.required_ruby_version = ">= 3.2" # spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" @@ -31,7 +31,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] # Uncomment to register a new dependency of your gem - spec.add_dependency "dry-schema", "~> 1.13" + spec.add_dependency "dry-schema", "~> 1.16" # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html diff --git a/spec/scheemer/fallbacker_spec.rb b/spec/scheemer/fallbacker_spec.rb index 41961ed..f1bccfd 100644 --- a/spec/scheemer/fallbacker_spec.rb +++ b/spec/scheemer/fallbacker_spec.rb @@ -61,7 +61,7 @@ subject(:data) do described_class.apply( { content: { key: "old-key" } }, - { "content.new_key" => lambda { "dynamic-value" } } + { "content.new_key" => -> { "dynamic-value" } } ) end diff --git a/spec/scheemer/invalid_schema_error_spec.rb b/spec/scheemer/invalid_schema_error_spec.rb new file mode 100644 index 0000000..60e7650 --- /dev/null +++ b/spec/scheemer/invalid_schema_error_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Scheemer::InvalidSchemaError do + let(:result) do + Struct.new(:errors).new( + { + record: { + name: ["is missing"], + }, + } + ) + end + + subject(:error) { described_class.new(result) } + + it "compiles a semi-readable developer message" do + expect(error.message).to eql(<<~MSG.tr("\n", "")) + The submitted request does not satisfy the following requirements: {:record=>{:name=>["is missing"]}} + MSG + end + + it "allows access to the violations" do + expect(error.violations).to eql( + { + record: { + name: ["is missing"], + }, + } + ) + end +end diff --git a/spec/scheemer/params_spec.rb b/spec/scheemer/params_spec.rb index cced0ea..2aa4d4c 100644 --- a/spec/scheemer/params_spec.rb +++ b/spec/scheemer/params_spec.rb @@ -21,6 +21,60 @@ expect(record.someValue).to eql("testing") end end + + context "with a list node" do + let(:klass) do + Class.new do + extend Scheemer::Params::DSL + end + end + + subject(:record) { klass.new([{ name: "testing" }]) } + + it "does not resolve" do + expect(record.respond_to?(:name)).to be false + end + end + end + + describe "#each" do + let(:klass) do + Class.new do + extend Scheemer::Params::DSL + end + end + + subject(:record) { klass.new({ "someKey" => ["testing"] }) } + + it { expect(record).to respond_to(:each) } + + it "can iterate through the params" do + expect(record.map(&:to_a)).to eql([["someKey", ["testing"]]]) + end + end + + describe "#to_h" do + let(:klass) do + Class.new do + extend Scheemer::Params::DSL + end + end + + context "with a hash node" do + subject(:record) { klass.new({ "someKey" => ["testing"] }) } + + it "can iterate through the params" do + expect(record.to_h).to eql({ "some_key" => ["testing"] }) + end + end + + context "with a list node" do + subject(:record) { klass.new([{ name: "someKey" }, { name: "testing" }]) } + + it "can iterate through the params" do + expect { record.to_h }.to raise_error(TypeError) + end + end end describe ".on_missing" do diff --git a/spec/scheemer/schema_spec.rb b/spec/scheemer/schema_spec.rb index 86021a7..460a269 100644 --- a/spec/scheemer/schema_spec.rb +++ b/spec/scheemer/schema_spec.rb @@ -7,6 +7,7 @@ subject(:schema) do described_class.new do required(:test) + optional(:id).filled(:uuid_v7) end end @@ -23,5 +24,27 @@ .to raise_error(Scheemer::InvalidSchemaError) end end + + context "when using the UUID v7 custom type" do + subject(:schema) do + described_class.new do + required(:id).filled(:uuid_v7) + end + end + + context "with a valid value" do + it do + expect { schema.validate!({ id: "0196d94e-dde8-74d3-a42e-ee38fa6442a8" }) } + .not_to raise_error + end + end + + context "with an invalid value" do + it do + expect { schema.validate!({ id: "asd" }) } + .to raise_error(Scheemer::InvalidSchemaError) + end + end + end end end diff --git a/spec/scheemer_spec.rb b/spec/scheemer_spec.rb index ec30500..903c692 100644 --- a/spec/scheemer_spec.rb +++ b/spec/scheemer_spec.rb @@ -59,4 +59,62 @@ it { expect { klass.new({}) }.to raise_error(NotImplementedError) } end end + + describe "#each" do + context "with a flat hash" do + let(:klass) do + Class.new do + extend Scheemer::DSL + + schema do + required(:root).hash do + required(:name).filled(:string) + end + end + end + end + + subject(:record) { klass.new({ root: { name: "testing" } }) } + + it { expect(record).to respond_to(:each) } + end + + context "with a list as the root node" do + let(:klass) do + Class.new do + extend Scheemer::DSL + + schema do + required(:root).hash do + required(:children).array(:string) + end + end + end + end + + subject(:record) { klass.new({ root: { children: ["testing"] } }) } + + it "can iterate through the params" do + expect(record.map(&:to_a)).to eql([[:children, ["testing"]]]) + end + end + + context "with a hash as the root node" do + let(:klass) do + Class.new do + extend Scheemer::DSL + + schema do + required(:children).array(:hash) + end + end + end + + subject(:record) { klass.new({ children: [{ name: "testing" }] }) } + + it "can iterate through the params" do + expect(record.first).to eql({ name: "testing" }) + end + end + end end