smart_params 5.1.0 → 6.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0cf4e5cc079291eb9eeaad6ca13e2a79d0bbf15b2c606060e996dd2e05425442
4
- data.tar.gz: 8a6fdf59b26cc8f385bb449be28dba4e5fe0841fda0f1f3bdc0e1cebfcbaf1c5
3
+ metadata.gz: f5a757ef69761d43ce38da683b79975b5f2a9f712ac79824cd386ea0d1c8b12f
4
+ data.tar.gz: a569e347394533b8466d49cf4c4ed9ac3b1df6a3a6997640d8a2a46aeb142541
5
5
  SHA512:
6
- metadata.gz: 73d1158ecfcd1b49da42ac85991fc9673be357e7b012716aa9981fef5cc14dc0d9592280b1443029c3c924f22bcaeb2de759a3d158f0fbe2cf876393aa7b13a6
7
- data.tar.gz: cb248bd6442bcab659cec87bc8e74daeb8c1304cb73009a2559b81d580276ba84392097692f6bbbc621f796826c0dfeba850ba49e62de657ca5ed817aff1aa17
6
+ metadata.gz: 8d6e2886fa056a1d5d5f66a2af46eb0e0034d3a0adcb7d2c75d5f54e1663ea261f67da8b3ba0fb7ffd1bfc8c5ee8104f8aaf90315ffaf18bc954cb199be2cad0
7
+ data.tar.gz: 6fc0347f0a76bdc4ec4863924a3f29200563dd0f3550216294da9899f7a22fe8c46f508527b3d5fffad515c05377c758fc449ab1d164f75d1395726293679a3a
data/README.md CHANGED
@@ -8,22 +8,22 @@ Work smart, not strong. This gem gives developers an easy to understand and easy
8
8
  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:
9
9
 
10
10
  ``` ruby
11
- class CreateAccountSchema
12
- include SmartParams
13
-
14
- schema do
15
- field :data, subschema: true do
16
- field :id, type: Coercible::String, nullable: true
17
- field :type, type: Strict::String
18
- field :attributes, subschema: true, nullable: true do
19
- field :email, type: Strict::String, nullable: true
20
- field :username, type: Strict::String, nullable: true
21
- field :name, type: Strict::String, nullable: true
22
- field :password, type: Strict::String.default { SecureRandom.hex(32) }, nullable: true
11
+ module AccountSchema
12
+ include SmartParams::FluentLanguage
13
+
14
+ schema do |root|
15
+ field root, :data, subschema: true do |data|
16
+ field data, :id, type: Coercible::String.optional
17
+ field data, :type, type: Strict::String
18
+ field data, :attributes do |attributes|
19
+ field attributes, :email, type: Strict::String
20
+ field attributes, :username, type: Strict::String.optional
21
+ field attributes, :name, type: Strict::String.optional
22
+ field attributes, :password, type: Strict::String.default { SecureRandom.hex(32) }.optional
23
23
  end
24
24
  end
25
- field :meta, Strict::Hash, nullable: true
26
- field :included, type: Strict::Array, nullable: true
25
+ field :meta, Strict::Hash.optional
26
+ field :included, type: Strict::Array.optional
27
27
  end
28
28
  end
29
29
  ```
@@ -33,11 +33,11 @@ And now using that schema in the controller:
33
33
  ``` ruby
34
34
  class AccountsController < ApplicationController
35
35
  def create
36
- schema = CreateAccountSchema.new(params)
36
+ payload = SmartParams.from(AccountSchema, params)
37
37
  # parameters will be a SmartParams::Dataset, which will respond to the various fields you defined
38
38
 
39
39
  # Here we're pulling out the id and data properties defined above
40
- record = Account.create({id: schema.data.id, **schema.data.attributes})
40
+ record = Account.create({id: payload[:data][:id], **payload[:data][:attributes]})
41
41
 
42
42
  redirect_to account_url(record)
43
43
  end
@@ -49,43 +49,43 @@ Okay, so lets look at some scenarios.
49
49
  First, lets try an empty payload:
50
50
 
51
51
  ``` ruby
52
- CreateAccountSchema.new({})
53
- # raises SmartParams::Error::InvalidPropertyType, keychain: [:data], wanted: Hash, raw: nil
54
52
 
55
- # You can return the exception directly by providing :safe => false
53
+ SmartParams.from(AccountSchema, {}).payload
54
+ # returns [InvalidPropertyTypeException | MissingPropertyException]
56
55
 
