ruact 0.0.1 → 0.0.3

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.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +86 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1680 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +89 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +330 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +176 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +188 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +113 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +248 -0
  44. data/lib/ruact/server_functions/registry.rb +148 -0
  45. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  46. data/lib/ruact/server_functions/route_source.rb +201 -0
  47. data/lib/ruact/server_functions/snapshot.rb +195 -0
  48. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  49. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  50. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  51. data/lib/ruact/server_functions.rb +75 -0
  52. data/lib/ruact/version.rb +1 -1
  53. data/lib/ruact/view_helper.rb +17 -9
  54. data/lib/ruact.rb +85 -6
  55. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  56. data/lib/tasks/benchmark.rake +15 -11
  57. data/lib/tasks/ruact.rake +81 -0
  58. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  59. data/spec/fixtures/flight/README.md +55 -7
  60. data/spec/fixtures/flight/bigint.txt +1 -0
  61. data/spec/fixtures/flight/infinity.txt +1 -0
  62. data/spec/fixtures/flight/nan.txt +1 -0
  63. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  64. data/spec/fixtures/flight/undefined.txt +1 -0
  65. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  66. data/spec/ruact/client_manifest_spec.rb +108 -0
  67. data/spec/ruact/configuration_spec.rb +501 -0
  68. data/spec/ruact/controller_request_spec.rb +204 -0
  69. data/spec/ruact/controller_spec.rb +427 -39
  70. data/spec/ruact/doctor_spec.rb +118 -0
  71. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  72. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  73. data/spec/ruact/errors_spec.rb +95 -0
  74. data/spec/ruact/flight/renderer_spec.rb +14 -3
  75. data/spec/ruact/flight/serializer_spec.rb +129 -88
  76. data/spec/ruact/html_converter_spec.rb +183 -5
  77. data/spec/ruact/install_generator_spec.rb +93 -0
  78. data/spec/ruact/query_request_spec.rb +446 -0
  79. data/spec/ruact/query_spec.rb +105 -0
  80. data/spec/ruact/railtie_spec.rb +2 -3
  81. data/spec/ruact/render_context_spec.rb +58 -0
  82. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  83. data/spec/ruact/render_pipeline_spec.rb +784 -330
  84. data/spec/ruact/serializable_spec.rb +8 -8
  85. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  86. data/spec/ruact/server_function_name_spec.rb +53 -0
  87. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  88. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  89. data/spec/ruact/server_functions/codegen_spec.rb +429 -0
  90. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  91. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  92. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  93. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  94. data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
  95. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  96. data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
  97. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  98. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  99. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  100. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  101. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  102. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  103. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  104. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  105. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  106. data/spec/ruact/server_spec.rb +180 -0
  107. data/spec/ruact/server_upload_request_spec.rb +311 -0
  108. data/spec/ruact/view_helper_spec.rb +23 -17
  109. data/spec/spec_helper.rb +52 -1
  110. data/spec/support/fixtures/pixel.png +0 -0
  111. data/spec/support/flight_wire_parser.rb +135 -0
  112. data/spec/support/flight_wire_parser_spec.rb +93 -0
  113. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  114. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  115. data/spec/support/rails_stub.rb +75 -5
  116. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
  117. data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
  118. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
  120. data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
  121. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  122. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  123. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
  124. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
  125. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
  126. metadata +87 -6
  127. data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
  128. data/lib/ruact/component_registry.rb +0 -31
  129. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 8.3 — covers `Ruact::ServerAction` extended onto a Module: AC1
