smart_params 5.1.0 → 6.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0cf4e5cc079291eb9eeaad6ca13e2a79d0bbf15b2c606060e996dd2e05425442
4
- data.tar.gz: 8a6fdf59b26cc8f385bb449be28dba4e5fe0841fda0f1f3bdc0e1cebfcbaf1c5
3
+ metadata.gz: 9f43877a67e8dd606f2f178c7d65f2ec41412f91dc4208cecc150ecfcaca7558
4
+ data.tar.gz: 8b5cf703c4eacabaa863e6efd8d938f4f87b417dfc6de1c80e6c6b89c4cef9be
5
5
  SHA512:
6
- metadata.gz: 73d1158ecfcd1b49da42ac85991fc9673be357e7b012716aa9981fef5cc14dc0d9592280b1443029c3c924f22bcaeb2de759a3d158f0fbe2cf876393aa7b13a6
7
- data.tar.gz: cb248bd6442bcab659cec87bc8e74daeb8c1304cb73009a2559b81d580276ba84392097692f6bbbc621f796826c0dfeba850ba49e62de657ca5ed817aff1aa17
6
+ metadata.gz: 4c3b9ae72f37f625a831a8b387457ee7f0425dabea293073b20df33796ec853af3e89f0d3279557640887ab743ae7673de3b620e691b4d5f0f050f41ef98a417
7
+ data.tar.gz: 3739b636c5f29142877b459d7914293970bbc97da5aad7fb1d6ed9a7636bb69d467bfb7644164ec346328f12fb2f58958b03af26e1487d22352676d7e148972e
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
  # }
@@ -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
+ raise MissingTypeException if type.nil? && !block_given?
28
+
29
+ root, *remaining = Kernel.Array(prefix)
30
+
31
+ path = [root, *remaining, name]
32
+
33
+ raise KeyAlreadyDefinedException.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,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
@@ -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.0"
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