57
- CreateAccountSchema.new({}, safe: false).payload
58
- # return #<SmartParams::Error::InvalidPropertyType... keychain: [:data], wanted: Hash, raw: nil>
56
+ SmartParams.validate!(AccountSchema, {})
57
+ # raises SmartParams::InvalidPayloadException(failures: [InvalidPropertyTypeException | MissingPropertyException])
59
58
  ```
60
59
 
61
60
  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:
62
61
 
63
62
  ``` ruby
64
- CreateAccountSchema.new({data: ""})
65
- # raise SmartParams::Error::InvalidPropertyType, keychain: [:data], wanted: Hash, raw: ""
63
+ SmartParams.from(AccountSchema, {data: ""})
64
+ # returns [MissingPropertyException(path: [:data], last: {data: ""})]
66
65
  ```
67
66
 
68
67
  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:
69
68
 
70
69
 
71
70
  ``` ruby
72
- schema = CreateAccountSchema.new({
71
+ payload = SmartParams.from(AccountSchema, {
73
72
  data: {
74
- type: "accounts"
73
+ type: "accounts",
74
+ attributes: {
75
+ email: "kurtis@example.com"
76
+ }
75
77
  }
76
78
  })
77
79
 
78
- schema.payload.data.type
80
+ payload[:data][:type]
79
81
  # "accounts"
80
82
 
81
- schema.data.type
82
- # "accounts"
83
-
84
- schema.as_json
83
+ payload.as_json
85
84
  # {
86
85
  # "data" => {
87
86
  # "type" => "accounts",
88
87
  # "attributes" => {
88
+ # "email" => "kurtis@example.com",
89
89
  # "password" => "1a6c3ffa4e96ad1660cb819f52a3393d924ac20073e84a9a6943a721d49bab38"
90
90
  # }
91
91
  # }
@@ -136,7 +136,7 @@ Or install it yourself with:
136
136
 
137
137
  ## Contributing
138
138
 
139
- 1. Read the [Code of Conduct](/CONDUCT)
139
+ 1. Read the [Code of Conduct](/CONDUCT.md)
140
140
  2. Fork it
141
141
  3. Create your feature branch (`git checkout -b my-new-feature`)
142
142
  4. Test your code: `rake spec`
@@ -2,156 +2,53 @@
2
2
 
3
3
  module SmartParams
4
4
  class Field
5
- attr_reader :keychain
6
- attr_reader :subfields
5
+ attr_reader :path
7
6
  attr_reader :type
8
- attr_reader :nullable
9
- attr_reader :key
10
7
 
11
- def inspect
12
- "#<#{self.class.name}:#{__id__} #{[
13
- ('subschema' if @subschema),
14
- ("#/#{@keychain.join('/')}" if @keychain),
15
- ("-> #{type.name}" if @type),
16
- ("= #{@value.inspect}" if @value)
17
- ].compact.join(' ')}>"
18
- end
19
-
20
- def initialize(keychain:, type:, key: nil, subschema: false, nullable: false, &nesting)
21
- @key = key
22
- @keychain = Array(keychain)
23
- @subfields = Set.new
8
+ def initialize(path:, type:, optional: false, **)
9
+ @path = path
24
10
  @type = type
25
- @nullable = nullable
26
- @subschema = subschema
27
- @specified = false
28
- @dirty = false
29
-
30
- instance_eval(&nesting) if nesting
31
-
32
- if subschema
33
- @type = @type.schema(subfields.reduce({}) do |mapping, field|
34
- mapping.merge("#{field.key}#{'?' if field.nullable}": field.type)
35
- end).with_key_transform(&:to_sym)
11
+ @optional = !!optional
12
+ end
13
+
14
+ def map(raw)
15
+ case [*dig_until(@path, raw), @type, @type&.optional? || @optional]
16
+ in [:error, last, _, false]
17
+ [:error, SmartParams::MissingPropertyException.new(path:, last:)]
18
+ in [:error, _, _, true]
19
+ :skip
20
+ in [:ok, value, nil, _]
21
+ [:ok, value]
22
+ in [:ok, value, type, _]
23
+ type.try(value)
36
24
  end
37
- @type = @type.optional if nullable
25
+ rescue Dry::Types::ConstraintError => constraint_error
26
+ Dry::Types::Result::Failure.new(value, constraint_error)
38
27
  end
39
28
 
