view_component_reducible 0.1.2 → 0.1.4

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: a699ce3503948db085a77d5d187e725157966bf53a132e9d9124a772db648f1c
4
- data.tar.gz: ba68826392cd3ea83ca6ceea8f8f753d893f013467d61eff20f3085fd40c9e0c
3
+ metadata.gz: 6b8576a2267bf71dae86a6339162039c3b2effa03bbd25f0eefb1fa378481244
4
+ data.tar.gz: eb2629af05937f40b4c87731f062f32d2640bb7a78cc0ceff20a4d6d4188c4f3
5
5
  SHA512:
6
- metadata.gz: 9eca692a8a5b7185869237597cb79d199d4e1e036501f2c9c2574e40ea643e99535202f31fb44198a51e293e2fd5078adefd2e65756bd95fc8732917a688b892
7
- data.tar.gz: bfe0b991478a5a8788f2b2f9c69f7705d5ad9bf1de828d76d50c5b652a8d57bb23c18dfe883289bf231c54b77885e311c35485e9d4a7fc37f200b87d113033a8
6
+ metadata.gz: 554e95d73a13b21d4bf47185b669d63cfec7a5846668de2889b9766efb30d80e872f2739ac33e0c70af99f4673aaf74bc17cf06725c9ea68ea9dfee53d637312
7
+ data.tar.gz: a507b13dae0abeefa7961ac0dddf5679d22bc90e64eb6a6324481b615aa6ee68e1e81e2b6c1c3ea5383c50175940898e91b7f57fb91d2c8736d23cd20908f4f1
@@ -4,8 +4,14 @@ module ViewComponentReducible
4
4
  module Adapter
5
5
  # Base adapter interface for envelope serialization.
6
6
  class Base
7
+ # @return [String]
8
+ def self.state_param_name
9
+ 'vcr_state'
10
+ end
11
+
7
12
  # @param secret [String]
8
- def initialize(secret:)
13
+ # @param _kwargs [Hash]
14
+ def initialize(secret:, **_kwargs)
9
15
  @secret = secret
10
16
  end
11
17
 
@@ -23,7 +23,7 @@ module ViewComponentReducible
23
23
  # @param request [ActionDispatch::Request]
24
24
  # @return [Hash]
25
25
  def load(request:)
26
- signed = request.params.fetch('vcr_state')
26
+ signed = request.params.fetch(self.class.state_param_name)
27
27
  verifier.verify(signed)
28
28
  end
29
29
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/message_verifier'
5
+ require 'securerandom'
6
+ require 'json'
7
+
8
+ module ViewComponentReducible
9
+ module Adapter
10
+ # Redis adapter storing envelope outside of the session.
11
+ class Redis < Base
12
+ DEFAULT_TTL = 900
13
+ DEFAULT_NAMESPACE = 'vcr'
14
+
15
+ def self.state_param_name
16
+ 'vcr_state_key'
17
+ end
18
+
19
+ # @param secret [String]
20
+ # @param redis [Object, nil]
21
+ # @param redis_url [String, nil]
22
+ # @param redis_ttl [Integer, nil]
23
+ # @param redis_namespace [String, nil]
24
+ def initialize(secret:, redis: nil, redis_url: nil, redis_ttl: DEFAULT_TTL, redis_namespace: DEFAULT_NAMESPACE)
25
+ super(secret:)
26
+ @redis = redis || build_client(redis_url)
27
+ @redis_ttl = redis_ttl
28
+ @redis_namespace = redis_namespace
29
+ end
30
+
31
+ # @return [ActiveSupport::MessageVerifier]
32
+ def verifier
33
+ @verifier ||= ActiveSupport::MessageVerifier.new(@secret, digest: 'SHA256', serializer: JSON)
34
+ end
35
+
36
+ # @param envelope [Hash]
37
+ # @param request [ActionDispatch::Request]
38
+ # @return [String]
39
+ def dump(envelope, request:)
40
+ _ = request
41
+ key = SecureRandom.hex(16)
42
+ write(key, JSON.generate(envelope))
43
+ verifier.generate({ 'k' => key })
44
+ end
45
+
46
+ # @param request [ActionDispatch::Request]
47
+ # @return [Hash]
48
+ def load(request:)
49
+ signed = request.params.fetch(self.class.state_param_name)
50
+ payload = verifier.verify(signed)
51
+ key = payload.fetch('k')
52
+ raw = @redis.get(redis_key(key))
53
+ raise KeyError, "Missing envelope for #{key}" if raw.nil?
54
+
55
+ JSON.parse(raw)
56
+ end
57
+
58
+ private
59
+
60
+ def build_client(redis_url)
61
+ require 'redis'
62
+ return ::Redis.new if redis_url.nil? || redis_url == ''
63
+
64
+ ::Redis.new(url: redis_url)
65
+ rescue LoadError
66
+ raise LoadError, 'Redis adapter requires the redis gem. Add `gem "redis"` to your bundle.'
67
+ end
68
+
69
+ def write(key, payload)
70
+ if @redis_ttl
71
+ @redis.setex(redis_key(key), @redis_ttl, payload)
72
+ else
73
+ @redis.set(redis_key(key), payload)
74
+ end
75
+ end
76
+
77
+ def redis_key(key)
78
+ "#{@redis_namespace}:#{key}"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -8,6 +8,10 @@ module ViewComponentReducible
8
8
  module Adapter
