siftly-rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +150 -0
- data/Rakefile +10 -0
- data/lib/siftly/rails/attribute_check.rb +32 -0
- data/lib/siftly/rails/model.rb +148 -0
- data/lib/siftly/rails/version.rb +7 -0
- data/lib/siftly/rails.rb +11 -0
- data/siftly-rails.gemspec +29 -0
- data/test/model_test.rb +258 -0
- data/test/test_helper.rb +58 -0
- metadata +137 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5a64b92b6f28032c4a3284e1d718c1c432bc9d5537c35ec5b2762797658de24c
|
|
4
|
+
data.tar.gz: c86c1d29b33eac1d9c24a51a8b0ff29a7bf4bf2509cd9017248391f0321c80a2
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 27192b24f4b2e69b02a1dc937bcac2cbaf4f0aee6508add3e7cd06643afb440b6c786b8d982fda8baffe5b80508caac587ed7b81f935e0cf28119315c12caa66
|
|
7
|
+
data.tar.gz: 03ea60160b8b602fd5d8038ec7a951e847c0abe5bdd9b6bc53a312ab5249ae92b2109c9a4490a89d9fdb2b2e0957af822125948e0fa64aa88d09f8f4208fed34
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tomos Rees
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Siftly::Rails
|
|
2
|
+
|
|
3
|
+
`siftly-rails` wires `Siftly.check` into Active Model and Active Record validations. Use it when you want attributes checked automatically during validation and want the results attached to the model instance.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem "siftly-rails"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
require "siftly/rails"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requiring `siftly/rails` auto-includes `Siftly::Rails::Model` into all Active Record models via `ActiveSupport.on_load(:active_record)`. You still need to require whichever plugin gems provide the filters you want — `siftly-rails` only adds the model integration layer.
|
|
16
|
+
|
|
17
|
+
For plain Active Model objects that don't inherit from `ActiveRecord::Base`, include the concern manually with `include Siftly::Rails::Model`.
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
First configure the normal Siftly pipeline:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
Siftly.configure do |config|
|
|
25
|
+
config.aggregator = :score
|
|
26
|
+
config.threshold = 1.0
|
|
27
|
+
config.use :keyword_pack
|
|
28
|
+
|
|
29
|
+
config.filter :keyword_pack do |filter|
|
|
30
|
+
filter.keywords = ["spam", "buy now"]
|
|
31
|
+
filter.weight = 1.0
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then declare the attributes to check:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
class ContactRequest < ApplicationRecord
|
|
40
|
+
siftly_check :email, :message, message: "contains spam"
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
At validation time, each declared attribute is checked with:
|
|
45
|
+
|
|
46
|
+
- `value:` the attribute value
|
|
47
|
+
- `attribute:` the attribute name
|
|
48
|
+
- `record:` the model instance
|
|
49
|
+
- `context:` resolved from the check options
|
|
50
|
+
- `filter_overrides:` resolved from the check options
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
request = ContactRequest.new(email: "spam@example.com", message: "buy now")
|
|
56
|
+
|
|
57
|
+
request.valid? # => false
|
|
58
|
+
request.siftly_spam? # => true
|
|
59
|
+
request.siftly_spam_attributes # => [:email, :message]
|
|
60
|
+
request.siftly_results.keys # => [:email, :message]
|
|
61
|
+
request.siftly_spam_result_for(:message) # => #<Siftly::Result ...>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## What `siftly_check` Does
|
|
65
|
+
|
|
66
|
+
Each `siftly_check` call:
|
|
67
|
+
|
|
68
|
+
- registers one validation callback for the declared attributes
|
|
69
|
+
- runs `Siftly.check` once per attribute
|
|
70
|
+
- stores the latest result per attribute in `@siftly_results`
|
|
71
|
+
- adds an Active Model error when a result is spam
|
|
72
|
+
|
|
73
|
+
The spam-tracking state is reset in `before_validation`, so old results do not leak across revalidation.
|
|
74
|
+
|
|
75
|
+
## Supported Options
|
|
76
|
+
|
|
77
|
+
Siftly options passed through to `Siftly.check`:
|
|
78
|
+
|
|
79
|
+
- `filters`
|
|
80
|
+
- `filter_overrides`
|
|
81
|
+
- `aggregator`
|
|
82
|
+
- `threshold`
|
|
83
|
+
- `failure_mode`
|
|
84
|
+
- `instrumenter`
|
|
85
|
+
- `context`
|
|
86
|
+
|
|
87
|
+
Model-side validation behavior:
|
|
88
|
+
|
|
89
|
+
- `allow_nil`
|
|
90
|
+
- `allow_blank`
|
|
91
|
+
- `message`
|
|
92
|
+
- `strict`
|
|
93
|
+
|
|
94
|
+
Active Model callback options:
|
|
95
|
+
|
|
96
|
+
- `if`
|
|
97
|
+
- `unless`
|
|
98
|
+
- `on`
|
|
99
|
+
|
|
100
|
+
## Option Resolution
|
|
101
|
+
|
|
102
|
+
Every non-callback option can be a literal value or a callable. This is broader than just `context` and `filter_overrides`.
|
|
103
|
+
|
|
104
|
+
Callables are executed via `instance_exec` on the model instance, so `self` inside the block is the record. Arity determines which explicit arguments are passed:
|
|
105
|
+
|
|
106
|
+
- arity `0`: no arguments (access the record through `self`)
|
|
107
|
+
- arity `1`: `attribute`
|
|
108
|
+
- arity `2+`: `attribute` and `record`
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
class Lead < ApplicationRecord
|
|
114
|
+
siftly_check :message,
|
|
115
|
+
filters: -> { [:keyword_pack] },
|
|
116
|
+
filter_overrides: ->(attribute) { { keyword_pack: { keywords: [trigger_phrase], weight: 1.2 } } },
|
|
117
|
+
context: ->(attribute, record) { { source: "#{attribute}:#{record.source_name}" } },
|
|
118
|
+
allow_blank: true
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Skip Behavior
|
|
123
|
+
|
|
124
|
+
A check is skipped entirely when:
|
|
125
|
+
|
|
126
|
+
- `allow_nil` is truthy and the value is `nil`
|
|
127
|
+
- `allow_blank` is truthy and the value is blank
|
|
128
|
+
|
|
129
|
+
Skipped checks:
|
|
130
|
+
|
|
131
|
+
- do not call `Siftly.check`
|
|
132
|
+
- do not store a result for that attribute
|
|
133
|
+
- do not add errors
|
|
134
|
+
|
|
135
|
+
## Exposed Instance Helpers
|
|
136
|
+
|
|
137
|
+
Instances get:
|
|
138
|
+
|
|
139
|
+
- `siftly_spam?`
|
|
140
|
+
- `siftly_results`
|
|
141
|
+
- `siftly_result_for(attribute)`
|
|
142
|
+
- `siftly_spam_attributes`
|
|
143
|
+
- `siftly_spam_results`
|
|
144
|
+
- `siftly_spam_result_for(attribute)`
|
|
145
|
+
|
|
146
|
+
## Practical Advice
|
|
147
|
+
|
|
148
|
+
- Scope `filters:` per model when different models need different spam rules. Relying only on global configuration is how you end up checking everything with everything.
|
|
149
|
+
- Remember that `siftly-rails` does not decide what counts as spam. It just runs the configured pipeline during validation.
|
|
150
|
+
- If a filter is flaky or network-backed, set `failure_mode` deliberately. Pretending the default is fine without thinking about it is lazy.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Siftly
|
|
4
|
+
module Rails
|
|
5
|
+
class AttributeCheck
|
|
6
|
+
CALLBACK_OPTIONS = %i[if unless on].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :attribute, :options
|
|
9
|
+
|
|
10
|
+
def initialize(attribute, options = {})
|
|
11
|
+
@attribute = attribute.to_sym
|
|
12
|
+
@options = options.transform_keys(&:to_sym).freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def callback_options
|
|
16
|
+
select_options(CALLBACK_OPTIONS)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def siftly_options
|
|
20
|
+
options.reject { |key, _value| CALLBACK_OPTIONS.include?(key) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def select_options(keys)
|
|
26
|
+
options.each_with_object({}) do |(key, value), result|
|
|
27
|
+
result[key] = value if keys.include?(key)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "active_support/core_ext/class/attribute"
|
|
5
|
+
|
|
6
|
+
module Siftly
|
|
7
|
+
module Rails
|
|
8
|
+
module Model
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
DEFAULT_ERROR_MESSAGE = "was flagged as spam"
|
|
12
|
+
CHECK_OPTION_KEYS = %i[
|
|
13
|
+
filters
|
|
14
|
+
filter_overrides
|
|
15
|
+
aggregator
|
|
16
|
+
threshold
|
|
17
|
+
failure_mode
|
|
18
|
+
instrumenter
|
|
19
|
+
context
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
included do
|
|
23
|
+
class_attribute :siftly_attribute_checks, instance_accessor: false, default: []
|
|
24
|
+
before_validation :reset_siftly_spam_tracking
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class_methods do
|
|
28
|
+
def siftly_check(*attributes, **options)
|
|
29
|
+
raise ArgumentError, "siftly_check requires at least one attribute" if attributes.empty?
|
|
30
|
+
|
|
31
|
+
checks = attributes.map { |attribute| AttributeCheck.new(attribute, options) }
|
|
32
|
+
self.siftly_attribute_checks = siftly_attribute_checks + checks
|
|
33
|
+
|
|
34
|
+
validate(**checks.first.callback_options) do
|
|
35
|
+
checks.each { |check| run_siftly_check(check) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def siftly_spam?
|
|
41
|
+
siftly_spam_attributes.any?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def siftly_results
|
|
45
|
+
current_siftly_results.dup
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def siftly_result_for(attribute)
|
|
49
|
+
current_siftly_results[attribute.to_sym]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def siftly_spam_attributes
|
|
53
|
+
siftly_spam_results.keys
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def siftly_spam_results
|
|
57
|
+
current_siftly_results.each_with_object({}) do |(attribute, result), spam_results|
|
|
58
|
+
spam_results[attribute] = result if result.spam?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def siftly_spam_result_for(attribute)
|
|
63
|
+
siftly_spam_results[attribute.to_sym]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def current_siftly_results
|
|
69
|
+
@siftly_results ||= {}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def reset_siftly_spam_tracking
|
|
73
|
+
@siftly_results = {}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def run_siftly_check(check)
|
|
77
|
+
return if skip_siftly_check?(check)
|
|
78
|
+
|
|
79
|
+
result = Siftly.check(**build_siftly_arguments(check))
|
|
80
|
+
current_siftly_results[check.attribute] = result
|
|
81
|
+
|
|
82
|
+
add_siftly_error(check) if result.spam?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def skip_siftly_check?(check)
|
|
86
|
+
value = public_send(check.attribute)
|
|
87
|
+
|
|
88
|
+
return true if truthy_option?(check, :allow_nil) && value.nil?
|
|
89
|
+
return true if truthy_option?(check, :allow_blank) && blank_value?(value)
|
|
90
|
+
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_siftly_arguments(check)
|
|
95
|
+
arguments = {
|
|
96
|
+
value: public_send(check.attribute),
|
|
97
|
+
attribute: check.attribute,
|
|
98
|
+
record: self,
|
|
99
|
+
context: resolve_check_option(check, :context) || {},
|
|
100
|
+
filter_overrides: resolve_check_option(check, :filter_overrides) || {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
CHECK_OPTION_KEYS.each do |key|
|
|
104
|
+
next if %i[context filter_overrides].include?(key)
|
|
105
|
+
|
|
106
|
+
value = resolve_check_option(check, key)
|
|
107
|
+
arguments[key] = value unless value.nil?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
arguments
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def add_siftly_error(check)
|
|
114
|
+
options = {}
|
|
115
|
+
strict = resolve_check_option(check, :strict)
|
|
116
|
+
options[:strict] = strict unless strict.nil?
|
|
117
|
+
|
|
118
|
+
errors.add(check.attribute, resolve_check_option(check, :message) || DEFAULT_ERROR_MESSAGE, **options)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def truthy_option?(check, key)
|
|
122
|
+
!!resolve_check_option(check, key)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def resolve_check_option(check, key)
|
|
126
|
+
value = check.siftly_options[key]
|
|
127
|
+
return value unless value.respond_to?(:call)
|
|
128
|
+
|
|
129
|
+
case value.arity
|
|
130
|
+
when 0
|
|
131
|
+
instance_exec(&value)
|
|
132
|
+
when 1
|
|
133
|
+
instance_exec(check.attribute, &value)
|
|
134
|
+
else
|
|
135
|
+
instance_exec(check.attribute, self, &value)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def blank_value?(value)
|
|
140
|
+
return true if value.nil?
|
|
141
|
+
return value.blank? if value.respond_to?(:blank?)
|
|
142
|
+
return value.empty? if value.respond_to?(:empty?)
|
|
143
|
+
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/siftly/rails.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "siftly"
|
|
4
|
+
require "active_support/lazy_load_hooks"
|
|
5
|
+
require_relative "rails/version"
|
|
6
|
+
require_relative "rails/attribute_check"
|
|
7
|
+
require_relative "rails/model"
|
|
8
|
+
|
|
9
|
+
ActiveSupport.on_load(:active_record) do
|
|
10
|
+
include Siftly::Rails::Model
|
|
11
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/siftly/rails/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "siftly-rails"
|
|
7
|
+
spec.version = Siftly::Rails::VERSION
|
|
8
|
+
spec.authors = ["Tomos Rees"]
|
|
9
|
+
|
|
10
|
+
spec.summary = "Active Record integration for the Siftly spam filtering core."
|
|
11
|
+
spec.description = "Siftly::Rails lets Rails applications validate model attributes with Siftly and inspect flagged attributes through the model instance."
|
|
12
|
+
spec.homepage = "https://github.com/tomosjohnrees/siftly-rails"
|
|
13
|
+
spec.license = "MIT"
|
|
14
|
+
spec.required_ruby_version = ">= 3.2"
|
|
15
|
+
|
|
16
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
17
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
18
|
+
|
|
19
|
+
spec.files = Dir.glob("{lib,test}/**/*") + %w[CHANGELOG.md Gemfile LICENSE README.md Rakefile siftly-rails.gemspec]
|
|
20
|
+
spec.require_paths = ["lib"]
|
|
21
|
+
|
|
22
|
+
spec.add_dependency "activerecord", ">= 7.1"
|
|
23
|
+
spec.add_dependency "activesupport", ">= 7.1"
|
|
24
|
+
spec.add_dependency "siftly", "~> 0.1.0"
|
|
25
|
+
|
|
26
|
+
spec.add_development_dependency "minitest", "= 6.0.2"
|
|
27
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
28
|
+
spec.add_development_dependency "sqlite3", "~> 2.0"
|
|
29
|
+
end
|
data/test/model_test.rb
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class ModelTest < Minitest::Test
|
|
6
|
+
def test_flagged_attributes_are_exposed_on_the_model
|
|
7
|
+
Siftly.configure do |config|
|
|
8
|
+
config.use :test_keyword
|
|
9
|
+
|
|
10
|
+
config.filter :test_keyword do |filter|
|
|
11
|
+
filter.keywords = ["spam"]
|
|
12
|
+
filter.weight = 1.0
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
model_class = Class.new(SiftlyRailsRecord) do
|
|
17
|
+
siftly_check :email, :message, message: "contains spam"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
record = model_class.new(email: "spam@example.com", message: "this message is spam", title: "hello")
|
|
21
|
+
|
|
22
|
+
refute record.valid?
|
|
23
|
+
assert_equal [:email, :message], record.siftly_spam_attributes.sort
|
|
24
|
+
assert record.siftly_spam?
|
|
25
|
+
assert_equal ["contains spam"], record.errors[:email]
|
|
26
|
+
assert_equal ["contains spam"], record.errors[:message]
|
|
27
|
+
assert_equal [:email, :message], record.siftly_results.keys.sort
|
|
28
|
+
assert_equal [:test_keyword], record.siftly_spam_result_for(:email).matches.map(&:filter)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def test_revalidation_clears_previous_spam_state
|
|
32
|
+
Siftly.configure do |config|
|
|
33
|
+
config.use :test_keyword
|
|
34
|
+
|
|
35
|
+
config.filter :test_keyword do |filter|
|
|
36
|
+
filter.keywords = ["spam"]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
model_class = Class.new(SiftlyRailsRecord) do
|
|
41
|
+
siftly_check :message
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
record = model_class.new(message: "spam offer")
|
|
45
|
+
|
|
46
|
+
refute record.valid?
|
|
47
|
+
assert_equal [:message], record.siftly_spam_attributes
|
|
48
|
+
|
|
49
|
+
record.message = "ordinary text"
|
|
50
|
+
|
|
51
|
+
assert record.valid?
|
|
52
|
+
assert_empty record.siftly_spam_attributes
|
|
53
|
+
assert_nil record.siftly_spam_result_for(:message)
|
|
54
|
+
refute record.siftly_result_for(:message).spam?
|
|
55
|
+
assert_empty record.errors[:message]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_callable_options_can_build_context_and_filter_overrides
|
|
59
|
+
model_class = Class.new(SiftlyRailsRecord) do
|
|
60
|
+
siftly_check :message,
|
|
61
|
+
filters: [:test_keyword],
|
|
62
|
+
filter_overrides: ->(attribute) { { test_keyword: { keywords: [trigger_phrase], weight: 1.2 } } },
|
|
63
|
+
context: ->(attribute) { { source: "#{attribute}:#{source_name}" } }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
record = model_class.new(message: "limited offer today", trigger_phrase: "offer", source_name: "landing-page")
|
|
67
|
+
|
|
68
|
+
refute record.valid?
|
|
69
|
+
|
|
70
|
+
result = record.siftly_spam_result_for(:message)
|
|
71
|
+
|
|
72
|
+
assert result.spam?
|
|
73
|
+
assert_equal "message matched offer", result.reasons.first
|
|
74
|
+
assert_equal "message:landing-page", result.matches.first.metadata[:source]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def test_allow_blank_skips_checking
|
|
78
|
+
model_class = Class.new(SiftlyRailsRecord) do
|
|
79
|
+
siftly_check :message, filters: [:test_keyword], allow_blank: true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
record = model_class.new(message: "")
|
|
83
|
+
|
|
84
|
+
assert record.valid?
|
|
85
|
+
refute record.siftly_spam?
|
|
86
|
+
assert_nil record.siftly_spam_result_for(:message)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def test_allow_nil_skips_checking
|
|
90
|
+
model_class = Class.new(SiftlyRailsRecord) do
|
|
91
|
+
siftly_check :message, filters: [:test_keyword], allow_nil: true
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
record = model_class.new(message: nil)
|
|
95
|
+
|
|
96
|
+
assert record.valid?
|
|
97
|
+
refute record.siftly_spam?
|
|
98
|
+
assert_nil record.siftly_result_for(:message)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def test_allow_nil_does_not_skip_blank_strings
|
|
102
|
+
Siftly.configure do |config|
|
|
103
|
+
config.use :test_keyword
|
|
104
|
+
config.filter(:test_keyword) { |f| f.keywords = [""] }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
model_class = Class.new(SiftlyRailsRecord) do
|
|
108
|
+
siftly_check :message, filters: [:test_keyword], allow_nil: true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
record = model_class.new(message: "")
|
|
112
|
+
|
|
113
|
+
# allow_nil: true should NOT skip blank strings — only nil
|
|
114
|
+
assert_kind_of Siftly::Result, record.siftly_result_for(:message) unless record.valid?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def test_clean_submission_passes_validation
|
|
118
|
+
Siftly.configure do |config|
|
|
119
|
+
config.use :test_keyword
|
|
120
|
+
config.filter(:test_keyword) { |f| f.keywords = ["spam"] }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
model_class = Class.new(SiftlyRailsRecord) do
|
|
124
|
+
siftly_check :message
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
record = model_class.new(message: "perfectly normal text")
|
|
128
|
+
|
|
129
|
+
assert record.valid?
|
|
130
|
+
refute record.siftly_spam?
|
|
131
|
+
assert_empty record.siftly_spam_attributes
|
|
132
|
+
refute record.siftly_result_for(:message).spam?
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def test_strict_option_raises_on_spam
|
|
136
|
+
Siftly.configure do |config|
|
|
137
|
+
config.use :test_keyword
|
|
138
|
+
config.filter(:test_keyword) { |f| f.keywords = ["spam"]; f.weight = 1.0 }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
model_class = Class.new(SiftlyRailsRecord) do
|
|
142
|
+
def self.name = "StrictSubmission"
|
|
143
|
+
siftly_check :message, strict: true
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
record = model_class.new(message: "this is spam")
|
|
147
|
+
|
|
148
|
+
assert_raises(ActiveModel::StrictValidationFailed) { record.valid? }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def test_if_condition_prevents_check_when_falsy
|
|
152
|
+
Siftly.configure do |config|
|
|
153
|
+
config.use :test_keyword
|
|
154
|
+
config.filter(:test_keyword) { |f| f.keywords = ["spam"]; f.weight = 1.0 }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
model_class = Class.new(SiftlyRailsRecord) do
|
|
158
|
+
siftly_check :message, if: -> { source_name == "trusted" }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
record = model_class.new(message: "spam offer", source_name: "untrusted")
|
|
162
|
+
assert record.valid?
|
|
163
|
+
|
|
164
|
+
record.source_name = "trusted"
|
|
165
|
+
refute record.valid?
|
|
166
|
+
assert record.siftly_spam?
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def test_unless_condition_prevents_check_when_truthy
|
|
170
|
+
Siftly.configure do |config|
|
|
171
|
+
config.use :test_keyword
|
|
172
|
+
config.filter(:test_keyword) { |f| f.keywords = ["spam"]; f.weight = 1.0 }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
model_class = Class.new(SiftlyRailsRecord) do
|
|
176
|
+
siftly_check :message, unless: -> { source_name == "trusted" }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
record = model_class.new(message: "spam offer", source_name: "trusted")
|
|
180
|
+
assert record.valid?
|
|
181
|
+
|
|
182
|
+
record.source_name = "untrusted"
|
|
183
|
+
refute record.valid?
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def test_siftly_check_requires_at_least_one_attribute
|
|
187
|
+
assert_raises(ArgumentError) do
|
|
188
|
+
Class.new(SiftlyRailsRecord) do
|
|
189
|
+
siftly_check
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def test_multiple_siftly_check_declarations
|
|
195
|
+
Siftly.configure do |config|
|
|
196
|
+
config.use :test_keyword
|
|
197
|
+
config.filter(:test_keyword) { |f| f.keywords = ["spam"]; f.weight = 1.0 }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
model_class = Class.new(SiftlyRailsRecord) do
|
|
201
|
+
siftly_check :email
|
|
202
|
+
siftly_check :message, message: "message spam"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
record = model_class.new(email: "spam@example.com", message: "spam offer")
|
|
206
|
+
|
|
207
|
+
refute record.valid?
|
|
208
|
+
assert_equal [:email, :message], record.siftly_spam_attributes.sort
|
|
209
|
+
assert_equal ["was flagged as spam"], record.errors[:email]
|
|
210
|
+
assert_equal ["message spam"], record.errors[:message]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def test_callable_context_with_zero_arity
|
|
214
|
+
Siftly.configure do |config|
|
|
215
|
+
config.use :test_keyword
|
|
216
|
+
config.filter(:test_keyword) { |f| f.keywords = ["spam"]; f.weight = 1.0 }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
model_class = Class.new(SiftlyRailsRecord) do
|
|
220
|
+
siftly_check :message,
|
|
221
|
+
context: -> { { source: "zero-arity" } }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
record = model_class.new(message: "spam")
|
|
225
|
+
|
|
226
|
+
refute record.valid?
|
|
227
|
+
assert_equal "zero-arity", record.siftly_spam_result_for(:message).matches.first.metadata[:source]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def test_callable_filter_overrides_with_two_arity
|
|
231
|
+
model_class = Class.new(SiftlyRailsRecord) do
|
|
232
|
+
siftly_check :message,
|
|
233
|
+
filters: [:test_keyword],
|
|
234
|
+
filter_overrides: ->(attribute, record) { { test_keyword: { keywords: [record.trigger_phrase], weight: 1.0 } } }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
record = model_class.new(message: "limited offer", trigger_phrase: "offer")
|
|
238
|
+
|
|
239
|
+
refute record.valid?
|
|
240
|
+
assert record.siftly_spam?
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def test_default_error_message
|
|
244
|
+
Siftly.configure do |config|
|
|
245
|
+
config.use :test_keyword
|
|
246
|
+
config.filter(:test_keyword) { |f| f.keywords = ["spam"]; f.weight = 1.0 }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
model_class = Class.new(SiftlyRailsRecord) do
|
|
250
|
+
siftly_check :message
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
record = model_class.new(message: "spam")
|
|
254
|
+
|
|
255
|
+
refute record.valid?
|
|
256
|
+
assert_equal ["was flagged as spam"], record.errors[:message]
|
|
257
|
+
end
|
|
258
|
+
end
|
data/test/test_helper.rb
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
|
|
5
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
6
|
+
|
|
7
|
+
require "minitest/autorun"
|
|
8
|
+
require "active_record"
|
|
9
|
+
require "sqlite3"
|
|
10
|
+
require "siftly"
|
|
11
|
+
require "siftly/rails"
|
|
12
|
+
|
|
13
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
14
|
+
ActiveRecord::Schema.verbose = false
|
|
15
|
+
|
|
16
|
+
ActiveRecord::Schema.define do
|
|
17
|
+
create_table :submissions, force: true do |table|
|
|
18
|
+
table.string :email
|
|
19
|
+
table.string :title
|
|
20
|
+
table.text :message
|
|
21
|
+
table.string :trigger_phrase
|
|
22
|
+
table.string :source_name
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class SiftlyRailsTestFilter < Siftly::Filter
|
|
27
|
+
register_as :test_keyword
|
|
28
|
+
|
|
29
|
+
def call(value:, attribute: nil, record: nil, context: {})
|
|
30
|
+
keywords = Array(config.fetch(:keywords, []))
|
|
31
|
+
matched_keyword = keywords.find { |keyword| value.to_s.downcase.include?(keyword.downcase) }
|
|
32
|
+
|
|
33
|
+
result(
|
|
34
|
+
matched: !matched_keyword.nil?,
|
|
35
|
+
score: matched_keyword ? config.fetch(:weight, 1.0) : 0.0,
|
|
36
|
+
reason: matched_keyword ? "#{attribute} matched #{matched_keyword}" : nil,
|
|
37
|
+
metadata: {
|
|
38
|
+
matched_keyword: matched_keyword,
|
|
39
|
+
source: context[:source],
|
|
40
|
+
record_class: record.class.name
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class SiftlyRailsRecord < ActiveRecord::Base
|
|
47
|
+
self.abstract_class = true
|
|
48
|
+
self.table_name = "submissions"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class Minitest::Test
|
|
52
|
+
def setup
|
|
53
|
+
super
|
|
54
|
+
Siftly.reset_configuration!
|
|
55
|
+
Siftly::Registry.clear
|
|
56
|
+
Siftly::Registry.register(SiftlyRailsTestFilter)
|
|
57
|
+
end
|
|
58
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: siftly-rails
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Tomos Rees
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activesupport
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: siftly
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 0.1.0
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: 0.1.0
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: minitest
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - '='
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: 6.0.2
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - '='
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: 6.0.2
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rake
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '13.0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '13.0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: sqlite3
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '2.0'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '2.0'
|
|
96
|
+
description: Siftly::Rails lets Rails applications validate model attributes with
|
|
97
|
+
Siftly and inspect flagged attributes through the model instance.
|
|
98
|
+
executables: []
|
|
99
|
+
extensions: []
|
|
100
|
+
extra_rdoc_files: []
|
|
101
|
+
files:
|
|
102
|
+
- CHANGELOG.md
|
|
103
|
+
- Gemfile
|
|
104
|
+
- LICENSE
|
|
105
|
+
- README.md
|
|
106
|
+
- Rakefile
|
|
107
|
+
- lib/siftly/rails.rb
|
|
108
|
+
- lib/siftly/rails/attribute_check.rb
|
|
109
|
+
- lib/siftly/rails/model.rb
|
|
110
|
+
- lib/siftly/rails/version.rb
|
|
111
|
+
- siftly-rails.gemspec
|
|
112
|
+
- test/model_test.rb
|
|
113
|
+
- test/test_helper.rb
|
|
114
|
+
homepage: https://github.com/tomosjohnrees/siftly-rails
|
|
115
|
+
licenses:
|
|
116
|
+
- MIT
|
|
117
|
+
metadata:
|
|
118
|
+
source_code_uri: https://github.com/tomosjohnrees/siftly-rails
|
|
119
|
+
changelog_uri: https://github.com/tomosjohnrees/siftly-rails/blob/main/CHANGELOG.md
|
|
120
|
+
rdoc_options: []
|
|
121
|
+
require_paths:
|
|
122
|
+
- lib
|
|
123
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
124
|
+
requirements:
|
|
125
|
+
- - ">="
|
|
126
|
+
- !ruby/object:Gem::Version
|
|
127
|
+
version: '3.2'
|
|
128
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
129
|
+
requirements:
|
|
130
|
+
- - ">="
|
|
131
|
+
- !ruby/object:Gem::Version
|
|
132
|
+
version: '0'
|
|
133
|
+
requirements: []
|
|
134
|
+
rubygems_version: 3.6.9
|
|
135
|
+
specification_version: 4
|
|
136
|
+
summary: Active Record integration for the Siftly spam filtering core.
|
|
137
|
+
test_files: []
|