40
- def deep?
41
- # We check @specified directly because we want to know if ANY
42
- # subfields have been passed, not just ones that match the schema.
43
- return false if nullable? && @specified
29
+ def update_in(result, value)
30
+ *body, butt = @path
44
31
 
45
- subfields.present?
46
- end
32
+ body.reduce(result) do |mapping, key|
33
+ mapping[key]
34
+ end.store(butt, value)
47
35
 
48
- def root?
49
- keychain.empty?
36
+ result
50
37
  end
51
38
 
52
- def value
53
- @value || ({} if root?)
54
- end
55
-
56
- def nullable?
57
- !!@nullable
58
- end
59
-
60
- def specified?
61
- if nullable?
62
- !!@specified && clean?
63
- else
64
- !!@specified
65
- end
66
- end
67
-
68
- # For nullable hashes: Any keys not in the schema make the hash dirty.
69
- # If a key is found that matches the schema, we can consider the hash
70
- # clean.
71
- def dirty?
72
- !!@dirty
73
- end
74
-
75
- def clean?
76
- return false if dirty?
77
- return true if empty? || subfields.reject(&:empty?).any?
78
-
79
- false
80
- end
81
-
82
- # Check if we should consider this value even when empty.
83
- def allow_empty?
84
- return true if specified? && nullable?
85
-
86
- subfields.any?(&:allow_empty?)
87
- end
88
-
89
- def claim(raw)
90
- return type[dug(raw)] if deep?
91
-
92
- @value = type[dug(raw)]
93
- rescue Dry::Types::ConstraintError => _constraint_exception
94
- raise SmartParams::Error::InvalidPropertyType.new(keychain:, wanted: type, raw: keychain.empty? ? raw : raw.dig(*keychain))
95
- rescue Dry::Types::MissingKeyError => missing_key_exception
96
- raise SmartParams::Error::InvalidPropertyType.new(keychain:, wanted: type, raw: keychain.empty? ? raw : raw.dig(*keychain), missing_key: missing_key_exception.key)
97
- end
98
-
99
- def to_hash
100
- keychain.reverse.reduce(value) do |accumulation, key|
101
- { key => accumulation }
102
- end
103
- end
104
-
105
- def empty?
106
- value.nil?
107
- end
108
-
109
- # Should this field be removed from resulting hash?
110
- def removable?
111
- empty? && !allow_empty?
112
- end
113
-
114
- def weight
115
- keychain.map(&:to_s)
116
- end
117
-
118
- private def field(key, subschema: false, type: SmartParams::Hash, nullable: false, &subfield)
119
- @subfields << self.class.new(key:, keychain: [*keychain, key], type:, nullable:, subschema:, &subfield)
120
- end
121
-
122
- private def subschema(key, nullable: false, &subfield)
123
- field(key, subschema: true, type: SmartParams::Hash, nullable:, &subfield)
124
- end
125
-
126
- # Very busy method with recent changes. TODO: clean-up
127
- private def dug(raw)
128
- return raw if keychain.empty?
129
-
130
- # If value provided is a hash, check if it's dirty. See #dirty? for
131
- # more info.
132
- if nullable?
133
- hash = raw.dig(*keychain)
134
- if hash.respond_to?(:keys)
135
- others = hash.keys - [keychain.last]
136
- @dirty = others.any?
39
+ private def dig_until(keychain, raw)
40
+ keychain.reduce([:ok, raw]) do |result, key|
41
+ case result
42
+ in [:ok, current]
43
+ if current.respond_to?(:key?) && current.key?(key)
44
+ [:ok, current[key]]
45
+ else
46
+ [:error, current]
47
+ end
48
+ in [:error, exception]
49
+ [:error, exception]
137
50
  end
138
51
  end
139
-
140
- # Trace the keychain to find out if the field is explicitly set in the
141
- # input hash.
142
- at = raw
143
- exact = true
144
- keychain.each do |key|
145
- if at.respond_to?(:key?) && at.key?(key)
146
- at = at[key]
147
- else
148
- exact = false
149
- break
150
- end
151
- end
152
- @specified = exact
153
-
154
- raw.dig(*keychain)
155
52
  end
156
53
  end
