smart_params 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7ee27024729173b4bbeb45827db79738a4ed84b07da26c315b57ba0fcca313cc
4
+ data.tar.gz: 0a65e6f80959a389e55d492735f0a6c8fcfa9c06397acaf9468c83a2e8db4670
5
+ SHA512:
6
+ metadata.gz: 4580e4078efacdf5b01d334b4f943b8a8426cc375a08180970715187de761595dce15c9b62599f259f769f31bd503f25781da895e1a8dbba0778d37d151ac414
7
+ data.tar.gz: 9e78ae9f84d8541b5ddebde79346e54dcec97b1bc2b055c00f7fa7d34aae03175b05dbf7e32a96ccad047e8be7c6f340c004384a27437e7d17eb5d8e9f710088
data/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # smart_params
2
+
3
+ - [![Build](http://img.shields.io/travis-ci/krainboltgreene/smart_params.svg?style=flat-square)](https://travis-ci.org/krainboltgreene/smart_params)
4
+ - [![Downloads](http://img.shields.io/gem/dtv/smart_params.svg?style=flat-square)](https://rubygems.org/gems/smart_params)
5
+ - [![Version](http://img.shields.io/gem/v/smart_params.svg?style=flat-square)](https://rubygems.org/gems/smart_params)
6
+
7
+
8
+ Work smart, not strong. This gem gives developers an easy to understand and easy to maintain schema for request parameters. Meant as a drop-in replacement for strong_params.
9
+
10
+
11
+ ## Using
12
+
13
+ So lets say you have a complex set of incoming data, say a JSON:API-specification compliant payload that contains the data to create an account on your server. Of course, your e-commerce platform is pretty flexible; You don't stop users from creating an account just because they don't have an email or password. So let's see how this would play out:
14
+
15
+ ``` ruby
16
+ class CreateAccountSchema
17
+ include SmartParams
18
+
19
+ schema type: Strict::Hash do
20
+ need :data, type: Strict::Hash do
21
+ allow :id, type: Coercible::String.optional
22
+ need :type, type: Strict::String
23
+ allow :attributes, type: Strict::Hash.optional do
24
+ allow :email, type: Strict::String.optional
25
+ allow :username, type: Strict::String.optional
26
+ allow :name, type: Strict::String.optional
27
+ allow :password, type: Strict::String.optional.default { SecureRandom.hex(32) }
28
+ end
29
+ end
30
+ allow :meta, type: Strict::Hash.optional
31
+ allow :included, type: Strict::Array.optional
32
+ end
33
+ end
34
+ ```
35
+
36
+ And now using that schema in the controller:
37
+
38
+ ``` ruby
39
+ class AccountsController < ApplicationController
40
+ def create
41
+ schema = CreateAccountSchema.new(params)
42
+ # parameters will be a SmartParams::Dataset, which will respond to the various fields you defined
43
+
44
+ # Here we're pulling out the id and data properties defined above
45
+ record = Account.create({id: schema.data.id, **schema.data.attributes})
46
+
47
+ redirect_to account_url(record)
48
+ end
49
+ end
50
+ ```
51
+
52
+ Okay, so lets look at some scenarios.
53
+
54
+ First, lets try an empty payload:
55
+
56
+ ``` ruby
57
+ CreateAccountSchema.new({})
58
+ # raises SmartParams::Error::InvalidPropertyType, keychain: [:data], wanted: Hash, raw: nil
59
+
60
+ # You can return the exception directly by providing :safe => false
61
+
62
+ CreateAccountSchema.new({}, safe: false).payload
63
+ # return #<SmartParams::Error::InvalidPropertyType... keychain: [:data], wanted: Hash, raw: nil>
64
+ ```
65
+
66
+ Great, we've told SmartParams we need `data` and it enforced this! The exception class knows the "key chain" path to the property that was missing and the value that was given. Lets experiment with that:
67
+
68
+ ``` ruby
69
+ CreateAccountSchema.new({data: ""})
70
+ # raise SmartParams::Error::InvalidPropertyType, keychain: [:data], wanted: Hash, raw: ""
71
+ ```
72
+
73
+ Sweet, we can definitely catch this and give the client a meaningful error! Okay, so to show off a good payload I'm going to do two things: Examine the properties and turn it to a JSON compatible structure. Lets see a minimum viable account according to our schema:
74
+
75
+
76
+ ``` ruby
77
+ schema = CreateAccountSchema.new({
78
+ data: {
79
+ type: "accounts"
80
+ }
81
+ })
82
+
83
+ schema.payload.data.type
84
+ # "accounts"
85
+
86
+ schema.data.type
87
+ # "accounts"
88
+
89
+ schema.as_json
90
+ # {
91
+ # "data" => {
92
+ # "type" => "accounts",
93
+ # "attributes" => {
94
+ # "password" => "1a6c3ffa4e96ad1660cb819f52a3393d924ac20073e84a9a6943a721d49bab38"
95
+ # }
96
+ # }
97
+ # }
98
+ ```
99
+
100
+ Wait, what happened here? Well we told SmartParams that we're going to want a default password, so it delivered!
101
+
102
+
103
+ ### Types
104
+
105
+ For more information on what types and options you can use, please read: http://dry-rb.org/gems/dry-types/
106
+
107
+
108
+ ### Why not strong_params?
109
+
110
+ Okay so sure strong_params exists and it's definitely better than `attr_accessible` (if you remember that mess), but it often leaves you with code like this:
111
+
112
+ https://github.com/diaspora/diaspora/blob/develop/app/controllers/users_controller.rb#L140-L158
113
+
114
+ Which while fine to start with usually evolves into:
115
+
116
+ https://github.com/discourse/discourse/blob/master/app/controllers/posts_controller.rb#L592-L677
117
+
118
+ None of this is very maintainable and it's definitely not easy to teach. So my solution is to follow the wake of other libraries: Define a maintainable interface that can be easily tested and easily integrated. It doesn't require wholesale adoption nor is it hard to remove.
119
+
120
+
121
+ ### Why not have this in the controller?
122
+
123
+ First and foremost because the controller already has a job: Determining the course of action for a request. Why complicate it with yet another responsibility? Second because it makes testing that much harder. Instead of just testing the correctness of your schema, you now have to mock authentication and authorization.
124
+
125
+
126
+ ### Why not use before_validation or before_save?
127
+
128
+ Your model is already complex enough and it doesn't need the added baggage of figuring out how to transform incoming data. We learned that lesson the hard way with `attr_accessible`. Further, it's not vary sharable or understandable over large time periods.
129
+
130
+
131
+ ## Installing
132
+
133
+ Add this line to your application's Gemfile:
134
+
135
+ gem "smart_params", "1.0.0"
136
+
137
+ And then execute:
138
+
139
+ $ bundle
140
+
141
+ Or install it yourself with:
142
+
143
+ $ gem install smart_params
144
+
145
+
146
+ ## Contributing
147
+
148
+ 1. Read the [Code of Conduct](/CONDUCT.md)
149
+ 2. Fork it
150
+ 3. Create your feature branch (`git checkout -b my-new-feature`)
151
+ 4. Commit your changes (`git commit -am 'Add some feature'`)
152
+ 5. Push to the branch (`git push origin my-new-feature`)
153
+ 6. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env rake
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ desc "Run all the tests in spec"
7
+ RSpec::Core::RakeTask.new(:spec) do |let|
8
+ let.pattern = "lib/**{,/*/**}/*_spec.rb"
9
+ end
10
+
11
+ desc "Default: run tests"
12
+ task default: :spec
@@ -0,0 +1,27 @@
1
+ module SmartParams
2
+ class Error
3
+ class InvalidPropertyType < Error
4
+ attr_reader :keychain
5
+ attr_reader :wanted
6
+ attr_reader :raw
7
+
8
+ def initialize(keychain:, wanted:, raw:)
9
+ @keychain = keychain
10
+ @wanted = type
11
+ @raw = raw
12
+ end
13
+
14
+ def message
15
+ "expected #{keychain.inspect} to be wanted of #{wanted.type.name}, but was #{raw.inspect}"
16
+ end
17
+
18
+ def as_json
19
+ {
20
+ "keychain" => keychain,
21
+ "wanted" => wanted.type.name,
22
+ "raw" => raw
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ module SmartParams
2
+ class Error < StandardError
3
+ require_relative "error/invalid_property_type"
4
+ end
5
+ end
@@ -0,0 +1,61 @@
1
+ module SmartParams
2
+ class Field
3
+ RECURSIVE_TREE = ->(accumulated, key) {accumulated[key] = Hash.new(&RECURSIVE_TREE)}
4
+
5
+ attr_reader :keychain
6
+ attr_reader :value
7
+ attr_reader :subfields
8
+ attr_reader :type
9
+
10
+ def initialize(keychain:, type:, &nesting)
11
+ @keychain = Array(keychain)
12
+ @subfields = Set.new
13
+ @type = type
14
+
15
+ if block_given?
16
+ instance_eval(&nesting)
17
+ end
18
+ end
19
+
20
+ def allow(key, type:, &subfield)
21
+ @subfields << self.class.new(keychain: [*keychain, key], type: type, &subfield)
22
+ end
23
+
24
+ def need(key, type:, &subfield)
25
+ @subfields << self.class.new(keychain: [*keychain, key], type: type, &subfield)
26
+ end
27
+
28
+ def deep?
29
+ subfields.present?
30
+ end
31
+
32
+ def claim(raw)
33
+ if keychain.empty?
34
+ @value = type[raw]
35
+ else
36
+ @value = type[raw.dig(*keychain)]
37
+ end
38
+ rescue Dry::Types::ConstraintError => bad_type_exception
39
+ raise SmartParams::Error::InvalidPropertyType, keychain: keychain, wanted: type, raw: raw.dig(*keychain)
40
+ end
41
+
42
+ def to_hash
43
+ *chain, key = keychain
44
+ Hash.new(&RECURSIVE_TREE).tap do |tree|
45
+ if chain.any?
46
+ tree.dig(*chain)[key] = value
47
+ else
48
+ tree[key] = value
49
+ end
50
+ end
51
+ end
52
+
53
+ def empty?
54
+ value.nil?
55
+ end
56
+
57
+ def weight
58
+ keychain.length
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,3 @@
1
+ module SmartParams
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,7 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe SmartParams::VERSION do
4
+ it "should be a string" do
5
+ expect(SmartParams::VERSION).to be_kind_of(String)
6
+ end
7
+ end
@@ -0,0 +1,92 @@
1
+ require "ostruct"
2
+ require "dry-types"
3
+ require "recursive-open-struct"
4
+ require "active_support/concern"
5
+ require "active_support/core_ext/object"
6
+
7
+ module SmartParams
8
+ extend ActiveSupport::Concern
9
+ include Dry::Types.module
10
+
11
+ RECURSIVE_TREE = ->(accumulated, key) {accumulated[key] = Hash.new(&RECURSIVE_TREE)}
12
+
13
+ require_relative "smart_params/field"
14
+ require_relative "smart_params/error"
15
+ require_relative "smart_params/version"
16
+
17
+ attr_reader :raw
18
+ attr_reader :schema
19
+ attr_reader :fields
20
+
21
+ def initialize(raw, safe: true)
22
+ @raw = raw
23
+ @schema = self.class.instance_variable_get(:@schema)
24
+ @fields = [@schema, *unfold(@schema.subfields)]
25
+ .sort_by(&:weight)
26
+ .each { |field| field.claim(raw) }
27
+ @safe = safe
28
+ rescue SmartParams::Error::InvalidPropertyType => invalid_property_type_exception
29
+ if safe?
30
+ raise invalid_property_type_exception
31
+ else
32
+ @exception = invalid_property_type_exception
33
+ end
34
+ end
35
+
36
+ def payload
37
+ if @exception.present?
38
+ @exception
39
+ else
40
+ RecursiveOpenStruct.new(structure)
41
+ end
42
+ end
43
+
44
+ def as_json
45
+ if @exception.present?
46
+ @exception.as_json
47
+ else
48
+ structure.deep_stringify_keys
49
+ end
50
+ end
51
+
52
+ def method_missing(name, *arguments, &block)
53
+ if payload.respond_to?(name)
54
+ payload.public_send(name)
55
+ else
56
+ super
57
+ end
58
+ end
59
+
60
+ # This function basically takes a list of fields and reduces them into a tree of values
61
+ private def structure
62
+ fields
63
+ .reject(&:empty?)
64
+ .map(&:to_hash)
65
+ .map do |hash|
66
+ # NOTE: okay, so this looks weird, but it's because the root type has no key
67
+ if hash.key?(nil) then hash.fetch(nil) else hash end
68
+ end
69
+ .reduce(&:deep_merge)
70
+ end
71
+
72
+ # This funcion takes a nested field tree and turns it into a list of fields
73
+ private def unfold(subfields)
74
+ subfields.to_a.reduce([]) do |list, field|
75
+ if field.deep?
76
+ [*list, field, *unfold(field.subfields)]
77
+ else
78
+ [*list, field]
79
+ end
80
+ end.flatten
81
+ end
82
+
83
+ private def safe?
84
+ @safe
85
+ end
86
+
87
+ class_methods do
88
+ def schema(type:, &subfield)
89
+ @schema = Field.new(keychain: [], type: type, &subfield)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,147 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe SmartParams do
4
+ let(:schema) { CreateAccountSchema.new(params) }
5
+
6
+ describe ".new" do
7
+ context "with an empty params" do
8
+ let(:params) { {} }
9
+
10
+ it "throws an error with a message detailing the invalid property type and given properties" do
11
+ expect {schema}.to raise_exception(SmartParams::Error::InvalidPropertyType, "expected [:data] to be kind of Hash, but was nil")
12
+ end
13
+
14
+ it "throws an error with the missing property and given properties" do
15
+ expect {schema}.to raise_exception do |exception|
16
+ expect(exception).to have_attributes(keychain: [:data], wanted: SmartParams::Strict::Hash, raw: nil)
17
+ end
18
+ end
19
+ end
20
+
21
+ context "with a good key but bad type" do
22
+ let(:params) { {data: ""} }
23
+
24
+ it "throws an error with a message detailing the invalid property, expected type, given type, and given value" do
25
+ expect { schema }.to raise_exception(SmartParams::Error::InvalidPropertyType, "expected [:data] to be kind of Hash, but was \"\"")
26
+ end
27
+
28
+ it "throws an error with the invalid property, expected type, given type, and given value" do
29
+ expect {schema }.to raise_exception do |exception|
30
+ expect(exception).to have_attributes(keychain: [:data], wanted: SmartParams::Strict::Hash, raw: "")
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ describe "#payload" do
37
+ context "with a reasonably good params" do
38
+ let(:params) do
39
+ {
40
+ data: {
41
+ type: "accounts",
42
+ attributes: {
43
+ email: "kurtis@example.com"
44
+ }
45
+ },
46
+ meta: {
47
+ jsonapi_version: "1.0"
48
+ },
49
+ included: [
50
+ {
51
+ data: {
52
+ id: "a",
53
+ type: "widget",
54
+ attributes: {
55
+ title: "Widget A",
56
+ }
57
+ }
58
+ }
59
+ ]
60
+ }
61
+ end
62
+
63
+ it "returns the type" do
64
+ expect(schema.payload.data.type).to eq("accounts")
65
+ end
66
+
67
+ it "returns the email" do
68
+ expect(schema.data.attributes.email).to eq("kurtis@example.com")
69
+ end
70
+
71
+ it "returns the password" do
72
+ expect(schema.data.attributes.password).to be_kind_of(String)
73
+ end
74
+
75
+ it "returns the jsonapi version" do
76
+ expect(schema.meta.jsonapi_version).to eq("1.0")
77
+ end
78
+ end
79
+ end
80
+
81
+ describe "#as_json" do
82
+ subject {schema.as_json}
83
+
84
+ context "with a reasonably good params" do
85
+ let(:params) do
86
+ {
87
+ data: {
88
+ type: "accounts",
89
+ attributes: {
90
+ email: "kurtis@example.com"
91
+ }
92
+ },
93
+ meta: {
94
+ jsonapi_version: "1.0"
95
+ },
96
+ included: [
97
+ {
98
+ data: {
99
+ id: "a",
100
+ type: "widget",
101
+ attributes: {
102
+ title: "Widget A",
103
+ }
104
+ }
105
+ }
106
+ ]
107
+ }
108
+ end
109
+
110
+ it "returns as json" do
111
+ expect(
112
+ subject
113
+ ).to eq(
114
+ hash_including(
115
+ {
116
+ "data" => hash_including(
117
+ {
118
+ "type" => "accounts",
119
+ "attributes" => hash_including(
120
+ {
121
+ "email" => "kurtis@example.com",
122
+ "password" => an_instance_of(String)
123
+ }
124
+ )
125
+ }
126
+ ),
127
+ "meta" => {
128
+ "jsonapi_version" => "1.0"
129
+ },
130
+ "included" => [
131
+ {
132
+ "data" => {
133
+ "id" => "a",
134
+ "type" => "widget",
135
+ "attributes" => {
136
+ "title" => "Widget A"
137
+ }
138
+ }
139
+ }
140
+ ]
141
+ }
142
+ )
143
+ )
144
+ end
145
+ end
146
+ end
147
+ end
metadata ADDED
@@ -0,0 +1,179 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: smart_params
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Kurtis Rainbolt-Greene
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2018-04-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.16'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.16'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.7'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.7'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '12.2'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '12.2'
55
+ - !ruby/object:Gem::Dependency
56
+ name: pry
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.11'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.11'
69
+ - !ruby/object:Gem::Dependency
70
+ name: pry-doc
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.11'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.11'
83
+ - !ruby/object:Gem::Dependency
84
+ name: activesupport
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '5.1'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '5.1'
97
+ - !ruby/object:Gem::Dependency
98
+ name: dry-monads
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.4'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.4'
111
+ - !ruby/object:Gem::Dependency
112
+ name: dry-types
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.12'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.12'
125
+ - !ruby/object:Gem::Dependency
126
+ name: recursive-open-struct
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '1.1'
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '1.1'
139
+ description: Apply an organized and easy to maintain schema to request params
140
+ email:
141
+ - kurtis@rainbolt-greene.online
142
+ executables: []
143
+ extensions: []
144
+ extra_rdoc_files: []
145
+ files:
146
+ - README.md
147
+ - Rakefile
148
+ - lib/smart_params.rb
149
+ - lib/smart_params/error.rb
150
+ - lib/smart_params/error/invalid_property_type.rb
151
+ - lib/smart_params/field.rb
152
+ - lib/smart_params/version.rb
153
+ - lib/smart_params/version_spec.rb
154
+ - lib/smart_params_spec.rb
155
+ homepage: http://krainboltgreene.github.io/smart_params
156
+ licenses:
157
+ - ISC
158
+ metadata: {}
159
+ post_install_message:
160
+ rdoc_options: []
161
+ require_paths:
162
+ - lib
163
+ required_ruby_version: !ruby/object:Gem::Requirement
164
+ requirements:
165
+ - - ">="
166
+ - !ruby/object:Gem::Version
167
+ version: '0'
168
+ required_rubygems_version: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - ">="
171
+ - !ruby/object:Gem::Version
172
+ version: '0'
173
+ requirements: []
174
+ rubyforge_project:
175
+ rubygems_version: 2.7.6
176
+ signing_key:
177
+ specification_version: 4
178
+ summary: Apply an organized and easy to maintain schema to request params
179
+ test_files: []