9
9
  # Session adapter storing envelope in server session.
10
10
  class Session < Base
11
+ def self.state_param_name
12
+ 'vcr_state_key'
13
+ end
14
+
11
15
  # @return [ActiveSupport::MessageVerifier]
12
16
  def verifier
13
17
  @verifier ||= ActiveSupport::MessageVerifier.new(@secret, digest: 'SHA256', serializer: JSON)
@@ -18,14 +22,17 @@ module ViewComponentReducible
18
22
  # @return [String]
19
23
  def dump(envelope, request:)
20
24
  key = SecureRandom.hex(16)
21
- request.session["vcr:#{key}"] = envelope
25
+ session = request.session
26
+ session_keys = session.respond_to?(:keys) ? session.keys : session.to_hash.keys
27
+ session_keys.grep(/\Avcr:/).each { |session_key| session.delete(session_key) }
28
+ session["vcr:#{key}"] = envelope
22
29
  verifier.generate({ 'k' => key })
23
30
  end
24
31
 
25
32
  # @param request [ActionDispatch::Request]
26
33
  # @return [Hash]
27
34
  def load(request:)
28
- signed = request.params.fetch('vcr_state')
35
+ signed = request.params.fetch(self.class.state_param_name)
29
36
  payload = verifier.verify(signed)
30
37
  key = payload.fetch('k')
31
38
  request.session.fetch("vcr:#{key}")
@@ -4,11 +4,15 @@ module ViewComponentReducible
4
4
  # Global configuration for adapter and secrets.
5
5
  class Configuration
6
6
  # @return [Class]
7
- attr_accessor :adapter, :secret
7
+ attr_accessor :adapter, :secret, :redis, :redis_url, :redis_ttl, :redis_namespace
8
8
 
9
9
  def initialize
10
10
  @adapter = Adapter::Session
11
11
  @secret = nil
12
+ @redis = nil
13
+ @redis_url = nil
14
+ @redis_ttl = nil
15
+ @redis_namespace = nil
12
16
  end
13
17
 
14
18
  # Build adapter instance for a controller request.
@@ -19,7 +23,13 @@ module ViewComponentReducible
19
23
  resolved_secret = secret || default_secret
20
24
  raise 'ViewComponentReducible secret is missing' if resolved_secret.nil?
21
25
 
22
- (adapter_class || adapter).new(secret: resolved_secret)
26
+ kwargs = { secret: resolved_secret }
27
+ kwargs[:redis] = redis if redis
28
+ kwargs[:redis_url] = redis_url if redis_url
29
+ kwargs[:redis_ttl] = redis_ttl unless redis_ttl.nil?
30
+ kwargs[:redis_namespace] = redis_namespace if redis_namespace
31
+
32
+ (adapter_class || adapter).new(**kwargs)
23
33
  end
24
34
 
25
35
  private
@@ -24,10 +24,11 @@ module ViewComponentReducible
24
24
  def vcr_dispatch_form(state:, msg_type:, msg_payload: {}, target_path: nil, url: '/vcr/dispatch', &block)
25
25
  payload = msg_payload.is_a?(String) ? msg_payload : JSON.generate(msg_payload)
26
26
  resolved_target = target_path || vcr_envelope_path || instance_variable_get(:@vcr_current_path) || 'root'
