From 6f04e424a2033218770cc8c59d71b2658fd93385 Mon Sep 17 00:00:00 2001 From: Yury Hapanovich Date: Mon, 27 Dec 2021 13:47:20 +0300 Subject: [PATCH 1/8] Add url format validator --- .gitignore | 9 + .rspec | 3 + Gemfile | 5 + LICENSE.txt | 21 ++ README.md | 81 ++++++++ lib/validates_url_format.rb | 54 +++++ lib/validates_url_format/validator.rb | 117 +++++++++++ lib/validates_url_format/version.rb | 3 + spec/models/model.rb | 7 + spec/models/model_allow_blank.rb | 7 + spec/models/model_allow_nil.rb | 7 + spec/models/model_custom_scheme.rb | 7 + spec/models/model_no_local.rb | 7 + spec/models/model_on_create.rb | 7 + spec/models/model_public_suffix.rb | 7 + spec/spec_helper.rb | 19 ++ spec/url_format_validator_spec.rb | 216 ++++++++++++++++++++ spec/validates_url_format/validator_spec.rb | 27 +++ validates_url_format.gemspec | 27 +++ 19 files changed, 631 insertions(+) create mode 100644 .gitignore create mode 100644 .rspec create mode 100644 Gemfile create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 lib/validates_url_format.rb create mode 100644 lib/validates_url_format/validator.rb create mode 100644 lib/validates_url_format/version.rb create mode 100644 spec/models/model.rb create mode 100644 spec/models/model_allow_blank.rb create mode 100644 spec/models/model_allow_nil.rb create mode 100644 spec/models/model_custom_scheme.rb create mode 100644 spec/models/model_no_local.rb create mode 100644 spec/models/model_on_create.rb create mode 100644 spec/models/model_public_suffix.rb create mode 100644 spec/spec_helper.rb create mode 100644 spec/url_format_validator_spec.rb create mode 100644 spec/validates_url_format/validator_spec.rb create mode 100644 validates_url_format.gemspec diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ea5798 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ +Gemfile.lock diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..34c5164 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..39ea1cc --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +source "https://rubygems.org" + +gemspec + +gem "rspec", "~> 3.0" diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..ef69dd9 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2021 Yury Hapanovich + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e51aa82 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# ValidatesUrlFormat + +This gem helps to validate URLs using ActiveModel. + +## Installation + +Add this to your `Gemfile`: + +```ruby +gem 'validates_url_format' +``` +And then execute: + +```sh +bundle install +``` + +Or install it yourself: + +```sh +gem install validates_url_format +``` + +## Usage + +```ruby +# options without :messages +ValidatesUrlFormat::Validator.new.valid?(url, options) + +# returns array [is_valid(true or false), message symbol] +# message symbols: :valid_url, :invalid_url, :nil_or_blank_url, :invalid_scheme, +# :invalid_userinfo, #local_url, #space_symbol, :public_suffix +``` + +### With ActiveRecord +```ruby +class Model < ActiveRecord::Base + attr_accessor :url, :second_url + + validates_url_format_of :url, allow_blank: true + validates :second_url, url_format: { allow_blank: true } +end +``` + +### With ActiveModel + +```ruby +class Model + include ActiveModel::Validations + + attr_accessor :url + + validates_url_format_of :url, allow_blank: true +end +``` + +Configuration options: +- :messages - A custom error messages hash. Default is: + DEFAULT_MESSAGES = { + valid_url: 'is a valid URL', + invalid_url: 'is not a valid URL', + nil_or_blank_url: 'is nil or blank URL', + invalid_scheme: 'a URL has invalid scheme', + invalid_userinfo: 'a URL has invalid user info', + local_url: 'is a local URL', + space_symbol: 'a URL has space symbol', + public_suffix: 'a URL is invalid by public suffix' + } +- :allow_nil - If set to true, skips this validation if the attribute is nil (default is false). +- :allow_blank - If set to true, skips this validation if the attribute is blank (default is false). +- :schemes - Array of URI schemes to validate against. (default is ['http', 'https']) +- :public_suffix - If set to true, validates domain name by public suffix. (default is false) +- :no_local - If set to true, filtrates local adresses. (default is false) + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/validates_url_format. + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/lib/validates_url_format.rb b/lib/validates_url_format.rb new file mode 100644 index 0000000..5f232b0 --- /dev/null +++ b/lib/validates_url_format.rb @@ -0,0 +1,54 @@ +require 'active_model' +require 'validates_url_format/validator' + +module ActiveModel + module Validations + class UrlFormatValidator < ActiveModel::EachValidator + DEFAULT_MESSAGES = { + valid_url: 'is a valid URL', + invalid_url: 'is not a valid URL', + nil_or_blank_url: 'is nil or blank URL', + invalid_scheme: 'a URL has invalid scheme', + invalid_userinfo: 'a URL has invalid user info', + local_url: 'is a local URL', + space_symbol: 'a URL has space symbol', + public_suffix: 'a URL is invalid by public suffix' + } + DEFAULT_SCHEMES = %w(http https) + + def initialize(options) + options.reverse_merge!(messages: DEFAULT_MESSAGES, no_local: false, public_suffix: false) + + super(options) + end + + def validate_each(record, attribute, value) + return record.errors.add(attribute, options.dig(:messages, :valid), value: value) unless value.is_a?(String) + + is_valid, message = ValidatesUrlFormat::Validator.new.valid?(value, options) + record.errors.add(attribute, options.dig(:messages, message), value: value) unless is_valid + end + end + + module ClassMethods + # Validates whether the value of the specified attribute is valid url. + # + # class Model + # include ActiveModel::Validations + # validates_url_format_of :homepage, allow_blank: true, schemes: ['ftp'] + # end + # + # Configuration options: + # :messages - A custom error messages (default is: 'is not a valid URL'). + # :allow_nil - If set to true, skips this validation if the attribute is nil (default is false). + # :allow_blank - If set to true, skips this validation if the attribute is blank (default is false). + # :schemes - Array of URI schemes to validate against. (default is ['http', 'https']) + # :public_suffix - If set to true, validates domain name by public suffix. (default is false) + # :no_local - If set to true, filtrates local adresses. (default is false) + + def validates_url_format_of(*attr_names) + validates_with UrlFormatValidator, _merge_attributes(attr_names) + end + end + end +end diff --git a/lib/validates_url_format/validator.rb b/lib/validates_url_format/validator.rb new file mode 100644 index 0000000..c562d61 --- /dev/null +++ b/lib/validates_url_format/validator.rb @@ -0,0 +1,117 @@ +require 'public_suffix' +require 'ipaddr' + +module ValidatesUrlFormat + class Validator + IPv4_PART = /\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]/ # 0-255 + IPv4_REGEXP = %r{\A(#{IPv4_PART}(\.#{IPv4_PART}){3})\z} + IPv6_REGEXP = %r{ + ( + ([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}| # 1:2:3:4:5:6:7:8 + ([0-9a-fA-F]{1,4}:){1,7}:| # 1:: 1:2:3:4:5:6:7:: + ([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}| # 1::8 1:2:3:4:5:6::8 1:2:3:4:5:6::8 + ([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}| # 1::7:8 1:2:3:4:5::7:8 1:2:3:4:5::8 + ([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}| # 1::6:7:8 1:2:3:4::6:7:8 1:2:3:4::8 + ([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}| # 1::5:6:7:8 1:2:3::5:6:7:8 1:2:3::8 + ([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}| # 1::4:5:6:7:8 1:2::4:5:6:7:8 1:2::8 + [0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})| # 1::3:4:5:6:7:8 1::3:4:5:6:7:8 1::8 + :((:[0-9a-fA-F]{1,4}){1,7}|:)| # ::2:3:4:5:6:7:8 ::2:3:4:5:6:7:8 ::8 :: + fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}| # fe80::7:8%eth0 fe80::7:8%1 (link-local IPv6 addresses with zone index) + ::(ffff(:0{1,4}){0,1}:){0,1} + ((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3} + (25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])| # ::255.255.255.255 ::ffff:255.255.255.255 ::ffff:0:255.255.255.255 (IPv4-mapped IPv6 addresses and IPv4-translated addresses) + ([0-9a-fA-F]{1,4}:){1,4}: + ((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3} + (25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]) # 2001:db8:3:4::192.0.2.33 64:ff9b::192.0.2.33 (IPv4-Embedded IPv6 Address) + ) + }x + ACCEPTED_SCRIPTS = '\p{Common}\p{Latin}\p{Cyrillic}\p{Arabic}\p{Georgian}' + # A TLD's maximum length is 63 characters. https://developer.mozilla.org/en-US/docs/Learn/Common_questions/What_is_a_domain_name + DOMAINNAME_REGEXP = %r{ + \A(xn--)?[#{ACCEPTED_SCRIPTS}_]+([-._][#{ACCEPTED_SCRIPTS}]+)*\.[^\d&&[#{ACCEPTED_SCRIPTS}]]{2,63}\.?\z + }x + USERINFO_REGEXP = %r{\A[^:&&[#{ACCEPTED_SCRIPTS}]]+:?[#{ACCEPTED_SCRIPTS}]*\z} + + LOCAL_TOP_DOMAINS = %W(local localhost intranet internet internal private corp home lan) + + DEFAULT_SCHEMES = %w(http https) + + def valid?(value, options) + @options = options + schemes = (options[:schemes] || DEFAULT_SCHEMES).map(&:to_s) + + return [false, :nil_or_blank_url] if not_allowed_nil_or_blank?(value) + return [true, :valid_url] if value.nil? || value.blank? + + validate_url(value, schemes) + end + + private + + def validate_url(value, schemes) + encoded_value = URI.encode(value) + uri = URI.parse(encoded_value) + host = uri && uri.host && URI.decode(uri.host) + scheme = uri && uri.scheme&.downcase + + return [false, :invalid_scheme] unless host && scheme && schemes.include?(scheme) + return [false, :invalid_userinfo] unless uri.userinfo.nil? || uri.userinfo.match?(USERINFO_REGEXP) + + case host + when IPv6_REGEXP + # TODO: Add IPv6 local addresses filtration + [true, :valid_url] + when IPv4_REGEXP + return [false, :local_url] if filter_local? && ipv4_local_address?(host) + + [true, :valid_url] + when DOMAINNAME_REGEXP + return [false, :space_symbol] if value.include?(' ') + return [false, :local_url] if filter_local? && domainname_local_address?(host) + return [false, :public_suffix] if check_by_publicsuffix? && !PublicSuffix.valid?(host, :default_rule => nil) + + [true, :valid_url] + else + [false, :invalid_url] + end + rescue URI::InvalidURIError + [false, :invalid_url] + end + + def not_allowed_nil_or_blank?(value) + (value.nil? && !@options[:allow_nil]) || + (value.blank? && !@options[:allow_blank]) + end + + def filter_local? + @options[:no_local] + end + + def check_by_publicsuffix? + @options[:public_suffix] + end + + def ipv4_local_address?(value) + ip = IPAddr.new(value) + # 127.0.0.0 - 127.255.255.255 loopback + # 10.0.0.0 - 10.255.255.255 private + # 192.168.0.0 - 192.168.255.255 private + # 172.16.0.0 - 172.31.255.255 private + # 169.254.0.0 - 169.254.255.255 link-local + return true if ip.loopback? || ip.private? || ip.link_local? + return true if ip == '0.0.0.0' # unknown or non-applicable target + return true if ip == '255.255.255.255' # local broadcast + + false + end + + def domainname_local_address?(value) + return true unless value.include?('.') + + top_level_domain = value.split('.').last + return true if LOCAL_TOP_DOMAINS.include?(top_level_domain) + + false + end + end +end diff --git a/lib/validates_url_format/version.rb b/lib/validates_url_format/version.rb new file mode 100644 index 0000000..bf9858c --- /dev/null +++ b/lib/validates_url_format/version.rb @@ -0,0 +1,3 @@ +module ValidatesUrlFormat + VERSION = '0.0.1' +end \ No newline at end of file diff --git a/spec/models/model.rb b/spec/models/model.rb new file mode 100644 index 0000000..7b1e2de --- /dev/null +++ b/spec/models/model.rb @@ -0,0 +1,7 @@ +class Model + include ActiveModel::Validations + + attr_accessor :url + + validates_url_format_of :url +end diff --git a/spec/models/model_allow_blank.rb b/spec/models/model_allow_blank.rb new file mode 100644 index 0000000..8e0b680 --- /dev/null +++ b/spec/models/model_allow_blank.rb @@ -0,0 +1,7 @@ +class ModelAllowBlank + include ActiveModel::Validations + + attr_accessor :url + + validates_url_format_of :url, allow_blank: true +end diff --git a/spec/models/model_allow_nil.rb b/spec/models/model_allow_nil.rb new file mode 100644 index 0000000..cdc3ed6 --- /dev/null +++ b/spec/models/model_allow_nil.rb @@ -0,0 +1,7 @@ +class ModelAllowNil + include ActiveModel::Validations + + attr_accessor :url + + validates_url_format_of :url, allow_nil: true +end diff --git a/spec/models/model_custom_scheme.rb b/spec/models/model_custom_scheme.rb new file mode 100644 index 0000000..7dc983d --- /dev/null +++ b/spec/models/model_custom_scheme.rb @@ -0,0 +1,7 @@ +class ModelCustomScheme + include ActiveModel::Validations + + attr_accessor :url + + validates_url_format_of :url, schemes: ['ftp'] +end diff --git a/spec/models/model_no_local.rb b/spec/models/model_no_local.rb new file mode 100644 index 0000000..e164f88 --- /dev/null +++ b/spec/models/model_no_local.rb @@ -0,0 +1,7 @@ +class ModelNoLocal + include ActiveModel::Validations + + attr_accessor :url + + validates_url_format_of :url, no_local: true +end diff --git a/spec/models/model_on_create.rb b/spec/models/model_on_create.rb new file mode 100644 index 0000000..ff5b6f9 --- /dev/null +++ b/spec/models/model_on_create.rb @@ -0,0 +1,7 @@ +class ModelOnCreate + include ActiveModel::Validations + + attr_accessor :url + + validates_url_format_of :url, on: :create +end diff --git a/spec/models/model_public_suffix.rb b/spec/models/model_public_suffix.rb new file mode 100644 index 0000000..6c55b45 --- /dev/null +++ b/spec/models/model_public_suffix.rb @@ -0,0 +1,7 @@ +class ModelPublicSuffix + include ActiveModel::Validations + + attr_accessor :url + + validates_url_format_of :url, public_suffix: true +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..2a482d0 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,19 @@ +require 'rspec' +require 'active_record' +require 'validates_url_format' + +ActiveRecord::Migration.verbose = false +ActiveRecord::Base.establish_connection( + 'adapter' => 'sqlite3', + 'database' => ':memory:' +) + +autoload :Model, 'models/model' +autoload :ModelNoLocal, 'models/model_no_local' +autoload :ModelAllowBlank, 'models/model_allow_blank' +autoload :ModelAllowNil, 'models/model_allow_nil' +autoload :ModelCustomScheme, 'models/model_custom_scheme' +autoload :ModelPublicSuffix, 'models/model_public_suffix' +autoload :ModelOnCreate, 'models/model_on_create' + +RSpec.configure(&:disable_monkey_patching!) diff --git a/spec/url_format_validator_spec.rb b/spec/url_format_validator_spec.rb new file mode 100644 index 0000000..5f78136 --- /dev/null +++ b/spec/url_format_validator_spec.rb @@ -0,0 +1,216 @@ +RSpec.describe 'URL format validation using Active Record' do + before(:all) do + ActiveRecord::Schema.define(version: 1) do + create_table :models, force: true do |table| + table.column :url, :string + end + end + end + + after(:all) do + ActiveRecord::Base.connection.drop_table(:models) + end + + let!(:model) { Model.new } + + [ + 'http://example.com', + 'https://example.com', + 'http://d124.example.com', + 'http://333.example.com', + 'http://example345.com', + 'http://example.com/', + 'http://www.example.com/', + 'http://sub.domain.example.com/', + 'http://bbc.co.uk', + 'http://example.com?foo', + 'http://example.com?url=http://example.com', + 'http://example.com:8000', + 'http://www.sub.example.com/page.html?foo=bar&baz=%23#anchor', + 'http://example.com/~user', + 'http://example.xy', + 'http://example.museum', + 'http://1.0.255.249', + 'http://1.2.3.4:80', + 'HttP://example.com', + 'https://example.com', + 'http://xn--rksmrgs-5wao1o.nu', # Punycode + 'http://example.com.', # Explicit TLD root period + 'http://example.com./foo', + 'http://example.cancerresearch', + 'http://example.solutions', + 'http://_test.example.com', + 'http://test.exa_mple.com', + 'http://кириллица.рф', + 'http://тест.бел', + 'http://test.қаз', + 'http://example.გე', + 'http://foo_bar.com', + 'http://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]', + 'http://[2001:DB8::1]', + 'http://[::ffff:c000:0280]', + 'http://1k.by', + 'http://11.22.com', + 'http://1.1.1.com', + 'http://2.2.2.2.com', + 'http://user:pass@example.com', + 'http://user:@example.com', + 'http://u:u:u@example.com', # password has : inside + 'http://u@example.com' # userinfo contains only username + ].each do |url| + it "allows url #{url}" do + model.url = url + expect(model).to be_valid + end + end + + [ + nil, 1, "", " ", "url", + "www.example.com", # without scheme + 'http://', # only a scheme + 'http:/', # without a host + "http://ex ample.com", # space in the hostname + "http://example.com/foo bar", + "http://example.com/some/? doodads=ok", # space in the querystring + 'http://256.0.0.1', # wrong number in ip + 'http://r?ksmorgas.com', # wrong symbol in the hostname + 'ftp://localhost', # wrong scheme + "http://example", # without top level domain + "http://example.c", # too short TLD length + 'http://example.toolongtlddddddddddddddddddddddddddddddddddddddddddddddddddddddd', # A TLD length is 64 characters + ["https://foo.com", "https://bar.com"], # an array of urls + 'http://[2001:0db8:85a3:0000:0000:8a2e:7334]' # 7 blocks in ipv6 address + ].each do |url| + it "does not allow url #{url}" do + model.url = url + expect(model).not_to be_valid + end + end + + it 'returns a default error message' do + model.url = 'http://invalid' + model.valid? + expect(model.errors[:url]).to eq(['is not a valid URL']) + end + + context 'with no_local: true' do + let!(:model) { ModelNoLocal.new } + + [ + 'http://127.1.1.1', + 'http://10.1.1.1', + 'http://172.20.1.1', + 'http://192.168.1.1', + 'http://0.0.0.0', + 'http://255.255.255.255', + 'http://169.254.0.0', + 'http://example.local', + 'http://example.test.localhost', + 'http://example.intranet', + 'http://example.internal', + 'http://example.corp', + 'http://example.home', + 'http://example.lan', + 'http://example.private', + 'http://localhost' + ].each do |url| + it "does not allow local url #{url}" do + model.url = url + expect(model).not_to be_valid + end + end + end + + context 'with allow_nil: true' do + let!(:model) { ModelAllowNil.new } + + it 'allows nil url' do + model.url = nil + expect(model).to be_valid + end + + it 'does not allow blank url' do + model.url = '' + expect(model).not_to be_valid + end + end + + context 'with allow_blank: true' do + let!(:model) { ModelAllowBlank.new } + + it 'allows blank url' do + model.url = '' + expect(model).to be_valid + end + + it 'allows nil url' do + model.url = nil + # require 'pry-byebug'; binding.pry + expect(model).to be_valid + end + end + + context 'with custom schemes' do + let!(:model) { ModelCustomScheme.new } + let(:url) { 'ftp://example.com' } + + it 'allows url with custom scheme' do + model.url = url + expect(model).to be_valid + end + end + + context 'with public_suffix: true' do + let!(:model) { ModelPublicSuffix.new } + + [ + 'http://example.com', + 'http://d124.example.com', + 'http://333.example.com', + 'http://example345.com', + 'http://www.example.com/', + 'http://sub.domain.example.com/', + 'http://bbc.co.uk', + 'http://www.sub.example.com', + 'http://example.museum', + 'http://xn--rksmrgs-5wao1o.nu', # Punycode + 'http://example.com.', # Explicit TLD root period + 'http://example.cancerresearch', + 'http://example.solutions', + 'http://_test.example.com', + 'http://test.exa_mple.com', + 'http://кириллица.рф', + 'http://тест.бел', + 'http://test.қаз', + 'http://example.გე', + 'http://foo_bar.com', + 'http://1k.by', + 'http://11.22.com', + 'http://1.1.1.com', + 'http://2.2.2.2.com' + ].each do |url| + it "allows url #{url}" do + model.url = url + expect(model).to be_valid + end + end + + context 'when private domain' do + let(:url) { 'http://blogspot.com' } + + it 'does not allow' do + model.url = url + expect(model).not_to be_valid + end + end + + context 'when url with not listed TLD' do + let(:url) { 'http://example.tldnotlisted' } + + it 'does not allow' do + model.url = url + expect(model).not_to be_valid + end + end + end +end diff --git a/spec/validates_url_format/validator_spec.rb b/spec/validates_url_format/validator_spec.rb new file mode 100644 index 0000000..0891845 --- /dev/null +++ b/spec/validates_url_format/validator_spec.rb @@ -0,0 +1,27 @@ +RSpec.describe 'URL format validation' do + let(:url) { 'http://example.com' } + let(:options) { {} } + + subject { ValidatesUrlFormat::Validator.new.valid?(url, options) } + + it 'returns success is_valid status and valid message' do + expect(subject).to eq([true, :valid_url]) + end + + context 'when wrong url' do + let(:url) { 'ftp://example.com' } + + it 'returns failed is_valid status and error message' do + expect(subject).to eq([false, :invalid_scheme]) + end + end + + context 'when options passed' do + let(:url) { 'ftp://example.com' } + let(:options) { { schemes: ['ftp'] } } + + it 'validates considering options' do + expect(subject).to eq([true, :valid_url]) + end + end +end diff --git a/validates_url_format.gemspec b/validates_url_format.gemspec new file mode 100644 index 0000000..ebf39b4 --- /dev/null +++ b/validates_url_format.gemspec @@ -0,0 +1,27 @@ +require_relative 'lib/validates_url_format/version' + +Gem::Specification.new do |spec| + spec.name = 'validates_url_format' + spec.version = ValidatesUrlFormat::VERSION + spec.authors = ['Yury Hapanovich', 'EComCharge'] + spec.email = ['yury.gapanovich@ecomcharge.com'] + + spec.summary = 'Library for validating urls using ActiveModel.' + spec.description = 'Library for validating urls using ActiveModel.' + spec.homepage = 'https://github.com/uragap/validates_url_format' + spec.license = 'MIT' + spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0') + + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = 'https://github.com/uragap/validates_url_format' + spec.metadata['changelog_uri'] = 'https://github.com/uragap/validates_url_format' + + spec.files = `git ls-files`.split("\n") + spec.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") + spec.require_paths = ['lib'] + + spec.add_dependency 'activerecord', '>= 3.2' + spec.add_dependency 'public_suffix' + spec.add_development_dependency 'sqlite3' + spec.add_development_dependency 'pry-byebug' +end From 65883557671f04a8d8638cb141be615c05ba1da2 Mon Sep 17 00:00:00 2001 From: Yury Hapanovich Date: Tue, 18 Jan 2022 15:37:36 +0300 Subject: [PATCH 2/8] Refactor url validator --- README.md | 24 ++++++++++++-------- lib/validates_url_format.rb | 6 ++--- lib/validates_url_format/validator.rb | 25 +++++++++------------ lib/validates_url_format/version.rb | 2 +- spec/validates_url_format/validator_spec.rb | 2 +- 5 files changed, 31 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index e51aa82..5875c07 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,6 @@ gem install validates_url_format ## Usage -```ruby -# options without :messages -ValidatesUrlFormat::Validator.new.valid?(url, options) - -# returns array [is_valid(true or false), message symbol] -# message symbols: :valid_url, :invalid_url, :nil_or_blank_url, :invalid_scheme, -# :invalid_userinfo, #local_url, #space_symbol, :public_suffix -``` - ### With ActiveRecord ```ruby class Model < ActiveRecord::Base @@ -72,6 +63,21 @@ Configuration options: - :public_suffix - If set to true, validates domain name by public suffix. (default is false) - :no_local - If set to true, filtrates local adresses. (default is false) +### Plain Ruby + +```ruby +ValidatesUrlFormat::Validator.new(options).validate(value) +``` +Returns array [is_valid(true or false), message symbol] +Message symbols: :valid_url, :invalid_url, :nil_or_blank_url, :invalid_scheme, + :invalid_userinfo, #local_url, #space_symbol, :public_suffix +Options: +- :allow_nil - If set to true, skips this validation if the attribute is nil (default is false). +- :allow_blank - If set to true, skips this validation if the attribute is blank (default is false). +- :schemes - Array of URI schemes to validate against. (default is ['http', 'https']) +- :public_suffix - If set to true, validates domain name by public suffix. (default is false) +- :no_local - If set to true, filtrates local adresses. (default is false) + ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/validates_url_format. diff --git a/lib/validates_url_format.rb b/lib/validates_url_format.rb index 5f232b0..8de2e53 100644 --- a/lib/validates_url_format.rb +++ b/lib/validates_url_format.rb @@ -23,9 +23,9 @@ def initialize(options) end def validate_each(record, attribute, value) - return record.errors.add(attribute, options.dig(:messages, :valid), value: value) unless value.is_a?(String) + return record.errors.add(attribute, options.dig(:messages, :invalid_url), value: value) if value.blank? || !value.is_a?(String) - is_valid, message = ValidatesUrlFormat::Validator.new.valid?(value, options) + is_valid, message = ValidatesUrlFormat::Validator.new(options).validate(value) record.errors.add(attribute, options.dig(:messages, message), value: value) unless is_valid end end @@ -39,7 +39,7 @@ module ClassMethods # end # # Configuration options: - # :messages - A custom error messages (default is: 'is not a valid URL'). + # :messages - A custom error messages (default is DEFAULT_MESSAGES). # :allow_nil - If set to true, skips this validation if the attribute is nil (default is false). # :allow_blank - If set to true, skips this validation if the attribute is blank (default is false). # :schemes - Array of URI schemes to validate against. (default is ['http', 'https']) diff --git a/lib/validates_url_format/validator.rb b/lib/validates_url_format/validator.rb index c562d61..cf0f23a 100644 --- a/lib/validates_url_format/validator.rb +++ b/lib/validates_url_format/validator.rb @@ -36,19 +36,14 @@ class Validator DEFAULT_SCHEMES = %w(http https) - def valid?(value, options) - @options = options - schemes = (options[:schemes] || DEFAULT_SCHEMES).map(&:to_s) - - return [false, :nil_or_blank_url] if not_allowed_nil_or_blank?(value) - return [true, :valid_url] if value.nil? || value.blank? + attr_accessor :options - validate_url(value, schemes) + def initialize(options) + @options = options end - private - - def validate_url(value, schemes) + def validate(value) + schemes = (options[:schemes] || DEFAULT_SCHEMES).map(&:to_s) encoded_value = URI.encode(value) uri = URI.parse(encoded_value) host = uri && uri.host && URI.decode(uri.host) @@ -78,17 +73,19 @@ def validate_url(value, schemes) [false, :invalid_url] end + private + def not_allowed_nil_or_blank?(value) - (value.nil? && !@options[:allow_nil]) || - (value.blank? && !@options[:allow_blank]) + (value.nil? && !options[:allow_nil]) || + (value.blank? && !options[:allow_blank]) end def filter_local? - @options[:no_local] + options[:no_local] end def check_by_publicsuffix? - @options[:public_suffix] + options[:public_suffix] end def ipv4_local_address?(value) diff --git a/lib/validates_url_format/version.rb b/lib/validates_url_format/version.rb index bf9858c..1449b6c 100644 --- a/lib/validates_url_format/version.rb +++ b/lib/validates_url_format/version.rb @@ -1,3 +1,3 @@ module ValidatesUrlFormat VERSION = '0.0.1' -end \ No newline at end of file +end diff --git a/spec/validates_url_format/validator_spec.rb b/spec/validates_url_format/validator_spec.rb index 0891845..50da94e 100644 --- a/spec/validates_url_format/validator_spec.rb +++ b/spec/validates_url_format/validator_spec.rb @@ -2,7 +2,7 @@ let(:url) { 'http://example.com' } let(:options) { {} } - subject { ValidatesUrlFormat::Validator.new.valid?(url, options) } + subject { ValidatesUrlFormat::Validator.new(options).validate(url) } it 'returns success is_valid status and valid message' do expect(subject).to eq([true, :valid_url]) From 4249987c75814fd59809002eb957b4eddcfbac50 Mon Sep 17 00:00:00 2001 From: Yury Hapanovich Date: Thu, 20 Jan 2022 11:29:28 +0300 Subject: [PATCH 3/8] Improve validation result readability --- README.md | 6 ++--- lib/validates_url_format.rb | 7 +++-- lib/validates_url_format/validator.rb | 30 ++++++++++++--------- spec/validates_url_format/validator_spec.rb | 14 +++++++--- 4 files changed, 34 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 5875c07..70f6bd0 100644 --- a/README.md +++ b/README.md @@ -68,12 +68,10 @@ Configuration options: ```ruby ValidatesUrlFormat::Validator.new(options).validate(value) ``` -Returns array [is_valid(true or false), message symbol] +Returns hash { is_valid: (true or false), message: message_symbol } Message symbols: :valid_url, :invalid_url, :nil_or_blank_url, :invalid_scheme, - :invalid_userinfo, #local_url, #space_symbol, :public_suffix + :invalid_userinfo, :local_url, :space_symbol, :public_suffix Options: -- :allow_nil - If set to true, skips this validation if the attribute is nil (default is false). -- :allow_blank - If set to true, skips this validation if the attribute is blank (default is false). - :schemes - Array of URI schemes to validate against. (default is ['http', 'https']) - :public_suffix - If set to true, validates domain name by public suffix. (default is false) - :no_local - If set to true, filtrates local adresses. (default is false) diff --git a/lib/validates_url_format.rb b/lib/validates_url_format.rb index 8de2e53..f442093 100644 --- a/lib/validates_url_format.rb +++ b/lib/validates_url_format.rb @@ -23,10 +23,9 @@ def initialize(options) end def validate_each(record, attribute, value) - return record.errors.add(attribute, options.dig(:messages, :invalid_url), value: value) if value.blank? || !value.is_a?(String) - - is_valid, message = ValidatesUrlFormat::Validator.new(options).validate(value) - record.errors.add(attribute, options.dig(:messages, message), value: value) unless is_valid + return record.errors.add(attribute, options.dig(:messages, :invalid_url), value: value) unless value.is_a?(String) + validation_result = ValidatesUrlFormat::Validator.new(options).validate(value) + record.errors.add(attribute, options.dig(:messages, validation_result[:message]), value: value) unless validation_result[:is_valid] end end diff --git a/lib/validates_url_format/validator.rb b/lib/validates_url_format/validator.rb index cf0f23a..4cf3261 100644 --- a/lib/validates_url_format/validator.rb +++ b/lib/validates_url_format/validator.rb @@ -38,43 +38,49 @@ class Validator attr_accessor :options - def initialize(options) + def initialize(options = {}) @options = options end def validate(value) + return result(false, :nil_or_blank_url) if value.blank? + schemes = (options[:schemes] || DEFAULT_SCHEMES).map(&:to_s) encoded_value = URI.encode(value) uri = URI.parse(encoded_value) host = uri && uri.host && URI.decode(uri.host) scheme = uri && uri.scheme&.downcase - return [false, :invalid_scheme] unless host && scheme && schemes.include?(scheme) - return [false, :invalid_userinfo] unless uri.userinfo.nil? || uri.userinfo.match?(USERINFO_REGEXP) + return result(false, :invalid_scheme) unless host && scheme && schemes.include?(scheme) + return result(false, :invalid_userinfo) unless uri.userinfo.nil? || uri.userinfo.match?(USERINFO_REGEXP) case host when IPv6_REGEXP # TODO: Add IPv6 local addresses filtration - [true, :valid_url] + result(true, :valid_url) when IPv4_REGEXP - return [false, :local_url] if filter_local? && ipv4_local_address?(host) + return result(false, :local_url) if filter_local? && ipv4_local_address?(host) - [true, :valid_url] + result(true, :valid_url) when DOMAINNAME_REGEXP - return [false, :space_symbol] if value.include?(' ') - return [false, :local_url] if filter_local? && domainname_local_address?(host) - return [false, :public_suffix] if check_by_publicsuffix? && !PublicSuffix.valid?(host, :default_rule => nil) + return result(false, :space_symbol) if value.include?(' ') + return result(false, :local_url) if filter_local? && domainname_local_address?(host) + return result(false, :public_suffix) if check_by_publicsuffix? && !PublicSuffix.valid?(host, :default_rule => nil) - [true, :valid_url] + result(true, :valid_url) else - [false, :invalid_url] + result(false, :invalid_url) end rescue URI::InvalidURIError - [false, :invalid_url] + result(false, :invalid_url) end private + def result(is_valid, message) + { is_valid: is_valid, message: message } + end + def not_allowed_nil_or_blank?(value) (value.nil? && !options[:allow_nil]) || (value.blank? && !options[:allow_blank]) diff --git a/spec/validates_url_format/validator_spec.rb b/spec/validates_url_format/validator_spec.rb index 50da94e..12de03c 100644 --- a/spec/validates_url_format/validator_spec.rb +++ b/spec/validates_url_format/validator_spec.rb @@ -5,14 +5,14 @@ subject { ValidatesUrlFormat::Validator.new(options).validate(url) } it 'returns success is_valid status and valid message' do - expect(subject).to eq([true, :valid_url]) + expect(subject).to eq({ is_valid: true, message: :valid_url }) end context 'when wrong url' do let(:url) { 'ftp://example.com' } it 'returns failed is_valid status and error message' do - expect(subject).to eq([false, :invalid_scheme]) + expect(subject).to eq({ is_valid: false, message: :invalid_scheme }) end end @@ -21,7 +21,15 @@ let(:options) { { schemes: ['ftp'] } } it 'validates considering options' do - expect(subject).to eq([true, :valid_url]) + expect(subject).to eq({ is_valid: true, message: :valid_url }) + end + end + + context 'nil value' do + let(:url) { nil } + + it 'returns failed is_valid status and error message' do + expect(subject).to eq({ is_valid: false, message: :nil_or_blank_url }) end end end From 1c4e3297ae53fadef995c8163e35e8c93584e8a7 Mon Sep 17 00:00:00 2001 From: Yury Hapanovich Date: Mon, 24 Jan 2022 16:02:59 +0300 Subject: [PATCH 4/8] Fix syntax --- README.md | 2 +- lib/validates_url_format.rb | 2 +- lib/validates_url_format/validator.rb | 4 ++-- spec/validates_url_format/validator_spec.rb | 14 +++++++------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 70f6bd0..36284a6 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Configuration options: ```ruby ValidatesUrlFormat::Validator.new(options).validate(value) ``` -Returns hash { is_valid: (true or false), message: message_symbol } +Returns hash { valid: (true or false), message: message_symbol } Message symbols: :valid_url, :invalid_url, :nil_or_blank_url, :invalid_scheme, :invalid_userinfo, :local_url, :space_symbol, :public_suffix Options: diff --git a/lib/validates_url_format.rb b/lib/validates_url_format.rb index f442093..9f708fa 100644 --- a/lib/validates_url_format.rb +++ b/lib/validates_url_format.rb @@ -25,7 +25,7 @@ def initialize(options) def validate_each(record, attribute, value) return record.errors.add(attribute, options.dig(:messages, :invalid_url), value: value) unless value.is_a?(String) validation_result = ValidatesUrlFormat::Validator.new(options).validate(value) - record.errors.add(attribute, options.dig(:messages, validation_result[:message]), value: value) unless validation_result[:is_valid] + record.errors.add(attribute, options.dig(:messages, validation_result[:message]), value: value) unless validation_result[:valid] end end diff --git a/lib/validates_url_format/validator.rb b/lib/validates_url_format/validator.rb index 4cf3261..8e8b8ee 100644 --- a/lib/validates_url_format/validator.rb +++ b/lib/validates_url_format/validator.rb @@ -77,8 +77,8 @@ def validate(value) private - def result(is_valid, message) - { is_valid: is_valid, message: message } + def result(valid, message) + { valid: valid, message: message } end def not_allowed_nil_or_blank?(value) diff --git a/spec/validates_url_format/validator_spec.rb b/spec/validates_url_format/validator_spec.rb index 12de03c..e5f05ee 100644 --- a/spec/validates_url_format/validator_spec.rb +++ b/spec/validates_url_format/validator_spec.rb @@ -4,15 +4,15 @@ subject { ValidatesUrlFormat::Validator.new(options).validate(url) } - it 'returns success is_valid status and valid message' do - expect(subject).to eq({ is_valid: true, message: :valid_url }) + it 'returns success valid status and valid message' do + expect(subject).to eq({ valid: true, message: :valid_url }) end context 'when wrong url' do let(:url) { 'ftp://example.com' } - it 'returns failed is_valid status and error message' do - expect(subject).to eq({ is_valid: false, message: :invalid_scheme }) + it 'returns failed valid status and error message' do + expect(subject).to eq({ valid: false, message: :invalid_scheme }) end end @@ -21,15 +21,15 @@ let(:options) { { schemes: ['ftp'] } } it 'validates considering options' do - expect(subject).to eq({ is_valid: true, message: :valid_url }) + expect(subject).to eq({ valid: true, message: :valid_url }) end end context 'nil value' do let(:url) { nil } - it 'returns failed is_valid status and error message' do - expect(subject).to eq({ is_valid: false, message: :nil_or_blank_url }) + it 'returns failed valid status and error message' do + expect(subject).to eq({ valid: false, message: :nil_or_blank_url }) end end end From 555eb3b423f6bbee65ab29c9935431a03d7ce4b0 Mon Sep 17 00:00:00 2001 From: Yury Hapanovich Date: Wed, 26 Jan 2022 15:43:27 +0300 Subject: [PATCH 5/8] Update validation process --- lib/validates_url_format/validator.rb | 8 ++++++-- spec/url_format_validator_spec.rb | 10 ++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/validates_url_format/validator.rb b/lib/validates_url_format/validator.rb index 8e8b8ee..b98a3a8 100644 --- a/lib/validates_url_format/validator.rb +++ b/lib/validates_url_format/validator.rb @@ -30,6 +30,7 @@ class Validator DOMAINNAME_REGEXP = %r{ \A(xn--)?[#{ACCEPTED_SCRIPTS}_]+([-._][#{ACCEPTED_SCRIPTS}]+)*\.[^\d&&[#{ACCEPTED_SCRIPTS}]]{2,63}\.?\z }x + ONE_LEVEL_DOMAINNAME_REGEX = %r{\A[^.&&[#{ACCEPTED_SCRIPTS}]]*\z} USERINFO_REGEXP = %r{\A[^:&&[#{ACCEPTED_SCRIPTS}]]+:?[#{ACCEPTED_SCRIPTS}]*\z} LOCAL_TOP_DOMAINS = %W(local localhost intranet internet internal private corp home lan) @@ -61,6 +62,11 @@ def validate(value) when IPv4_REGEXP return result(false, :local_url) if filter_local? && ipv4_local_address?(host) + result(true, :valid_url) + when ONE_LEVEL_DOMAINNAME_REGEX + return result(false, :invalid_url) unless domainname_local_address?(host) + return result(false, :local_url) if filter_local? + result(true, :valid_url) when DOMAINNAME_REGEXP return result(false, :space_symbol) if value.include?(' ') @@ -109,8 +115,6 @@ def ipv4_local_address?(value) end def domainname_local_address?(value) - return true unless value.include?('.') - top_level_domain = value.split('.').last return true if LOCAL_TOP_DOMAINS.include?(top_level_domain) diff --git a/spec/url_format_validator_spec.rb b/spec/url_format_validator_spec.rb index 5f78136..5cc146b 100644 --- a/spec/url_format_validator_spec.rb +++ b/spec/url_format_validator_spec.rb @@ -56,7 +56,9 @@ 'http://user:pass@example.com', 'http://user:@example.com', 'http://u:u:u@example.com', # password has : inside - 'http://u@example.com' # userinfo contains only username + 'http://u@example.com', # userinfo contains only username + 'http://localhost:3128', + 'http://ecom.com/pa^h?foo=bar' ].each do |url| it "allows url #{url}" do model.url = url @@ -67,13 +69,14 @@ [ nil, 1, "", " ", "url", "www.example.com", # without scheme + 'mailto:foo@example.org', 'http://', # only a scheme 'http:/', # without a host "http://ex ample.com", # space in the hostname "http://example.com/foo bar", "http://example.com/some/? doodads=ok", # space in the querystring 'http://256.0.0.1', # wrong number in ip - 'http://r?ksmorgas.com', # wrong symbol in the hostname + 'http://e?om.com', # wrong symbol 'ftp://localhost', # wrong scheme "http://example", # without top level domain "http://example.c", # too short TLD length @@ -112,7 +115,7 @@ 'http://example.home', 'http://example.lan', 'http://example.private', - 'http://localhost' + 'http://localhost:3008' ].each do |url| it "does not allow local url #{url}" do model.url = url @@ -145,7 +148,6 @@ it 'allows nil url' do model.url = nil - # require 'pry-byebug'; binding.pry expect(model).to be_valid end end From cf2fb8600ecc3a24d5e2c1d72166a272e1bc8ab1 Mon Sep 17 00:00:00 2001 From: Yury Hapanovich Date: Mon, 21 Feb 2022 15:17:05 +0300 Subject: [PATCH 6/8] Take out rspec gem from Gemfile --- Gemfile | 2 -- validates_url_format.gemspec | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 39ea1cc..b4e2a20 100644 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,3 @@ source "https://rubygems.org" gemspec - -gem "rspec", "~> 3.0" diff --git a/validates_url_format.gemspec b/validates_url_format.gemspec index ebf39b4..7cf7618 100644 --- a/validates_url_format.gemspec +++ b/validates_url_format.gemspec @@ -24,4 +24,5 @@ Gem::Specification.new do |spec| spec.add_dependency 'public_suffix' spec.add_development_dependency 'sqlite3' spec.add_development_dependency 'pry-byebug' + spec.add_development_dependency 'rspec', '~> 3.0' end From e6b1796efc1bdac900f4139050957f24f28d3f13 Mon Sep 17 00:00:00 2001 From: Yury Hapanovich Date: Mon, 21 Feb 2022 15:17:43 +0300 Subject: [PATCH 7/8] Fix errors messages --- lib/validates_url_format.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/validates_url_format.rb b/lib/validates_url_format.rb index 9f708fa..fbe2979 100644 --- a/lib/validates_url_format.rb +++ b/lib/validates_url_format.rb @@ -17,7 +17,8 @@ class UrlFormatValidator < ActiveModel::EachValidator DEFAULT_SCHEMES = %w(http https) def initialize(options) - options.reverse_merge!(messages: DEFAULT_MESSAGES, no_local: false, public_suffix: false) + options[:messages] = (options[:messages] || {}).reverse_merge(DEFAULT_MESSAGES) + options.reverse_merge!(no_local: false, public_suffix: false) super(options) end From a6fa445e30b6307fce1d32a3c3ddaf00a586cfdc Mon Sep 17 00:00:00 2001 From: Yury Hapanovich Date: Mon, 21 Feb 2022 15:17:56 +0300 Subject: [PATCH 8/8] Fix version --- lib/validates_url_format/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/validates_url_format/version.rb b/lib/validates_url_format/version.rb index 1449b6c..2e5cf9d 100644 --- a/lib/validates_url_format/version.rb +++ b/lib/validates_url_format/version.rb @@ -1,3 +1,3 @@ module ValidatesUrlFormat - VERSION = '0.0.1' + VERSION = '0.1.0' end