4
+ # (registration shape + no method defined on host module) and AC6 (guard-
5
+ # rail matrix mirroring the controller-DSL path adapted to module context).
6
+
7
+ require "spec_helper"
8
+
9
+ RSpec.describe Ruact::ServerAction, :story_8_3 do
10
+ describe "AC1 — extend Ruact::ServerAction + ruact_action registers a standalone host" do
11
+ it "registers a standalone host in Ruact.action_registry with controller: <the Module>" do
12
+ mod = Module.new do
13
+ extend Ruact::ServerAction
14
+
15
+ def self.name
16
+ "AC1RegistrationModule"
17
+ end
18
+
19
+ ruact_action(:standalone_create_post) { |_params| { ok: true } }
20
+ end
21
+
22
+ entries = Ruact.action_registry.entries
23
+ expect(entries).to include(:standalone_create_post)
24
+
25
+ entry = entries[:standalone_create_post]
26
+ expect(entry).to be_a(Ruact::ServerFunctions::RegistryEntry)
27
+ expect(entry.kind).to eq(:action)
28
+ expect(entry.controller).to be(mod)
29
+ expect(entry.controller).to be_a(Module)
30
+ expect(entry.controller).not_to be_a(Class)
31
+ expect(entry.js_identifier).to eq("standaloneCreatePost")
32
+ expect(entry.block).to be_a(Proc)
33
+ end
34
+
35
+ it "does NOT define an instance method on the host module — the block is reachable " \
36
+ "only through the gem endpoint, never as a Ruby method" do
37
+ mod = Module.new do
38
+ extend Ruact::ServerAction
39
+
40
+ def self.name
41
+ "AC1NoMethodModule"
42
+ end
43
+
44
+ ruact_action(:no_method_exposed) { |_params| nil }
45
+ end
46
+
47
+ expect(mod.respond_to?(:no_method_exposed)).to be(false)
48
+ expect(mod.instance_methods).not_to include(:no_method_exposed)
49
+ expect(mod.singleton_methods).not_to include(:no_method_exposed)
50
+ # Direct dispatch attempts (someone trying to `Mod.send(:no_method_exposed)`
51
+ # should fail with NoMethodError) — proves the surface is endpoint-only.
52
+ expect { mod.send(:no_method_exposed, {}) }.to raise_error(NoMethodError)
53
+ end
54
+ end
55
+
56
+ describe "AC6 — guard-rail matrix (mirror controller-DSL adapted to module context)" do
57
+ let(:host) do
58
+ Module.new do
59
+ extend Ruact::ServerAction
60
+
61
+ def self.name
62
+ "GuardRailModule"
63
+ end
64
+ end
65
+ end
66
+
67
+ it "raises ArgumentError when given a String instead of a Symbol" do
68
+ expect do
69
+ host.module_eval { ruact_action("create_post") { |_p| nil } }
70
+ end.to raise_error(ArgumentError, /ruact_action requires a Symbol/)
71
+ end
72
+
73
+ it "raises ArgumentError when the block is missing" do
74
+ expect { host.module_eval { ruact_action(:create_post) } }
75
+ .to raise_error(ArgumentError, /requires a block/)
76
+ end
77
+
78
+ it "raises ArgumentError when the block accepts no positional argument" do
79
+ expect do
80
+ host.module_eval { ruact_action(:create_post) {} } # no positional arg
81
+ end.to raise_error(ArgumentError, /exactly one positional parameter/)
82
+ end
83
+
84
+ it "raises ArgumentError when the block accepts more than one positional argument" do
85
+ expect do
86
+ host.module_eval { ruact_action(:create_post) { |_a, _b| nil } }
87
+ end.to raise_error(ArgumentError, /exactly one positional parameter/)
88
+ end
89
+
90
+ it "raises ArgumentError when the block has a required keyword argument" do
91
+ block_with_kwarg = ->(_p, required:) { required }
92
+ expect do
93
+ host.module_eval { ruact_action(:create_post, &block_with_kwarg) }
94
+ end.to raise_error(ArgumentError, /no required keyword arguments/)
95
+ end
96
+
97
+ it "ACCEPTS a block with a single positional arg" do
98
+ expect do
99
+ host.module_eval { ruact_action(:create_post) { |_p| nil } }
100
+ end.not_to raise_error
101
+ end
102
+
103
+ it "ACCEPTS a block with a splat positional arg" do
104
+ another_host = Module.new do
105
+ extend Ruact::ServerAction
106
+
107
+ def self.name
108
+ "SplatHost"
109
+ end
110
+ end
111
+ expect do
112
+ another_host.module_eval { ruact_action(:create_post) { |*_args| nil } }
113
+ end.not_to raise_error
114
+ end
115
+
116
+ it "ACCEPTS a block with optional keyword args" do
117
+ another_host = Module.new do
118
+ extend Ruact::ServerAction
119
+
120
+ def self.name
121
+ "OptKwHost"
122
+ end
123
+ end
124
+ expect do
125
+ another_host.module_eval { ruact_action(:create_post) { |_p, key: nil| key } }
126
+ end.not_to raise_error
127
+ end
128
+
129
+ it "raises Ruact::ConfigurationError for a bad naming-bridge symbol (:Create_Post)" do
130
+ expect do
131
+ host.module_eval { ruact_action(:Create_Post) { |_p| nil } }
132
+ end.to raise_error(Ruact::ConfigurationError) do |error|
133
+ expect(error.message).to include(":Create_Post")
134
+ expect(error.message).to include("GuardRailModule")
135
+ end
136
+ end
137
+
138
+ it "raises Ruact::ConfigurationError for a JS-reserved-word target (:class → \"class\")" do
139
+ expect do
140
+ host.module_eval { ruact_action(:class) { |_p| nil } }
141
+ end.to raise_error(Ruact::ConfigurationError, /JS reserved word/)
142
+ end
143
+
144
+ it "raises Ruact::ConfigurationError for a ruact-runtime-reserved target (:revalidate)" do
145
+ expect do
146
+ host.module_eval { ruact_action(:revalidate) { |_p| nil } }
147
+ end.to raise_error(Ruact::ConfigurationError) do |error|
148
+ expect(error.message).to include("revalidate")
149
+ end
150
+ end
151
+
152
+ it "raises Ruact::ConfigurationError for the second standalone host declaring the same symbol" do
153
+ host.module_eval { ruact_action(:dup_symbol) { |_p| nil } }
154
+ second_host = Module.new do
155
+ extend Ruact::ServerAction
156
+
157
+ def self.name
158
+ "SecondHostModule"
159
+ end
160
+ end
161
+ expect do
162
+ second_host.module_eval { ruact_action(:dup_symbol) { |_p| nil } }
163
+ end.to raise_error(Ruact::ConfigurationError) do |error|
164
+ expect(error.message).to include(":dup_symbol")
165
+ expect(error.message).to include("GuardRailModule")
166
+ expect(error.message).to include("SecondHostModule")
167
+ end
168
+ end
169
+
170
+ it "does NOT have a FRAMEWORK_RESERVED_METHODS check — standalone modules can use " \
171
+ "names that would clobber an ActionController method (e.g., :params), since the " \
172
+ "module has no ActionController surface" do
173
+ expect do
174
+ Module.new do
175
+ extend Ruact::ServerAction
176
+
177
+ def self.name
178
+ "FrameworkResvHost"
179
+ end
180
+
181
+ ruact_action(:params) { |_p| nil }
182
+ end
183
+ end.not_to raise_error
184
+ end
185
+
186
+ it "Story 8.3 review R4 — rejects Class hosts at first ruact_action call with a " \
187
+ "documented Ruact::ConfigurationError pointing the dev to `include Ruact::Controller`" do
188
+ klass = Class.new do
189
+ extend Ruact::ServerAction
190
+
191
+ def self.name
192
+ "WronglyExtendedClass"
193
+ end
194
+ end
195
+
196
+ expect do
197
+ klass.class_eval { ruact_action(:bad_host) { |_p| nil } }
198
+ end.to raise_error(Ruact::ConfigurationError) do |error|
199
+ expect(error.message).to include("WronglyExtendedClass")
200
+ expect(error.message).to include("standalone HOST MODULES")
201
+ expect(error.message).to include("include Ruact::Controller")
202
+ end
203
+ end
204
+
205
+ it "does NOT install a method_added hook — a later `def` on the host module does not " \
206
+ "raise (standalone modules don't define action methods at all)" do
207
+ expect do
208
+ Module.new do
209
+ extend Ruact::ServerAction
210
+
211
+ def self.name
212
+ "MethodAddedHost"
213
+ end
214
+
215
+ ruact_action(:registered_action) { |_p| nil }
216
+
217
+ # A later method definition on the module would not trigger anything
218
+ # — the registry holds the block; the module surface is irrelevant.
219
+ define_method(:registered_action) { :unrelated_def }
220
+ end
221
+ end.not_to raise_error
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 8.3 — `Ruact::ServerFunctions::StandaloneContext`: AC3 surface
4
+ # (exposes params/request/session/cookies/headers; render/redirect_to/head
5
+ # raise NoMethodError; current_user memoizes + falls back to env key +
6
+ # raises CurrentUserNotConfiguredError when neither path produces a value).
7
+
8
+ require "spec_helper"
9
+ require "action_controller"
10
+ require "action_dispatch"
11
+
12
+ module Ruact
13
+ module ServerFunctions
14
+ RSpec.describe StandaloneContext, :story_8_3 do
15
+ let(:env) { {} }
16
+ let(:request) do
17
+ ActionDispatch::Request.new(env.merge(
18
+ "REQUEST_METHOD" => "POST",
19
+ "rack.input" => StringIO.new("")
20
+ ))
21
+ end
22
+ let(:params) { ActionController::Parameters.new("title" => "Hello") }
23
+ let(:context) { described_class.new(params: params, request: request) }
24
+
25
+ describe "exposed surface (AC3)" do
26
+ it "exposes params (the action-call args, as ActionController::Parameters)" do
27
+ expect(context.params).to be_a(ActionController::Parameters)
28
+ expect(context.params[:title]).to eq("Hello")
29
+ end
30
+
31
+ it "exposes the live request" do
32
+ expect(context.request).to be(request)
33
+ end
34
+
35
+ it "exposes headers via request.headers" do
36
+ expect(context.headers).to be(request.headers)
37
+ end
38
+ end
39
+
40
+ describe "blocked surface — render / redirect_to / head (AC3)" do
41
+ it "render raises NoMethodError with the documented hint" do
42
+ expect { context.render(json: { ok: true }) }
43
+ .to raise_error(NoMethodError, /does not expose `render`/)
44
+ end
45
+
46
+ it "redirect_to raises NoMethodError with the documented hint" do
47
+ expect { context.redirect_to("/login") }
48
+ .to raise_error(NoMethodError, /does not expose `redirect_to`/)
49
+ end
50
+
51
+ it "head raises NoMethodError with the documented hint" do
52
+ expect { context.head(:no_content) }
53
+ .to raise_error(NoMethodError, /does not expose `head`/)
54
+ end
55
+ end
56
+
57
+ describe "current_user resolver path (AC3)" do
58
+ around do |example|
59
+ Ruact.instance_variable_set(:@config, nil)
60
+ Ruact.instance_variable_set(:@configured_at_least_once, false)
61
+ example.run
62
+ ensure
63
+ Ruact.instance_variable_set(:@config, nil)
64
+ Ruact.instance_variable_set(:@configured_at_least_once, false)
65
+ end
66
+
67
+ before do
68
+ # Silence the warn-on-reconfigure noise.
69
+ allow(Rails).to receive(:logger).and_return(Logger.new(IO::NULL))
70
+ end
71
+
72
+ it "raises Ruact::CurrentUserNotConfiguredError when no resolver is configured AND no env key is set" do
73
+ expect { context.current_user }.to raise_error(Ruact::CurrentUserNotConfiguredError) do |err|
74
+ expect(err.message).to include("Ruact.current_user requires Ruact.config.current_user_resolver")
75
+ expect(err.message).to include("Devise")
76
+ expect(err.message).to include("hand-rolled session")
77
+ end
78
+ end
79
+
80
+ it "returns the configured resolver's value, passing request.env to the lambda" do
81
+ user = Struct.new(:id).new(42)
82
+ Ruact.configure do |c|
83
+ c.current_user_resolver = lambda { |env_arg|
84
+ expect(env_arg).to be(request.env)
85
+ user
86
+ }
87
+ end
88
+ expect(context.current_user).to be(user)
89
+ end
90
+
91
+ it "memoizes current_user across multiple calls (resolver invoked once)" do
92
+ call_count = 0
93
+ Ruact.configure do |c|
94
+ c.current_user_resolver = lambda { |_env|
95
+ call_count += 1
96
+ :user
97
+ }
98
+ end
99
+ context.current_user
100
+ context.current_user
101
+ context.current_user
102
+ expect(call_count).to eq(1)
103
+ end
104
+
105
+ it "prefers an upstream-set request.env['ruact.current_user'] over the resolver" do
106
+ env["ruact.current_user"] = :upstream_user
107
+ resolver_called = false
108
+ Ruact.configure do |c|
109
+ c.current_user_resolver = lambda { |_env|
110
+ resolver_called = true
111
+ :resolver_user
112
+ }
113
+ end
114
+ expect(context.current_user).to eq(:upstream_user)
115
+ expect(resolver_called).to be(false)
116
+ end
117
+
118
+ it "falls back to the resolver when the env key is absent (not just nil)" do
119
+ # Pitfall: env.key? differs from env.fetch — a `nil` value should still
120
+ # prefer the env path. Verify by setting nil explicitly.
121
+ env["ruact.current_user"] = nil
122
+ Ruact.configure do |c|
123
+ c.current_user_resolver = ->(_env) { :resolver_user }
124
+ end
125
+ expect(context.current_user).to be_nil
126
+ end
127
+ end
128
+
129
+ describe "__ruact_current_user_read? (Pitfall #4 dev warning flag)" do
130
+ it "is false when the block never reads current_user" do
131
+ expect(context.__ruact_current_user_read?).to be(false)
132
+ end
133
+
134
+ it "flips to true when the block reads current_user" do
135
+ Ruact.configure { |c| c.current_user_resolver = ->(_env) { :u } }
136
+ context.current_user
137
+ expect(context.__ruact_current_user_read?).to be(true)
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Story 8.3 — `Ruact::ServerFunctions::StandaloneDispatcher`: AC2 + AC3
4
+ # dispatch shape: JSON / multipart / URL-encoded bodies; nil → 204; Hash →
5
+ # 200 JSON; Array → 200 JSON; `Ruact::ActionError` → status + body; unknown
6
+ # content-type → empty params; params shadow is `ActionController::Parameters`.
7
+
8
+ require "spec_helper"
9
+ require "action_controller"
10
+ require "action_dispatch"
11
+ require "rack"
12
+ require "securerandom"
13
+
14
+ module Ruact
15
+ module ServerFunctions
16
+ RSpec.describe StandaloneDispatcher, :story_8_3 do
17
+ let(:posts_module) do
18
+ Module.new do
19
+ extend Ruact::ServerAction
20
+
21
+ def self.name
22
+ "StandaloneDispatcherHost"
23
+ end
24
+ end
25
+ end
26
+
27
+ def make_request(body:, content_type:)
28
+ env = Rack::MockRequest.env_for(
29
+ "/__ruact/fn/whatever",
30
+ method: "POST",
31
+ input: body,
32
+ "CONTENT_TYPE" => content_type
33
+ )
34
+ ActionDispatch::Request.new(env)
35
+ end
36
+
37
+ def make_response
38
+ ActionDispatch::Response.new.tap do |resp|
39
+ # Touch the response so its internal state is initialized.
40
+ resp.request = nil
41
+ end
42
+ end
43
+
44
+ def register_and_entry(symbol, &block)
45
+ posts_module.module_eval { ruact_action(symbol, &block) }
46
+ Ruact.action_registry.entries[symbol]
47
+ end
48
+
49
+ describe "AC2 — content-type routing into params shadow" do
50
+ it "parses application/json bodies" do
51
+ entry = register_and_entry(:json_echo, &:to_unsafe_h)
52
+ request = make_request(body: '{"title":"Hi"}', content_type: "application/json")
53
+ response = make_response
54
+
55
+ described_class.dispatch(entry, request, response)
56
+
57
+ expect(response.status).to eq(200)
58
+ expect(response.headers["Content-Type"]).to include("application/json")
59
+ expect(JSON.parse(response.body)).to eq("title" => "Hi")
60
+ end
61
+
62
+ it "parses multipart/form-data bodies (Story 8.3 review R5 — AC9 multipart coverage)" do
63
+ # Hand-build a multipart body so the dispatcher exercises the same
64
+ # `request.request_parameters` path the runtime's `<form action>`
65
+ # wire shape produces. Mirrors the `multipart_post` helper in
66
+ # dispatch_request_spec.rb.
67
+ boundary = "----RuactDispatcherSpec#{SecureRandom.hex(8)}"
68
+ body = +""
69
+ body << "--#{boundary}\r\n"
70
+ body << "Content-Disposition: form-data; name=\"title\"\r\n\r\n"
71
+ body << "From multipart\r\n"
72
+ body << "--#{boundary}\r\n"
73
+ body << "Content-Disposition: form-data; name=\"body\"\r\n\r\n"
74
+ body << "Multipart body\r\n"
75
+ body << "--#{boundary}--\r\n"
76
+
77
+ entry = register_and_entry(:multipart_echo, &:to_unsafe_h)
78
+ request = make_request(body: body, content_type: "multipart/form-data; boundary=#{boundary}")
79
+ response = make_response
80
+
81
+ described_class.dispatch(entry, request, response)
82
+
83
+ expect(response.status).to eq(200)
84
+ expect(JSON.parse(response.body)).to eq(
85
+ "title" => "From multipart",
86
+ "body" => "Multipart body"
87
+ )
88
+ end
89
+
90
+ it "parses application/x-www-form-urlencoded bodies" do
91
+ entry = register_and_entry(:form_echo, &:to_unsafe_h)
92
+ request = make_request(
93
+ body: "title=Hello&body=World",
94
+ content_type: "application/x-www-form-urlencoded"
95
+ )
96
+ response = make_response
97
+
98
+ described_class.dispatch(entry, request, response)
99
+
100
+ expect(response.status).to eq(200)
101
+ expect(JSON.parse(response.body)).to eq("title" => "Hello", "body" => "World")
102
+ end
103
+
104
+ it "returns an empty params hash for unknown content types" do
105
+ entry = register_and_entry(:unknown_ct) { |params| { "keys" => params.to_unsafe_h.keys } }
106
+ request = make_request(body: "ignored", content_type: "application/xml")
107
+ response = make_response
108
+
109
+ described_class.dispatch(entry, request, response)
110
+
111
+ expect(response.status).to eq(200)
112
+ expect(JSON.parse(response.body)).to eq("keys" => [])
113
+ end
114
+
115
+ it "treats an empty JSON body as an empty hash" do
116
+ entry = register_and_entry(:empty_json, &:to_unsafe_h)
117
+ request = make_request(body: "", content_type: "application/json")
118
+ response = make_response
119
+
120
+ described_class.dispatch(entry, request, response)
121
+
122
+ expect(response.status).to eq(200)
123
+ expect(JSON.parse(response.body)).to eq({})
124
+ end
125
+
126
+ it "wraps a scalar JSON top-level value under the `_value` key (mirrors controller path)" do
127
+ entry = register_and_entry(:scalar_json) { |params| { value: params[:_value] } }
128
+ request = make_request(body: "42", content_type: "application/json")
129
+ response = make_response
130
+
131
+ described_class.dispatch(entry, request, response)
132
+
133
+ expect(response.status).to eq(200)
134
+ expect(JSON.parse(response.body)).to eq("value" => 42)
135
+ end
136
+
137
+ it "exposes the params shadow as ActionController::Parameters (strong params API)" do
138
+ entry = register_and_entry(:strong) do |params|
139
+ permitted = params.require(:post).permit(:title)
140
+ { "permitted" => permitted.to_h }
141
+ end
142
+ request = make_request(
143
+ body: '{"post":{"title":"Hi","evil":"ignored"}}',
144
+ content_type: "application/json"
145
+ )
146
+ response = make_response
147
+
148
+ described_class.dispatch(entry, request, response)
149
+
150
+ expect(response.status).to eq(200)
151
+ expect(JSON.parse(response.body)).to eq("permitted" => { "title" => "Hi" })
152
+ end
153
+ end
154
+
155
+ describe "AC2 — response shape" do
156
+ it "renders 204 No Content when the block returns nil" do
157
+ entry = register_and_entry(:nil_return) { |_params| nil }
158
+ request = make_request(body: "{}", content_type: "application/json")
159
+ response = make_response
160
+
161
+ described_class.dispatch(entry, request, response)
162
+
163
+ expect(response.status).to eq(204)
164
+ expect(response.body.to_s).to eq("")
165
+ end
166
+
167
+ it "renders 200 + JSON when the block returns a Hash" do
168
+ entry = register_and_entry(:hash_return) { |_params| { ok: true } }
169
+ request = make_request(body: "{}", content_type: "application/json")
170
+ response = make_response
171
+
172
+ described_class.dispatch(entry, request, response)
173
+
174
+ expect(response.status).to eq(200)
175
+ expect(JSON.parse(response.body)).to eq("ok" => true)
176
+ end
177
+
178
+ it "renders 200 + JSON when the block returns an Array" do
179
+ entry = register_and_entry(:array_return) { |_params| [1, 2, 3] }
180
+ request = make_request(body: "{}", content_type: "application/json")
181
+ response = make_response
182
+
183
+ described_class.dispatch(entry, request, response)
184
+
185
+ expect(response.status).to eq(200)
186
+ expect(JSON.parse(response.body)).to eq([1, 2, 3])
187
+ end
188
+
189
+ it "renders 200 + JSON when the block returns a scalar" do
190
+ entry = register_and_entry(:string_return) { |_params| "pong" }
191
+ request = make_request(body: "{}", content_type: "application/json")
192
+ response = make_response
193
+
194
+ described_class.dispatch(entry, request, response)
195
+
196
+ expect(response.status).to eq(200)
197
+ expect(JSON.parse(response.body)).to eq("pong")
198
+ end
199
+ end
200
+
201
+ describe "Story 8.3 review R3 — malformed JSON → structured 400" do
202
+ it "renders 400 + JSON {error} when the JSON body is malformed " \
203
+ "(parity with the controller-DSL path's malformed-JSON handler)" do
204
+ entry = register_and_entry(:malformed_demo) { |_p| { ok: true } }
205
+ request = make_request(body: "{ not json", content_type: "application/json")
206
+ response = make_response
207
+
208
+ described_class.dispatch(entry, request, response)
209
+
210
+ expect(response.status).to eq(400)
211
+ body = JSON.parse(response.body)
212
+ expect(body.fetch("error")).to match(/ruact action :malformed_demo received malformed JSON body/)
213
+ end
214
+
215
+ it "does NOT invoke the block when the body cannot be parsed" do
216
+ block_called = false
217
+ entry = register_and_entry(:never_runs) do |_p|
218
+ block_called = true
219
+ { ok: true }
220
+ end
221
+ request = make_request(body: "{ broken", content_type: "application/json")
222
+ response = make_response
223
+
224
+ described_class.dispatch(entry, request, response)
225
+
226
+ expect(block_called).to be(false)
227
+ expect(response.status).to eq(400)
228
+ end
229
+ end
230
+
231
+ describe "AC2 — Ruact::ActionError → status + body" do
232
+ it "renders the error's integer status + JSON body verbatim" do
233
+ entry = register_and_entry(:raise_action_error) do |_params|
234
+ raise Ruact::ActionError.new(status: 422, body: { error: "invalid" })
235
+ end
236
+ request = make_request(body: "{}", content_type: "application/json")
237
+ response = make_response
238
+
239
+ described_class.dispatch(entry, request, response)
240
+
241
+ expect(response.status).to eq(422)
242
+ expect(JSON.parse(response.body)).to eq("error" => "invalid")
243
+ end
244
+
245
+ it "translates a Symbol status to the matching HTTP code" do
246
+ entry = register_and_entry(:raise_symbol_status) do |_params|
247
+ raise Ruact::ActionError.new(status: :unauthorized, body: { error: "no" })
248
+ end
249
+ request = make_request(body: "{}", content_type: "application/json")
250
+ response = make_response
251
+
252
+ described_class.dispatch(entry, request, response)
253
+
254
+ expect(response.status).to eq(401)
255
+ expect(JSON.parse(response.body)).to eq("error" => "no")
256
+ end
257
+
258
+ it "renders nil body as empty when ActionError.body is nil" do
259
+ entry = register_and_entry(:raise_no_body) do |_params|
260
+ raise Ruact::ActionError.new(status: 418, body: nil)
261
+ end
262
+ request = make_request(body: "{}", content_type: "application/json")
263
+ response = make_response
264
+
265
+ described_class.dispatch(entry, request, response)
266
+
267
+ expect(response.status).to eq(418)
268
+ expect(response.body.to_s).to eq("")
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end