157
54
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartParams
4
+ module FluentLanguage
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ include Dry.Types()
9
+
10
+ mattr_accessor :namespaces
11
+ end
12
+
13
+ class_methods do
14
+ private def schema(namespace = :default)
15
+ self.namespaces ||= {}
16
+
17
+ raise SmartParams::NamespaceAlreadyDefinedException.new(namespace:) if self.namespaces.key?(namespace)
18
+
19
+ self.namespaces[namespace] = []
20
+
21
+ yield(namespace)
22
+
23
+ self.namespaces
24
+ end
25
+
26
+ private def field(prefix, name, type = nil, **)
27
+ root, *remaining = Kernel.Array(prefix)
28
+
29
+ path = [root, *remaining, name]
30
+
31
+ raise SmartParams::MissingTypeAnnotationException.new(path:) if type.nil? && !block_given?
32
+
33
+ raise SmartParams::PathAlreadyDefinedException.new(path:) if self.namespaces[root].any? { |field| field.path == path }
34
+
35
+ self.namespaces[root] = [
36
+ *self.namespaces[root],
37
+ SmartParams::Field.new(path: [*remaining, name], type:, subschema: block_given?, **)
38
+ ]
39
+
40
+ yield(path) if block_given?
41
+
42
+ self.namespaces[root]
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartParams
4
+ class InvalidPayloadException < StandardError
5
+ attr_reader :failures
6
+
7
+ def initialize(failures:)
8
+ @failures = failures
9
+ super(message)
10
+ end
11
+
12
+ def message
13
+ "structure failed to validate: \n\t#{@failures.map(&:message).join("\n\t")}"
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartParams
4
+ class InvalidPropertyTypeException < StandardError
5
+ attr_reader :path
6
+ attr_reader :wanted
7
+ attr_reader :raw
8
+ attr_reader :type
9
+ attr_reader :grievance
10
+
11
+ def initialize(path:, wanted:, raw:, grievance:)
12
+ @path = path
13
+ @wanted = wanted
14
+ @raw = raw
15
+ @type = raw.inspect
16
+ @grievance = grievance
17
+ super(message)
18
+ end
19
+
20
+ def message
21
+ "expected /#{path.join('/')} to be #{wanted.name}, but is #{type} and #{grievance}"
22
+ end
23
+
24
+ def as_json
25
+ {
26
+ "path" => path,
27
+ "wanted" => wanted,
28
+ "grievance" => grievance,
29
+ "raw" => raw
30
+ }
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartParams
4
+ class MissingPropertyException < StandardError
5
+ attr_accessor :path
6
+ attr_accessor :last
7
+
8
+ def initialize(path:, last:)
9
+ @path = path
10
+ @last = last
11
+ super(message)
12
+ end
13
+
14
+ def message
15
+ "/#{@path.join('/')} is missing from the structure, last node was #{@last.inspect}"
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartParams
4
+ class MissingTypeAnnotationException < StandardError
5
+ attr_accessor :path
6
+
7
+ def initialize(path:)
8
+ @path = path
9
+ super(message)
10
+ end
11
+
12
+ def message
13
+ "/#{@path.join('/')} was expected to define a type or a block, but did neither"
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartParams
4
+ class NamespaceAlreadyDefinedException < StandardError
5
+ def initialize(namespace:)
6
+ @namespace = namespace
7
+ super(message)
8
+ end
9
+
10
+ def message
11
+ "#{@namespace} was already taken as a schema namespace"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartParams
4
+ class NoMatchingNamespaceException < StandardError
5
+ def initialize(namespace:, available:)
6
+ @namespace = namespace
7
+ @available = available
8
+ super(message)
9
+ end
10
+
11
+ def message
12
+ "#{@namespace} does not exist, only #{@available.inspect}"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartParams
4
+ class PathAlreadyDefinedException < StandardError
5
+ def initialize(path:)
6
+ @path = path
7
+ super(message)
8
+ end
9
+
10
+ def message
11
+ "/#{@path.join('/')} was already taken as a field path"
12
+ end
13
+ end
14
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SmartParams
4
- VERSION = "5.1.0"
4
+ VERSION = "6.0.1"
5
5
  end
data/lib/smart_params.rb CHANGED
@@ -1,108 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry-types"
4
- require "recursive-open-struct"
5
4
  require "active_support/concern"
6
5
  require "active_support/core_ext/object"
7
- require "active_support/core_ext/module/delegation"
6
+ require "active_support/core_ext/module"
8
7
 
9
8
  module SmartParams
10
- extend ActiveSupport::Concern
11
- include Dry.Types()
12
-
9
+ require_relative "smart_params/invalid_payload_exception"
10
+ require_relative "smart_params/invalid_property_type_exception"
11
+ require_relative "smart_params/missing_property_exception"
12
+ require_relative "smart_params/namespace_already_defined_exception"
13
+ require_relative "smart_params/no_matching_namespace_exception"
13
14
  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, name: :default)
