well_formed 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/.rspec +3 -0
- data/.rubocop.yml +12 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +869 -0
- data/Rakefile +36 -0
- data/Steepfile +17 -0
- data/gems/well_formed-pundit/.rspec +3 -0
- data/gems/well_formed-pundit/Gemfile +13 -0
- data/gems/well_formed-pundit/README.md +73 -0
- data/gems/well_formed-pundit/Rakefile +12 -0
- data/gems/well_formed-pundit/lib/well_formed/pundit/version.rb +7 -0
- data/gems/well_formed-pundit/lib/well_formed/pundit.rb +25 -0
- data/gems/well_formed-pundit/lib/well_formed-pundit.rb +8 -0
- data/gems/well_formed-pundit/spec/spec_helper.rb +13 -0
- data/gems/well_formed-pundit/spec/well_formed/pundit_integration_spec.rb +101 -0
- data/gems/well_formed-pundit/spec/well_formed/pundit_spec.rb +80 -0
- data/gems/well_formed-pundit/well_formed-pundit.gemspec +28 -0
- data/lib/generators/resource_form_generator.rb +26 -0
- data/lib/generators/templates/form.rb.tt +28 -0
- data/lib/well_formed/action_form.rb +7 -0
- data/lib/well_formed/attribute_assignment.rb +42 -0
- data/lib/well_formed/collections.rb +114 -0
- data/lib/well_formed/errors.rb +16 -0
- data/lib/well_formed/initializer.rb +31 -0
- data/lib/well_formed/nested_attributes.rb +194 -0
- data/lib/well_formed/nested_form.rb +14 -0
- data/lib/well_formed/performer.rb +57 -0
- data/lib/well_formed/persistence.rb +61 -0
- data/lib/well_formed/railtie.rb +9 -0
- data/lib/well_formed/record_identity.rb +32 -0
- data/lib/well_formed/resource_form.rb +7 -0
- data/lib/well_formed/simple_action.rb +14 -0
- data/lib/well_formed/simple_nested_form.rb +12 -0
- data/lib/well_formed/simple_resource.rb +14 -0
- data/lib/well_formed/simple_struct.rb +29 -0
- data/lib/well_formed/struct.rb +7 -0
- data/lib/well_formed/transactional.rb +38 -0
- data/lib/well_formed/translations.rb +30 -0
- data/lib/well_formed/version.rb +5 -0
- data/lib/well_formed/with_user.rb +43 -0
- data/lib/well_formed.rb +38 -0
- data/rbs_collection.yaml +19 -0
- data/sig/well_formed.rbs +129 -0
- metadata +105 -0
data/Rakefile
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
|
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
7
|
+
|
|
8
|
+
require "rubocop/rake_task"
|
|
9
|
+
|
|
10
|
+
RuboCop::RakeTask.new(:rubocop)
|
|
11
|
+
|
|
12
|
+
EXTENSION_GEMS = Dir.glob(File.join(__dir__, "gems", "*")).select { |d| File.directory?(d) }
|
|
13
|
+
|
|
14
|
+
namespace :gems do
|
|
15
|
+
task install: :environment do
|
|
16
|
+
EXTENSION_GEMS.each do |gem_dir|
|
|
17
|
+
sh "bundle install", chdir: gem_dir
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
task spec: :environment do
|
|
22
|
+
EXTENSION_GEMS.each do |gem_dir|
|
|
23
|
+
sh "bundle exec rake spec", chdir: gem_dir
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
task rubocop: :environment do
|
|
28
|
+
EXTENSION_GEMS.each do |gem_dir|
|
|
29
|
+
sh "bundle exec rake rubocop", chdir: gem_dir
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
task all: %i[spec rubocop]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
task default: %i[spec rubocop]
|
data/Steepfile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
D = Steep::Diagnostic
|
|
2
|
+
|
|
3
|
+
target :lib do
|
|
4
|
+
signature "sig"
|
|
5
|
+
check "lib"
|
|
6
|
+
configure_code_diagnostics(D::Ruby.lenient)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# target :test do
|
|
10
|
+
# unreferenced! # Skip type checking the `lib` code when types in `test` target is changed
|
|
11
|
+
# signature "sig/test" # Put RBS files for tests under `sig/test`
|
|
12
|
+
# check "test" # Type check Ruby scripts under `test`
|
|
13
|
+
#
|
|
14
|
+
# configure_code_diagnostics(D::Ruby.lenient) # Weak type checking for test code
|
|
15
|
+
#
|
|
16
|
+
# # library "pathname" # Standard libraries
|
|
17
|
+
# end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gemspec
|
|
6
|
+
|
|
7
|
+
gem "well_formed", path: "../../"
|
|
8
|
+
|
|
9
|
+
gem "rake", "~> 13.0"
|
|
10
|
+
gem "rspec", "~> 3.0"
|
|
11
|
+
gem "activerecord", require: false
|
|
12
|
+
gem "standard", "~> 1.3"
|
|
13
|
+
gem "rubocop-rails", require: false
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# well_formed-pundit
|
|
2
|
+
|
|
3
|
+
[Pundit](https://github.com/varvet/pundit) authorization integration for [WellFormed](https://github.com/bmorrall/well_formed) form objects.
|
|
4
|
+
|
|
5
|
+
Adds `policy`, `authorize!`, and `policy_scope` helpers directly to any WellFormed form, using the form's built-in `resource` and `user` references.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bundle add well_formed-pundit
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Require the gem in your application:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "well_formed-pundit"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`WellFormed::Pundit` is automatically included into all WellFormed forms — no `include` required.
|
|
22
|
+
|
|
23
|
+
### `authorize!`
|
|
24
|
+
|
|
25
|
+
Raise `Pundit::NotAuthorizedError` if the user is not permitted to perform an action on the resource:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
class CreateArticleForm < WellFormed::ResourceForm
|
|
29
|
+
resource_alias :article
|
|
30
|
+
|
|
31
|
+
attribute :title, :string
|
|
32
|
+
attribute :body, :string
|
|
33
|
+
|
|
34
|
+
validates :title, presence: true
|
|
35
|
+
|
|
36
|
+
def perform
|
|
37
|
+
authorize!(:create?) # authorizes resource
|
|
38
|
+
authorize!(parent_record, :update?) # authorizes a different record
|
|
39
|
+
# proceed with save ...
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### `policy`
|
|
45
|
+
|
|
46
|
+
Access the resolved Pundit policy instance directly. Defaults to the form's `resource`, but accepts an optional record argument:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
form.policy # => ArticlePolicy for resource
|
|
50
|
+
form.policy.create? # => true / false
|
|
51
|
+
form.policy(other) # => policy resolved for a different record
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### `policy_scope`
|
|
55
|
+
|
|
56
|
+
Resolve a scoped collection for the current user:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
articles = form.policy_scope(Article.all)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## API
|
|
63
|
+
|
|
64
|
+
| Method | Description |
|
|
65
|
+
|--------|-------------|
|
|
66
|
+
| `policy(record = resource)` | Returns the Pundit policy instance for `record` and `user` |
|
|
67
|
+
| `authorize!(query)` | Raises `Pundit::NotAuthorizedError` unless the user is authorized for `resource` |
|
|
68
|
+
| `authorize!(record, query)` | Raises `Pundit::NotAuthorizedError` unless the user is authorized for `record` |
|
|
69
|
+
| `policy_scope(collection)` | Returns the policy scope resolved for `user` |
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WellFormed
|
|
4
|
+
module Pundit
|
|
5
|
+
# Returns the Pundit policy instance for the given record (defaults to resource).
|
|
6
|
+
def policy(record = resource)
|
|
7
|
+
::Pundit::PolicyFinder.new(record).policy!.new(user, record)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Returns the scoped collection for the current user.
|
|
11
|
+
def policy_scope(collection)
|
|
12
|
+
::Pundit::PolicyFinder.new(collection).scope!.new(user, collection).resolve
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Raises Pundit::NotAuthorizedError if the user is not authorized for the given query.
|
|
16
|
+
# Optionally pass an explicit record as the first argument; defaults to resource.
|
|
17
|
+
def authorize!(record_or_query, query = nil)
|
|
18
|
+
if query.nil?
|
|
19
|
+
::Pundit.authorize(user, resource, record_or_query)
|
|
20
|
+
else
|
|
21
|
+
::Pundit.authorize(user, record_or_query, query)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "well_formed-pundit"
|
|
4
|
+
|
|
5
|
+
RSpec.configure do |config|
|
|
6
|
+
config.example_status_persistence_file_path = ".rspec_status"
|
|
7
|
+
|
|
8
|
+
config.disable_monkey_patching!
|
|
9
|
+
|
|
10
|
+
config.expect_with :rspec do |c|
|
|
11
|
+
c.syntax = :expect
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Integration spec: exercises real Pundit policy resolution (no doubles)
|
|
4
|
+
|
|
5
|
+
Article = Struct.new(:id)
|
|
6
|
+
|
|
7
|
+
class ArticlePolicy
|
|
8
|
+
attr_reader :user, :record
|
|
9
|
+
|
|
10
|
+
def initialize(user, record)
|
|
11
|
+
@user = user
|
|
12
|
+
@record = record
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create? = user[:admin]
|
|
16
|
+
def update? = user[:admin]
|
|
17
|
+
|
|
18
|
+
class Scope
|
|
19
|
+
attr_reader :user, :scope
|
|
20
|
+
|
|
21
|
+
def initialize(user, scope)
|
|
22
|
+
@user = user
|
|
23
|
+
@scope = scope
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def resolve
|
|
27
|
+
user[:admin] ? :all : :restricted
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
RSpec.describe WellFormed::Pundit, :integration do
|
|
33
|
+
let(:article) { Article.new(1) }
|
|
34
|
+
let(:admin) { {admin: true} }
|
|
35
|
+
let(:guest) { {admin: false} }
|
|
36
|
+
|
|
37
|
+
let(:form_class) do
|
|
38
|
+
stub_const("UpdateArticleForm", Class.new(WellFormed::ResourceForm) do
|
|
39
|
+
attribute :title, :string
|
|
40
|
+
end)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe "#policy" do
|
|
44
|
+
subject(:form) { form_class.new(article, admin) }
|
|
45
|
+
|
|
46
|
+
it "returns the ArticlePolicy instance" do
|
|
47
|
+
expect(form.policy).to be_a(ArticlePolicy)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "passes user and record to the policy" do
|
|
51
|
+
expect(form.policy.user).to eq(admin)
|
|
52
|
+
expect(form.policy.record).to eq(article)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "uses an explicit record when provided" do
|
|
56
|
+
other_article = Article.new(2)
|
|
57
|
+
expect(form.policy(other_article).record).to eq(other_article)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe "#authorize!" do
|
|
62
|
+
context "when the user is authorized" do
|
|
63
|
+
subject(:form) { form_class.new(article, admin) }
|
|
64
|
+
|
|
65
|
+
it "does not raise" do
|
|
66
|
+
expect { form.authorize!(:update?) }.not_to raise_error
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "accepts an explicit record as the first argument" do
|
|
70
|
+
other_article = Article.new(2)
|
|
71
|
+
expect { form.authorize!(other_article, :update?) }.not_to raise_error
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
context "when the user is not authorized" do
|
|
76
|
+
subject(:form) { form_class.new(article, guest) }
|
|
77
|
+
|
|
78
|
+
it "raises Pundit::NotAuthorizedError" do
|
|
79
|
+
expect { form.authorize!(:update?) }.to raise_error(::Pundit::NotAuthorizedError)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
describe "#policy_scope" do
|
|
85
|
+
context "when the user is an admin" do
|
|
86
|
+
subject(:form) { form_class.new(article, admin) }
|
|
87
|
+
|
|
88
|
+
it "returns :all" do
|
|
89
|
+
expect(form.policy_scope(Article)).to eq(:all)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
context "when the user is a guest" do
|
|
94
|
+
subject(:form) { form_class.new(article, guest) }
|
|
95
|
+
|
|
96
|
+
it "returns :restricted" do
|
|
97
|
+
expect(form.policy_scope(Article)).to eq(:restricted)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe WellFormed::Pundit do
|
|
4
|
+
it "has a version number" do
|
|
5
|
+
expect(WellFormed::Pundit::VERSION).not_to be_nil
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
let(:resource) { double("resource") }
|
|
9
|
+
let(:user) { double("user") }
|
|
10
|
+
|
|
11
|
+
let(:form_class) do
|
|
12
|
+
stub_const("TestForm", Class.new(WellFormed::ResourceForm) do
|
|
13
|
+
attribute :title, :string
|
|
14
|
+
end)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
subject(:form) { form_class.new(resource, user) }
|
|
18
|
+
|
|
19
|
+
describe "auto-include via WithUser" do
|
|
20
|
+
it "is included automatically when WithUser is prepended" do
|
|
21
|
+
expect(form_class.ancestors).to include(WellFormed::Pundit)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
describe "#policy" do
|
|
26
|
+
it "returns the Pundit policy for the resource and user" do
|
|
27
|
+
policy_instance = double("policy_instance")
|
|
28
|
+
policy_class = double("policy_class", new: policy_instance)
|
|
29
|
+
policy_finder = double("policy_finder", policy!: policy_class)
|
|
30
|
+
|
|
31
|
+
allow(::Pundit::PolicyFinder).to receive(:new).with(resource).and_return(policy_finder)
|
|
32
|
+
|
|
33
|
+
expect(form.policy).to eq(policy_instance)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "accepts an explicit record argument" do
|
|
37
|
+
other_record = double("other_record")
|
|
38
|
+
policy_instance = double("policy_instance")
|
|
39
|
+
policy_class = double("policy_class", new: policy_instance)
|
|
40
|
+
policy_finder = double("policy_finder", policy!: policy_class)
|
|
41
|
+
|
|
42
|
+
allow(::Pundit::PolicyFinder).to receive(:new).with(other_record).and_return(policy_finder)
|
|
43
|
+
|
|
44
|
+
expect(form.policy(other_record)).to eq(policy_instance)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe "#authorize!" do
|
|
49
|
+
it "delegates to Pundit.authorize with resource" do
|
|
50
|
+
allow(::Pundit).to receive(:authorize).with(user, resource, :create?).and_return(true)
|
|
51
|
+
expect { form.authorize!(:create?) }.not_to raise_error
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "raises Pundit::NotAuthorizedError when not authorized" do
|
|
55
|
+
allow(::Pundit).to receive(:authorize).with(user, resource, :create?)
|
|
56
|
+
.and_raise(::Pundit::NotAuthorizedError)
|
|
57
|
+
expect { form.authorize!(:create?) }.to raise_error(::Pundit::NotAuthorizedError)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "accepts an explicit record as the first argument" do
|
|
61
|
+
other_record = double("other_record")
|
|
62
|
+
allow(::Pundit).to receive(:authorize).with(user, other_record, :update?).and_return(true)
|
|
63
|
+
expect { form.authorize!(other_record, :update?) }.not_to raise_error
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
describe "#policy_scope" do
|
|
68
|
+
it "resolves the scoped collection for the user" do
|
|
69
|
+
collection = double("collection")
|
|
70
|
+
resolved = double("resolved")
|
|
71
|
+
scope_instance = double("scope_instance", resolve: resolved)
|
|
72
|
+
scope_class = double("scope_class", new: scope_instance)
|
|
73
|
+
scope_finder = double("scope_finder", scope!: scope_class)
|
|
74
|
+
|
|
75
|
+
allow(::Pundit::PolicyFinder).to receive(:new).with(collection).and_return(scope_finder)
|
|
76
|
+
|
|
77
|
+
expect(form.policy_scope(collection)).to eq(resolved)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/well_formed/pundit/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "well_formed-pundit"
|
|
7
|
+
spec.version = WellFormed::Pundit::VERSION
|
|
8
|
+
spec.authors = ["Ben Morrall"]
|
|
9
|
+
spec.email = ["bemo56@hotmail.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Pundit authorization integration for well_formed"
|
|
12
|
+
spec.homepage = "https://github.com/bmorrall/well_formed"
|
|
13
|
+
spec.license = "MIT"
|
|
14
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
15
|
+
|
|
16
|
+
gemspec = File.basename(__FILE__)
|
|
17
|
+
spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
|
|
18
|
+
ls.readlines("\x0", chomp: true).reject do |f|
|
|
19
|
+
(f == gemspec) ||
|
|
20
|
+
f.start_with?(*%w[spec/ .git Gemfile])
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
spec.require_paths = ["lib"]
|
|
25
|
+
|
|
26
|
+
spec.add_dependency "well_formed", ">= 0.1.0"
|
|
27
|
+
spec.add_dependency "pundit", ">= 2.0"
|
|
28
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/named_base"
|
|
5
|
+
|
|
6
|
+
class ResourceFormGenerator < Rails::Generators::NamedBase
|
|
7
|
+
source_root File.expand_path("templates", __dir__)
|
|
8
|
+
|
|
9
|
+
def create_form_file
|
|
10
|
+
template "form.rb.tt", File.join("app/forms", class_path, "#{base_name}_form.rb")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def form_class_name
|
|
16
|
+
[*class_path.map(&:camelize), "#{base_name.camelize}Form"].join("::")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def base_name
|
|
20
|
+
file_name.delete_suffix("_form")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def resource_alias_name
|
|
24
|
+
base_name
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= form_class_name %> < WellFormed::ResourceForm
|
|
4
|
+
# resource_alias :<%= resource_alias_name %>
|
|
5
|
+
|
|
6
|
+
# attribute :title, :string
|
|
7
|
+
|
|
8
|
+
# merge_model_errors
|
|
9
|
+
|
|
10
|
+
# validates :title, presence: true
|
|
11
|
+
|
|
12
|
+
# save_within_transaction
|
|
13
|
+
|
|
14
|
+
# before_save :set_defaults
|
|
15
|
+
# after_save :notify
|
|
16
|
+
# after_save_commit :notify_async
|
|
17
|
+
|
|
18
|
+
# private
|
|
19
|
+
|
|
20
|
+
# def set_defaults
|
|
21
|
+
# end
|
|
22
|
+
|
|
23
|
+
# def notify
|
|
24
|
+
# end
|
|
25
|
+
|
|
26
|
+
# def notify_async
|
|
27
|
+
# end
|
|
28
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WellFormed
|
|
4
|
+
module AttributeAssignment
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend(ClassMethods)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
def unmatched_attributes(policy)
|
|
11
|
+
raise ArgumentError, "policy must be :ignore, :warn, or :raise" unless %i[ignore warn raise].include?(policy)
|
|
12
|
+
|
|
13
|
+
@unmatched_attributes_policy = policy
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def unmatched_attributes_policy
|
|
17
|
+
@unmatched_attributes_policy || :ignore
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def assign_attributes_to(resource)
|
|
22
|
+
matched = attributes.select { |attr, _| resource.respond_to?("#{attr}=") }
|
|
23
|
+
unmatched_keys = attributes.keys - matched.keys
|
|
24
|
+
|
|
25
|
+
if unmatched_keys.any?
|
|
26
|
+
case self.class.unmatched_attributes_policy
|
|
27
|
+
when :warn
|
|
28
|
+
warn "#{self.class} has attributes with no setter on resource: #{unmatched_keys.join(", ")}"
|
|
29
|
+
when :raise
|
|
30
|
+
raise UnmatchedAttributesError,
|
|
31
|
+
"#{self.class} has attributes with no setter on resource: #{unmatched_keys.join(", ")}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if resource.respond_to?(:assign_attributes)
|
|
36
|
+
resource.assign_attributes(matched)
|
|
37
|
+
else
|
|
38
|
+
matched.each { |attr, value| resource.public_send(:"#{attr}=", value) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WellFormed
|
|
4
|
+
module Collections
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend(ClassMethods)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
# Declares a scoped collection for a form attribute, with optional inclusion validation
|
|
11
|
+
# and optional code-to-id resolution.
|
|
12
|
+
#
|
|
13
|
+
# Generates a +collection_for_<name>+ instance method that returns an ActiveRecord relation.
|
|
14
|
+
# The block is evaluated in the context of the form instance, so +user+, +resource+, and
|
|
15
|
+
# any other instance methods are available.
|
|
16
|
+
#
|
|
17
|
+
# @param name [Symbol] the attribute name (e.g. :user_id)
|
|
18
|
+
# @param validate [false, true, Symbol] when truthy, adds an inclusion validator.
|
|
19
|
+
# - +true+ — validates that the attribute value exists in the collection matched by +:id+
|
|
20
|
+
# - Symbol — validates matched by the given field (e.g. <tt>validate: :code</tt>)
|
|
21
|
+
# @param resolves_to [false, true, Symbol] when truthy, the attribute accepts the +validate+
|
|
22
|
+
# field value as external input (e.g. a code string) and resolves it to the +resolves_to+
|
|
23
|
+
# field value (e.g. integer id) via an +after_validation+ callback. Also overrides
|
|
24
|
+
# +resource_defaults+ to reverse-populate the attribute with the +validate+ field value
|
|
25
|
+
# for edit forms. Requires +validate:+ to be a Symbol.
|
|
26
|
+
# - +true+ — resolves to +:id+
|
|
27
|
+
# - Symbol — resolves to the given field (e.g. <tt>resolves_to: :uuid</tt>)
|
|
28
|
+
# @yieldreturn [ActiveRecord::Relation] the scoped collection
|
|
29
|
+
#
|
|
30
|
+
# Example (basic):
|
|
31
|
+
# class CreatePostForm < WellFormed::ResourceForm
|
|
32
|
+
# attribute :user_id, :integer
|
|
33
|
+
#
|
|
34
|
+
# collection_for :user_id, validate: true do
|
|
35
|
+
# User.all
|
|
36
|
+
# end
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# Example (code-to-id resolution):
|
|
40
|
+
# class CreatePostForm < WellFormed::ResourceForm
|
|
41
|
+
# attribute :user_id # must not be typed :integer — accepts code strings
|
|
42
|
+
#
|
|
43
|
+
# collection_for :user_id, validate: :code, resolves_to: :id do
|
|
44
|
+
# User.all
|
|
45
|
+
# end
|
|
46
|
+
# end
|
|
47
|
+
def collection_for(name, validate: false, resolves_to: false, &block)
|
|
48
|
+
raise ArgumentError, "collection_for :#{name} requires a block" unless block
|
|
49
|
+
|
|
50
|
+
define_method(:"collection_for_#{name}", &block)
|
|
51
|
+
|
|
52
|
+
if resolves_to
|
|
53
|
+
unless validate.is_a?(Symbol)
|
|
54
|
+
raise ArgumentError,
|
|
55
|
+
"collection_for :#{name} requires validate: <Symbol> when resolves_to: is set"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
match_field = validate
|
|
59
|
+
resolve_field = (resolves_to == true) ? :id : resolves_to
|
|
60
|
+
collection_method = :"collection_for_#{name}"
|
|
61
|
+
|
|
62
|
+
# Define a single validate method that both validates the input field
|
|
63
|
+
# value and, on success, transforms it to the resolve_field value.
|
|
64
|
+
# Uses :validate callbacks (not :validation) which are reliably set up
|
|
65
|
+
# by ActiveModel::Validations in all contexts.
|
|
66
|
+
resolve_method = :"_resolve_#{name}_to_#{resolve_field}"
|
|
67
|
+
|
|
68
|
+
define_method(resolve_method) do
|
|
69
|
+
val = public_send(name)
|
|
70
|
+
return if val.blank?
|
|
71
|
+
|
|
72
|
+
record = public_send(collection_method).find_by(match_field => val)
|
|
73
|
+
if record.nil?
|
|
74
|
+
errors.add(name, :inclusion)
|
|
75
|
+
else
|
|
76
|
+
public_send(:"#{name}=", record.public_send(resolve_field))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
validate resolve_method
|
|
81
|
+
|
|
82
|
+
resolve_attr = name.to_s
|
|
83
|
+
|
|
84
|
+
prepend(Module.new do
|
|
85
|
+
define_method(:resource_defaults) do
|
|
86
|
+
defaults = super()
|
|
87
|
+
stored = resource&.public_send(resolve_attr)
|
|
88
|
+
return defaults if stored.blank?
|
|
89
|
+
|
|
90
|
+
code_value = public_send(collection_method)
|
|
91
|
+
.find_by(resolve_field => stored)
|
|
92
|
+
&.public_send(match_field)
|
|
93
|
+
defaults.merge(resolve_attr => code_value)
|
|
94
|
+
end
|
|
95
|
+
end)
|
|
96
|
+
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
return unless validate
|
|
101
|
+
|
|
102
|
+
match_field = (validate == true) ? :id : validate
|
|
103
|
+
collection_method = :"collection_for_#{name}"
|
|
104
|
+
|
|
105
|
+
validates name, inclusion: {
|
|
106
|
+
in: ->(record) {
|
|
107
|
+
record.public_send(collection_method).where(match_field => record.public_send(name)).pluck(match_field)
|
|
108
|
+
},
|
|
109
|
+
allow_blank: true
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WellFormed
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class UnmatchedAttributesError < Error; end
|
|
6
|
+
|
|
7
|
+
class RecordInvalid < Error
|
|
8
|
+
attr_reader :record
|
|
9
|
+
|
|
10
|
+
def initialize(record)
|
|
11
|
+
@record = record
|
|
12
|
+
messages = record.errors.full_messages
|
|
13
|
+
super(messages.empty? ? "Record invalid" : "Validation failed: #{messages.join(", ")}")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|