27
+ state_param = ViewComponentReducible.config.adapter.state_param_name
27
28
 
28
- form_tag(url, method: :post, data: { vcr_form: true }) do
29
+ form_tag(url, method: :post, data: { vcr_form: true, vcr_state_param: state_param }) do
29
30
  body = [
30
- hidden_field_tag('vcr_state', state),
31
+ hidden_field_tag(state_param, state),
31
32
  hidden_field_tag('vcr_msg_type', msg_type),
32
33
  hidden_field_tag('vcr_msg_payload', payload),
33
34
  hidden_field_tag('vcr_target_path', resolved_target),
@@ -74,7 +75,8 @@ module ViewComponentReducible
74
75
  if (payload.state) {
75
76
  var boundary = document.querySelector('[data-vcr-path="' + targetPath + '"]');
76
77
  if (boundary) {
77
- boundary.querySelectorAll('input[name="vcr_state"]').forEach(function(input) {
78
+ var stateParam = form.dataset.vcrStateParam || "vcr_state";
79
+ boundary.querySelectorAll('input[name="' + stateParam + '"]').forEach(function(input) {
78
80
  input.value = payload.state;
79
81
  });
80
82
  }
@@ -20,17 +20,59 @@ module ViewComponentReducible
20
20
  type = params.fetch('vcr_msg_type')
21
21
  payload_json = params['vcr_msg_payload']
22
22
  payload = payload_json && payload_json != '' ? JSON.parse(payload_json) : {}
23
- new(type:, payload:)
23
+ build(type:, payload:)
24
24
  end
25
25
 
26
- private
26
+ # Build a Msg with a normalized payload object.
27
+ # @param type [String, Symbol]
28
+ # @param payload [Object, nil]
29
+ # @return [ViewComponentReducible::Msg]
30
+ def self.build(type:, payload: nil)
31
+ return new(type:, payload:) if payload.is_a?(Data)
27
32
 
28
- def normalized_type
33
+ normalized = normalize_type(type)
34
+ new(type:, payload: self::Payload.from_hash(normalized, payload))
35
+ end
36
+
37
+ # @param type [String, Symbol]
38
+ # @return [Symbol]
39
+ def self.normalize_type(type)
29
40
  type.to_s
30
41
  .gsub(/([a-z\d])([A-Z])/, '\1_\2')
31
42
  .tr('-', '_')
32
43
  .downcase
33
44
  .to_sym
34
45
  end
46
+
47
+ private
48
+
49
+ def normalized_type
50
+ self.class.normalize_type(type)
51
+ end
52
+ end
53
+
54
+ class Msg
55
+ module Payload
56
+ Empty = Data.define
57
+ Value = Data.define(:value)
58
+
59
+ def self.from_hash(_type, payload)
60
+ payload_hash = payload.is_a?(Hash) ? payload.transform_keys(&:to_s) : nil
61
+
62
+ return Empty.new if payload.nil? || (payload.respond_to?(:empty?) && payload.empty?)
63
+ return Value.new(value: payload) unless payload_hash
64
+
65
+ build_generic(payload_hash)
66
+ end
67
+
68
+ def self.build_generic(payload_hash)
69
+ return Empty.new if payload_hash.empty?
70
+
71
+ keys = payload_hash.keys.map(&:to_sym)
72
+ klass = Data.define(*keys)
73
+ values = keys.to_h { |key| [key, payload_hash[key.to_s]] }
74
+ klass.new(**values)
75
+ end
76
+ end
35
77
  end
36
78
  end
@@ -48,34 +48,53 @@ module ViewComponentReducible
48
48
  schema = component_klass.vcr_state_schema
49
49
  state = schema.build_data(env['data'])
50
50
 
51
- new_state = component.reduce(state, msg)
52
- env['data'] = normalize_state(new_state, schema)
53
- effects = build_effects(component, schema, env['data'], msg)
51
+ reducer_result = component.reduce(state, msg)
52
+ reduced_state, reducer_effects = case reducer_result
53
+ in state_only unless reducer_result.is_a?(Array)
54
+ [state_only, []]
55
+ in [state_only]
56
+ [state_only, []]
57
+ in [state_only, *effects]
58
+ [state_only, effects]
59
+ end
60
+ env['data'] = normalize_state(reduced_state, schema)
61
+ effects = normalize_effects(reducer_effects) + normalize_effects(build_effects(component, schema, env['data'],
62
+ msg))
54
63
 
55
64
  run_effects(component_klass, env, effects, controller)
56
65
  end
57
66
 
58
67
  def run_effects(component_klass, env, effects, controller)
59
- return env if effects.nil? || effects.empty?
68
+ effects_queue = normalize_effects(effects).dup
69
+ return env if effects_queue.empty?
60
70
 
61
- effects_queue = effects.dup
62
71
  steps = 0
63
72
 
64
73
  while (eff = effects_queue.shift)
65
74
  steps += 1
66
75
  raise 'Too many effect steps' if steps > MAX_EFFECT_STEPS
67
76
 
68
- follow_msg = eff.call(controller: controller, envelope: env)
77
+ follow_msg = resolve_effect_msg(eff, controller, env)
69
78
  next unless follow_msg
79
+ raise ArgumentError, 'Effect must return a Msg' unless follow_msg.is_a?(Msg)
70
80
 
71
81
  component = component_klass.new(vcr_envelope: env)
72
82
  schema = component_klass.vcr_state_schema
73
83
  state = schema.build_data(env['data'])
74
84
 
75
- new_state = component.reduce(state, follow_msg)
76
- env['data'] = normalize_state(new_state, schema)
77
- new_effects = build_effects(component, schema, env['data'], follow_msg)
78
- effects_queue.concat(Array(new_effects))
85
+ reducer_result = component.reduce(state, follow_msg)
86
+ reduced_state, reducer_effects = case reducer_result
87
+ in state_only unless reducer_result.is_a?(Array)
88
+ [state_only, []]
89
+ in [state_only]
90
+ [state_only, []]
91
+ in [state_only, *effects]
92
+ [state_only, effects]
93
+ end
94
+ env['data'] = normalize_state(reduced_state, schema)
95
+ new_effects = normalize_effects(reducer_effects) + normalize_effects(build_effects(component, schema,
96
+ env['data'], follow_msg))
97
+ effects_queue.concat(new_effects)
79
98
  end
80
99
 
81
100
  env
@@ -110,6 +129,17 @@ module ViewComponentReducible
110
129
  Array(component.effects(state, msg))
111
130
  end
112
131
 
132
+ def normalize_effects(effects)
133
+ Array(effects).compact
134
+ end
135
+
136
+ def resolve_effect_msg(effect, controller, env)
137
+ return effect if effect.is_a?(Msg)
138
+ return effect.call(controller: controller, envelope: env) if effect.respond_to?(:call)
139
+
140
+ raise ArgumentError, 'Effect must respond to #call or be a Msg'
141
+ end
142
+
113
143
  def deep_dup(obj)
114
144
  Marshal.load(Marshal.dump(obj))
115
145
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ViewComponentReducible
4
- VERSION = '0.1.2'
4
+ VERSION = '0.1.4'
5
5
  end
@@ -6,6 +6,7 @@ require_relative 'view_component_reducible/msg'
6
6
  require_relative 'view_component_reducible/adapter/base'
7
7
  require_relative 'view_component_reducible/adapter/hidden_field'
8
8
  require_relative 'view_component_reducible/adapter/session'
9
+ require_relative 'view_component_reducible/adapter/redis'
9
10
  require_relative 'view_component_reducible/helpers'
10
11
  require_relative 'view_component_reducible/state/schema'
11
12
  require_relative 'view_component_reducible/state/dsl'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: view_component_reducible
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - manabeai
@@ -65,6 +65,20 @@ dependencies:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
67
  version: '6.1'
68
+ - !ruby/object:Gem::Dependency
69
+ name: redis
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '4.0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '4.0'
68
82
  - !ruby/object:Gem::Dependency
69
83
  name: view_component
70
84
  requirement: !ruby/object:Gem::Requirement
@@ -94,6 +108,7 @@ files:
94
108
  - lib/view_component_reducible.rb
95
109
  - lib/view_component_reducible/adapter/base.rb
96
110
  - lib/view_component_reducible/adapter/hidden_field.rb
111
+ - lib/view_component_reducible/adapter/redis.rb
97
112
  - lib/view_component_reducible/adapter/session.rb
98
113
  - lib/view_component_reducible/component.rb
99
114
  - lib/view_component_reducible/configuration.rb