ruact 0.0.2 → 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.
- checksums.yaml +4 -4
- data/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +86 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1680 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +89 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +330 -0
- data/lib/ruact/server_functions/codegen_v2.rb +176 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +188 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +113 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +248 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +75 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +446 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +429 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
- metadata +88 -5
- data/lib/ruact/component_registry.rb +0 -31
- 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
|