22
- @safe = safe
23
- @raw = raw
24
- @schema = self.class.instance_variable_get(:@schema)[name]
25
-
26
- @fields = [@schema, *unfold(@schema.subfields)].sort_by(&:weight).each { |field| field.claim(raw) }
27
- rescue SmartParams::Error::InvalidPropertyType => invalid_property_exception
28
- raise invalid_property_exception if safe?
29
-
30
- @exception = invalid_property_exception
31
- end
32
-
33
- def inspect
34
- "#<#{self.class.name}:#{__id__} @fields=#{@fields.inspect} @raw=#{@raw.inspect}>"
35
- end
36
-
37
- def payload
38
- if @exception.present?
39
- @exception
40
- else
41
- RecursiveOpenStruct.new(structure)
15
+ require_relative "smart_params/fluent_language"
16
+
17
+ def self.validate!(schema, raw, namespace = :default)
18
+ case map(fetch_namespace(schema, namespace), raw)
19
+ in [result, []]
20
+ result
21
+ in [_, failures]
22
+ raise InvalidPayloadException.new(failures:)
42
23
  end
43
24
  end
44
25
 
45
- def to_hash(options = nil)
46
- if @exception.present?
47
- @exception.as_json(options)
48
- else
49
- structure.as_json(options) || {}
26
+ def self.from(schema, raw, namespace = :default)
27
+ case map(fetch_namespace(schema, namespace), raw)
28
+ in [result, []]
29
+ result
30
+ in [_, failures]
31
+ failures
50
32
  end
51
33
  end
52
- alias as_json to_hash
53
34
 
54
- delegate :[], to: :to_hash
55
- delegate :fetch, to: :to_hash
56
- delegate :fetch_values, to: :to_hash
57
- delegate :merge, to: :to_hash
58
- delegate :keys, to: :to_hash
59
- delegate :key?, to: :to_hash
60
- delegate :has_key?, to: :to_hash
61
- delegate :values, to: :to_hash
62
- delegate :value?, to: :to_hash
63
- delegate :has_value?, to: :to_hash
64
- delegate :dig, to: :to_hash
65
- delegate :to_s, to: :to_hash
66
-
67
- def respond_to_missing?(name, include_private = false)
68
- payload.respond_to?(name) || super
69
- end
70
-
71
- def method_missing(name, *arguments, &)
72
- if payload.respond_to?(name)
73
- payload.public_send(name)
74
- else
75
- super
76
- end
77
- end
78
-
79
- # This function basically takes a list of fields and reduces them into a tree of values
80
- private def structure
81
- fields
82
- .reject(&:removable?)
83
- .map(&:to_hash)
84
- .reduce(&:deep_merge)
85
- end
86
-
87
- # This funcion takes a nested field tree and turns it into a list of fields
88
- private def unfold(subfields)
89
- subfields.to_a.reduce([]) do |list, field|
90
- if field.deep?
91
- [*list, field, *unfold(field.subfields.to_a)]
92
- else
93
- [*list, field]
35
+ private_class_method def self.map(fields, raw)
36
+ fields.reduce([Hash.new { |h, k| h[k] = h.class.new(&h.default_proc) }, []]) do |(result, failures), field|
37
+ case field.map(raw)
38
+ in :skip
39
+ [result, failures]
40
+ in [:ok, value]
41
+ [field.update_in(result, value), failures]
42
+ in Dry::Types::Result::Success => success
43
+ [field.update_in(result, success.input), failures]
44
+ in Dry::Types::Result::Failure => failure
45
+ [result, [*failures, InvalidPropertyTypeException.new(path: field.path, wanted: field.type, raw: failure.input, grievance: failure.error)]]
46
+ in [:error, value]
47
+ [result, [*failures, value]]
94
48
  end
95
- end.flatten
96
- end
97
-
98
- private def safe?
99
- @safe
49
+ end
100
50
  end
101
51
 
102
- class_methods do
103
- def schema(name: :default, &definitions)
104
- @schema ||= {}
105
- @schema[name] = Field.new(keychain: [], type: SmartParams::Hash, subschema: false, &definitions)
106
- end
52
+ private_class_method def self.fetch_namespace(schema, namespace)
53
+ schema.namespaces[namespace] || raise(NoMatchingNamespaceException.new(namespace:, available: schema.namespaces.keys))
107
54
  end
108
55
  end