ruact 0.0.3 → 0.0.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 +4 -4
- data/CHANGELOG.md +2 -0
- data/docs/internal/decisions/server-functions-api.md +99 -0
- data/lib/ruact/errors.rb +11 -0
- data/lib/ruact/server_functions/codegen.rb +14 -0
- data/lib/ruact/server_functions/codegen_v2.rb +46 -10
- data/lib/ruact/server_functions/error_rendering.rb +2 -0
- data/lib/ruact/server_functions/name_bridge.rb +11 -6
- data/lib/ruact/server_functions/query_dispatch.rb +73 -8
- data/lib/ruact/server_functions/query_source.rb +150 -0
- data/lib/ruact/server_functions.rb +38 -2
- data/lib/ruact/version.rb +1 -1
- data/spec/ruact/query_request_spec.rb +154 -2
- data/spec/ruact/server_functions/codegen_spec.rb +82 -3
- data/spec/ruact/server_functions/name_bridge_spec.rb +24 -0
- data/spec/ruact/server_functions/query_source_spec.rb +142 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +67 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +34 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +176 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +9 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +56 -13
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +95 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +7 -1
- metadata +4 -1
|
@@ -26,6 +26,7 @@ module Ruact
|
|
|
26
26
|
autoload :SnapshotWriter, "ruact/server_functions/snapshot_writer"
|
|
27
27
|
autoload :Codegen, "ruact/server_functions/codegen"
|
|
28
28
|
autoload :RouteSource, "ruact/server_functions/route_source"
|
|
29
|
+
autoload :QuerySource, "ruact/server_functions/query_source"
|
|
29
30
|
autoload :ErrorRendering, "ruact/server_functions/error_rendering"
|
|
30
31
|
autoload :EndpointController, "ruact/server_functions/endpoint_controller"
|
|
31
32
|
autoload :StandaloneContext, "ruact/server_functions/standalone_context"
|
|
@@ -45,13 +46,23 @@ module Ruact
|
|
|
45
46
|
# AC2 — transparency over silence: the exposed names are ALWAYS logged so a
|
|
46
47
|
# routed non-GET action never becomes a callable server function silently.
|
|
47
48
|
#
|
|
49
|
+
# Story 9.5 — the `entries` array now carries BOTH mutation actions (from
|
|
50
|
+
# {RouteSource}) and read queries (from {QuerySource}); they share ONE
|
|
51
|
+
# merged JS namespace. {.detect_merged_namespace_collisions!} catches a
|
|
52
|
+
# route×query clash (each source already catches its own intra-kind
|
|
53
|
+
# collisions); the rename-override macro `ruact_function_name` on the
|
|
54
|
+
# mutation side (or renaming the query method) resolves it.
|
|
55
|
+
#
|
|
48
56
|
# @param route_set [#routes] the Rails route set.
|
|
49
57
|
# @param root [Pathname] the app root (for `tmp/cache` + `app/javascript`).
|
|
50
58
|
# @param logger [#info, nil] logger for the exposure line; defaults to
|
|
51
59
|
# `Rails.logger` when Rails is loaded, else nil.
|
|
52
|
-
# @return [Array<Hash>] the exposed v2 entries.
|
|
60
|
+
# @return [Array<Hash>] the exposed v2 entries (actions + queries).
|
|
53
61
|
def self.write_v2_snapshot!(route_set:, root:, logger: default_logger)
|
|
54
|
-
|
|
62
|
+
actions = RouteSource.collect(route_set)
|
|
63
|
+
queries = QuerySource.collect(route_set)
|
|
64
|
+
entries = (actions + queries).sort_by { |entry| entry["js_identifier"] }
|
|
65
|
+
detect_merged_namespace_collisions!(entries)
|
|
55
66
|
|
|
56
67
|
json_path = root.join("tmp/cache/ruact/server-functions.next.json")
|
|
57
68
|
ts_path = root.join("app/javascript/.ruact/server-functions.next.ts")
|
|
@@ -71,5 +82,30 @@ module Ruact
|
|
|
71
82
|
def self.default_logger
|
|
72
83
|
defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
|
|
73
84
|
end
|
|
85
|
+
|
|
86
|
+
# Story 9.5 (Task 2) — the merged JS namespace covers route (action)
|
|
87
|
+
# entries AND query entries. {RouteSource} already rejects action×action
|
|
88
|
+
# collisions and {QuerySource} rejects query×query; this final pass catches
|
|
89
|
+
# a route×query clash — two distinct origins (one a mutation route, one a
|
|
90
|
+
# query method) mapping to the same `js_identifier` would emit two
|
|
91
|
+
# `export const <id>` lines and crash the generated module at load. Fail
|
|
92
|
+
# loudly at boot naming BOTH origins. The escape hatch is the
|
|
93
|
+
# `ruact_function_name :<action>, as: "<id>"` rename macro on the mutation
|
|
94
|
+
# controller (Story 9.3) or renaming the colliding query method.
|
|
95
|
+
#
|
|
96
|
+
# @param entries [Array<Hash>] merged action + query entries.
|
|
97
|
+
# @raise [Ruact::ConfigurationError]
|
|
98
|
+
def self.detect_merged_namespace_collisions!(entries)
|
|
99
|
+
entries.group_by { |entry| entry["js_identifier"] }.each do |js_id, group|
|
|
100
|
+
next if group.size < 2
|
|
101
|
+
|
|
102
|
+
origins = group.map { |entry| "#{entry['controller']}##{entry['action']}" }
|
|
103
|
+
raise Ruact::ConfigurationError,
|
|
104
|
+
"server-function naming collision: #{origins.join(' and ')} " \
|
|
105
|
+
"both map to JS identifier \"#{js_id}\" — disambiguate with " \
|
|
106
|
+
"`ruact_function_name :<action>, as: \"<other-name>\"` on the mutation " \
|
|
107
|
+
"controller, or rename the query method."
|
|
108
|
+
end
|
|
109
|
+
end
|
|
74
110
|
end
|
|
75
111
|
end
|
data/lib/ruact/version.rb
CHANGED
|
@@ -155,17 +155,47 @@ module QueryRequestSpecSupport # rubocop:disable Style/OneClassPerFile
|
|
|
155
155
|
|
|
156
156
|
# Custom parent for the AC2 configurability unit assertion.
|
|
157
157
|
class AltParentController < ActionController::Base; end
|
|
158
|
+
|
|
159
|
+
# Story 9.5 (FR88) fixture — a required kwarg (`q`), an optional kwarg with
|
|
160
|
+
# a default (`limit`), and a separate required-only method. Mounted on the
|
|
161
|
+
# shared app alongside CatalogQuery so the FR88 sanitization is exercised
|
|
162
|
+
# end-to-end through the host chain.
|
|
163
|
+
class SearchQuery < ApplicationQuery
|
|
164
|
+
# `q` is the natural query parameter name and is asserted verbatim in the
|
|
165
|
+
# FR88 rejection messages below.
|
|
166
|
+
def search(q:, limit: "10") # rubocop:disable Naming/MethodParameterName
|
|
167
|
+
{ "q" => q, "limit" => limit }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def tags(filter:)
|
|
171
|
+
{ "filter" => filter }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# A `**rest` query: it opts into arbitrary kwargs, so extra params are NOT
|
|
175
|
+
# "unknown" — but the FR88 type allowlist still rejects arrays/objects.
|
|
176
|
+
def flexible(q:, **rest) # rubocop:disable Naming/MethodParameterName
|
|
177
|
+
{ "q" => q, "rest" => rest.transform_keys(&:to_s) }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# For the null-wire contract: `useQuery` sends `null` as a bare key (`?opt`),
|
|
181
|
+
# which Rack parses as `nil` (distinct from `?opt=` → `""`).
|
|
182
|
+
def nullable(opt: "default")
|
|
183
|
+
{ "opt" => opt }
|
|
184
|
+
end
|
|
185
|
+
end
|
|
158
186
|
end
|
|
159
187
|
|
|
160
188
|
if defined?(ControllerRequestSpecSupport) &&
|
|
161
189
|
!ControllerRequestSpecSupport.instance_variable_get(:@__ruact_query_routes_appended)
|
|
162
190
|
ControllerRequestSpecSupport.instance_variable_set(:@__ruact_query_routes_appended, true)
|
|
163
191
|
ControllerRequestSpecSupport.app_class.routes.append do
|
|
164
|
-
ruact_queries QueryRequestSpecSupport::CatalogQuery,
|
|
192
|
+
ruact_queries QueryRequestSpecSupport::CatalogQuery,
|
|
193
|
+
QueryRequestSpecSupport::PublicCatalogQuery,
|
|
194
|
+
QueryRequestSpecSupport::SearchQuery
|
|
165
195
|
end
|
|
166
196
|
end
|
|
167
197
|
|
|
168
|
-
RSpec.describe "Story 9.4: Ruact::Query + ruact_queries dispatch", :story_9_4 do
|
|
198
|
+
RSpec.describe "Story 9.4: Ruact::Query + ruact_queries dispatch", :story_9_4 do # rubocop:disable RSpec/MultipleDescribes
|
|
169
199
|
include Rack::Test::Methods
|
|
170
200
|
|
|
171
201
|
let(:app_class) { ControllerRequestSpecSupport.app_class }
|
|
@@ -444,3 +474,125 @@ RSpec.describe "Story 9.4: Ruact::Query + ruact_queries dispatch", :story_9_4 do
|
|
|
444
474
|
end
|
|
445
475
|
end
|
|
446
476
|
end
|
|
477
|
+
|
|
478
|
+
RSpec.describe "Story 9.5: FR88 query kwargs sanitization", :story_9_5 do
|
|
479
|
+
include Rack::Test::Methods
|
|
480
|
+
|
|
481
|
+
let(:app_class) { ControllerRequestSpecSupport.app_class }
|
|
482
|
+
let(:app) { app_class.instance }
|
|
483
|
+
|
|
484
|
+
let(:login_headers) { { "HTTP_X_TEST_LOGIN" => "1" } }
|
|
485
|
+
|
|
486
|
+
before do
|
|
487
|
+
Rails.logger = Logger.new(IO::NULL)
|
|
488
|
+
ControllerRequestSpecSupport.boot!
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def structured_error(response)
|
|
492
|
+
expect(response.status).to eq(400)
|
|
493
|
+
body = JSON.parse(response.body)
|
|
494
|
+
expect(body.fetch("_ruact_server_action_error")).to be(true)
|
|
495
|
+
expect(body.fetch("error_class")).to eq("Ruact::BadRequestError")
|
|
496
|
+
body
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
describe "AC3 happy path — declared primitives pass" do
|
|
500
|
+
it "passes a required + optional kwarg by name (values arrive as strings)" do
|
|
501
|
+
get "/q/search?q=ruby&limit=5", {}, login_headers
|
|
502
|
+
expect(last_response.status).to eq(200)
|
|
503
|
+
expect(JSON.parse(last_response.body)).to eq("q" => "ruby", "limit" => "5")
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
it "omits an absent optional kwarg so the method default applies" do
|
|
507
|
+
get "/q/search?q=ruby", {}, login_headers
|
|
508
|
+
expect(last_response.status).to eq(200)
|
|
509
|
+
expect(JSON.parse(last_response.body)).to eq("q" => "ruby", "limit" => "10")
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
it "accepts boolean-ish / numeric-ish primitive strings on the wire" do
|
|
513
|
+
get "/q/search?q=true&limit=0", {}, login_headers
|
|
514
|
+
expect(last_response.status).to eq(200)
|
|
515
|
+
expect(JSON.parse(last_response.body)).to eq("q" => "true", "limit" => "0")
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
describe "AC3 — array param rejected (only string/number/boolean/null allowed)" do
|
|
520
|
+
it "400s with a descriptive error naming the key and the allowlist" do
|
|
521
|
+
get "/q/search?q[]=a&q[]=b", {}, login_headers
|
|
522
|
+
body = structured_error(last_response)
|
|
523
|
+
expect(body.fetch("message")).to match(/:q must be a string, number, boolean, or null/)
|
|
524
|
+
expect(body.fetch("message")).to match(/arrays and objects are rejected/)
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
describe "AC3 — object (hash) param rejected" do
|
|
529
|
+
it "400s with a descriptive error naming the offending key" do
|
|
530
|
+
get "/q/search?q[deep]=1", {}, login_headers
|
|
531
|
+
body = structured_error(last_response)
|
|
532
|
+
expect(body.fetch("message")).to match(/:q must be a string, number, boolean, or null/)
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
describe "AC3 — missing required kwarg → 400 naming the missing parameter" do
|
|
537
|
+
it "400s naming :q when it is absent" do
|
|
538
|
+
get "/q/search?limit=5", {}, login_headers
|
|
539
|
+
body = structured_error(last_response)
|
|
540
|
+
expect(body.fetch("message")).to match(/missing required parameter\(s\) :q/)
|
|
541
|
+
expect(body.fetch("action_name")).to eq("search")
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
it "400s naming :filter on a required-only method" do
|
|
545
|
+
get "/q/tags", {}, login_headers
|
|
546
|
+
body = structured_error(last_response)
|
|
547
|
+
expect(body.fetch("message")).to match(/missing required parameter\(s\) :filter/)
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
describe "AC3 — unknown param → 400 (rejected, not silently dropped)" do
|
|
552
|
+
it "400s naming the unknown parameter" do
|
|
553
|
+
get "/q/search?q=ruby&bogus=1", {}, login_headers
|
|
554
|
+
body = structured_error(last_response)
|
|
555
|
+
expect(body.fetch("message")).to match(/unknown parameter :bogus/)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
it "rejects before running the query even when the declared param is also present" do
|
|
559
|
+
get "/q/search?q=ruby&bogus=1", {}, login_headers
|
|
560
|
+
expect(last_response.status).to eq(400)
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
describe "AC3 — a `**rest` query opts into extra kwargs, but the type allowlist still applies" do
|
|
565
|
+
it "accepts extra primitive params (not 'unknown' for a **rest signature)" do
|
|
566
|
+
get "/q/flexible?q=ruby&extra=1&flag=true", {}, login_headers
|
|
567
|
+
expect(last_response.status).to eq(200)
|
|
568
|
+
expect(JSON.parse(last_response.body)).to eq(
|
|
569
|
+
"q" => "ruby", "rest" => { "extra" => "1", "flag" => "true" }
|
|
570
|
+
)
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
it "STILL rejects an array param on a **rest query (FR88 type boundary holds)" do
|
|
574
|
+
get "/q/flexible?q=ruby&bad[]=1", {}, login_headers
|
|
575
|
+
body = structured_error(last_response)
|
|
576
|
+
expect(body.fetch("message")).to match(/:bad must be a string, number, boolean, or null/)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
it "STILL rejects an object param on a **rest query" do
|
|
580
|
+
get "/q/flexible?q=ruby&bad[x]=1", {}, login_headers
|
|
581
|
+
body = structured_error(last_response)
|
|
582
|
+
expect(body.fetch("message")).to match(/:bad must be a string, number, boolean, or null/)
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
describe "null wire contract — a bare key (`?opt`) is delivered as nil, not empty string" do
|
|
587
|
+
it "delivers nil (the bare-key wire form useQuery sends for null)" do
|
|
588
|
+
get "/q/nullable?opt", {}, login_headers
|
|
589
|
+
expect(last_response.status).to eq(200)
|
|
590
|
+
expect(JSON.parse(last_response.body)).to eq("opt" => nil)
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
it "delivers an empty string for `?opt=` (distinct from null)" do
|
|
594
|
+
get "/q/nullable?opt=", {}, login_headers
|
|
595
|
+
expect(JSON.parse(last_response.body)).to eq("opt" => "")
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
end
|
|
@@ -377,13 +377,13 @@ module Ruact
|
|
|
377
377
|
.to raise_error(Ruact::ConfigurationError, /absent from path/)
|
|
378
378
|
end
|
|
379
379
|
|
|
380
|
-
it "rejects a v2 entry with
|
|
380
|
+
it "rejects a v2 entry with an unknown kind (Story 9.5 — action/query only)" do
|
|
381
381
|
evil = v2_snapshot.merge(functions: [
|
|
382
|
-
{ "js_identifier" => "createPost", "kind" => "
|
|
382
|
+
{ "js_identifier" => "createPost", "kind" => "mutation",
|
|
383
383
|
"http_method" => "POST", "path" => "/posts", "segments" => [] }
|
|
384
384
|
])
|
|
385
385
|
expect { described_class.render(evil) }
|
|
386
|
-
.to raise_error(Ruact::ConfigurationError, /v2 entries are
|
|
386
|
+
.to raise_error(Ruact::ConfigurationError, /v2 entries are "action" or "query"/)
|
|
387
387
|
end
|
|
388
388
|
|
|
389
389
|
it "rejects a v2 entry whose js_identifier is reserved" do
|
|
@@ -424,6 +424,85 @@ module Ruact
|
|
|
424
424
|
.to raise_error(Ruact::ConfigurationError, /absent from path/)
|
|
425
425
|
end
|
|
426
426
|
end
|
|
427
|
+
|
|
428
|
+
describe ".render — v2 query entries (Story 9.5)", :story_9_5 do
|
|
429
|
+
def query_entry(js_id, path, accepts_params:)
|
|
430
|
+
{
|
|
431
|
+
"js_identifier" => js_id, "kind" => "query", "http_method" => "GET",
|
|
432
|
+
"path" => path, "segments" => [], "accepts_params" => accepts_params,
|
|
433
|
+
"controller" => "CatalogQuery", "action" => js_id
|
|
434
|
+
}
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
it "emits a no-param query as _makeQuery with the () signature + useQuery re-export" do
|
|
438
|
+
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
439
|
+
query_entry("categories", "/q/categories", accepts_params: false)
|
|
440
|
+
])
|
|
441
|
+
expect(out).to include('import { _makeQuery } from "ruact/server-functions-runtime";')
|
|
442
|
+
expect(out).to include("export const categories: () => Promise<unknown> =")
|
|
443
|
+
expect(out).to include('_makeQuery({ path: "/q/categories", kind: "query" });')
|
|
444
|
+
expect(out).to include('export { useQuery } from "ruact/server-functions-runtime";')
|
|
445
|
+
expect(out).to include('export { revalidate } from "ruact/server-functions-runtime";')
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
it "emits a param-declaring query with the (params) signature" do
|
|
449
|
+
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
450
|
+
query_entry("searchUsers", "/q/searchUsers", accepts_params: true)
|
|
451
|
+
])
|
|
452
|
+
expect(out).to include("export const searchUsers: (params: Record<string, unknown>) => Promise<unknown> =")
|
|
453
|
+
expect(out).to include('_makeQuery({ path: "/q/searchUsers", kind: "query" });')
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
it "imports both accessors and re-exports useQuery for a mixed action+query snapshot" do
|
|
457
|
+
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
458
|
+
{ "js_identifier" => "createPost", "kind" => "action",
|
|
459
|
+
"http_method" => "POST", "path" => "/posts", "segments" => [] },
|
|
460
|
+
query_entry("categories", "/q/categories", accepts_params: false)
|
|
461
|
+
])
|
|
462
|
+
expect(out).to include('import { _makeServerFunction, _makeQuery } from "ruact/server-functions-runtime";')
|
|
463
|
+
expect(out).to include("_makeServerFunction({ method: \"POST\"")
|
|
464
|
+
expect(out).to include("_makeQuery({ path: \"/q/categories\", kind: \"query\" })")
|
|
465
|
+
expect(out).to include('export { useQuery } from "ruact/server-functions-runtime";')
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
it "accepts GET for a query entry (queries are GET-only)" do
|
|
469
|
+
expect do
|
|
470
|
+
described_class.render(version: 2, generated_at: "t", functions: [
|
|
471
|
+
query_entry("categories", "/q/categories", accepts_params: false)
|
|
472
|
+
])
|
|
473
|
+
end.not_to raise_error
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
it "rejects a non-GET verb on a query entry" do
|
|
477
|
+
evil = { "js_identifier" => "categories", "kind" => "query", "http_method" => "POST",
|
|
478
|
+
"path" => "/q/categories", "segments" => [] }
|
|
479
|
+
expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
|
|
480
|
+
.to raise_error(Ruact::ConfigurationError, /invalid http_method/)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
it "rejects a query entry whose js_identifier is the useQuery re-export name" do
|
|
484
|
+
evil = { "js_identifier" => "useQuery", "kind" => "query", "http_method" => "GET",
|
|
485
|
+
"path" => "/q/useQuery", "segments" => [], "accepts_params" => false }
|
|
486
|
+
expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
|
|
487
|
+
.to raise_error(Ruact::ConfigurationError, /reserved/)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
it "rejects a query entry whose js_identifier is the _makeQuery import name" do
|
|
491
|
+
evil = { "js_identifier" => "_makeQuery", "kind" => "query", "http_method" => "GET",
|
|
492
|
+
"path" => "/q/_makeQuery", "segments" => [], "accepts_params" => false }
|
|
493
|
+
expect { described_class.render(version: 2, generated_at: "t", functions: [evil]) }
|
|
494
|
+
.to raise_error(Ruact::ConfigurationError, /reserved/)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
it "does NOT re-export useQuery for an action-only snapshot (byte-stable with 9.3)" do
|
|
498
|
+
out = described_class.render(version: 2, generated_at: "t", functions: [
|
|
499
|
+
{ "js_identifier" => "createPost", "kind" => "action",
|
|
500
|
+
"http_method" => "POST", "path" => "/posts", "segments" => [] }
|
|
501
|
+
])
|
|
502
|
+
expect(out).not_to include("useQuery")
|
|
503
|
+
expect(out).to end_with("export { revalidate } from \"ruact/server-functions-runtime\";\n")
|
|
504
|
+
end
|
|
505
|
+
end
|
|
427
506
|
end
|
|
428
507
|
end
|
|
429
508
|
end
|
|
@@ -181,6 +181,30 @@ module Ruact
|
|
|
181
181
|
it "R12 — accepts :_make_ref_action (suffix escape hatch keeps working)" do
|
|
182
182
|
expect(described_class.to_js_identifier(:_make_ref_action)).to eq("_makeRefAction")
|
|
183
183
|
end
|
|
184
|
+
|
|
185
|
+
# Story 9.5 — `_makeQuery` (the v2 query import) and `useQuery` (the
|
|
186
|
+
# query hook re-export) are new top-level bindings in the generated
|
|
187
|
+
# module; a query/action method mapping to either would redeclare the
|
|
188
|
+
# import / duplicate the export and crash at module load.
|
|
189
|
+
it "Story 9.5 — rejects :use_query because it collides with the useQuery re-export" do
|
|
190
|
+
expect { described_class.to_js_identifier(:use_query) }
|
|
191
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
192
|
+
expect(error.message).to include(":use_query")
|
|
193
|
+
expect(error.message).to include("duplicate export")
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it "Story 9.5 — rejects :_make_query because it collides with the codegen's runtime import" do
|
|
198
|
+
expect { described_class.to_js_identifier(:_make_query) }
|
|
199
|
+
.to raise_error(Ruact::ConfigurationError) do |error|
|
|
200
|
+
expect(error.message).to include(":_make_query")
|
|
201
|
+
expect(error.message).to include("duplicate export")
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it "Story 9.5 — accepts :use_query_results (suffix escape hatch keeps working)" do
|
|
206
|
+
expect(described_class.to_js_identifier(:use_query_results)).to eq("useQueryResults")
|
|
207
|
+
end
|
|
184
208
|
end
|
|
185
209
|
end
|
|
186
210
|
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "ruact/server_functions"
|
|
5
|
+
require "ruact/server_functions/query_source"
|
|
6
|
+
|
|
7
|
+
module Ruact
|
|
8
|
+
module ServerFunctions
|
|
9
|
+
# Plain query classes — only `instance_method(...).parameters` (kwargs
|
|
10
|
+
# presence) and `.name` (collision origin) are read. Defined at module
|
|
11
|
+
# level so they are not example-group-leaky constants.
|
|
12
|
+
class CatalogQ
|
|
13
|
+
def categories; end
|
|
14
|
+
|
|
15
|
+
def search_users(term:, limit: 10); end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class PeopleQ
|
|
19
|
+
def search_users(term:); end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Story 9.5 — query introspection: the drawn route table (filtered to the
|
|
23
|
+
# generated query dispatch controllers) → v2 query entries. Pure: route set
|
|
24
|
+
# and the query-class resolver are injected, so the derivation is testable
|
|
25
|
+
# with no Rails boot and no controllers (a fake route set + plain query
|
|
26
|
+
# classes).
|
|
27
|
+
RSpec.describe QuerySource, :story_9_5 do
|
|
28
|
+
# Minimal route double exposing the surface QuerySource reads:
|
|
29
|
+
# `defaults[:controller]`, `defaults[:action]`, `path.spec.to_s`.
|
|
30
|
+
def query_route(controller, action, path)
|
|
31
|
+
spec = Object.new
|
|
32
|
+
spec.define_singleton_method(:to_s) { "#{path}(.:format)" }
|
|
33
|
+
path_obj = Object.new
|
|
34
|
+
path_obj.define_singleton_method(:spec) { spec }
|
|
35
|
+
route = Object.new
|
|
36
|
+
route.define_singleton_method(:defaults) { { controller: controller, action: action } }
|
|
37
|
+
route.define_singleton_method(:path) { path_obj }
|
|
38
|
+
route
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def route_set(routes)
|
|
42
|
+
rs = Object.new
|
|
43
|
+
rs.define_singleton_method(:routes) { routes }
|
|
44
|
+
rs
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
let(:prefix) { QuerySource::QUERY_CONTROLLER_PREFIX }
|
|
48
|
+
|
|
49
|
+
def resolver(map)
|
|
50
|
+
->(controller) { map[controller] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
describe ".collect" do
|
|
54
|
+
it "emits one query entry per mounted query route (route-truth)" do
|
|
55
|
+
rs = route_set([
|
|
56
|
+
query_route("#{prefix}catalog_q", "categories", "/q/categories"),
|
|
57
|
+
query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers")
|
|
58
|
+
])
|
|
59
|
+
entries = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ))
|
|
60
|
+
|
|
61
|
+
expect(entries.map { |e| e["js_identifier"] }).to eq(%w[categories searchUsers])
|
|
62
|
+
expect(entries).to all(include("kind" => "query", "http_method" => "GET", "segments" => []))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "derives jsId via NameBridge and carries the clean path" do
|
|
66
|
+
rs = route_set([query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers")])
|
|
67
|
+
entry = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ)).first
|
|
68
|
+
expect(entry["js_identifier"]).to eq("searchUsers")
|
|
69
|
+
expect(entry["path"]).to eq("/q/searchUsers")
|
|
70
|
+
expect(entry["controller"]).to eq("Ruact::ServerFunctions::CatalogQ")
|
|
71
|
+
expect(entry["action"]).to eq("search_users")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "flags accepts_params true when the method declares kwargs, false otherwise" do
|
|
75
|
+
rs = route_set([
|
|
76
|
+
query_route("#{prefix}catalog_q", "categories", "/q/categories"),
|
|
77
|
+
query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers")
|
|
78
|
+
])
|
|
79
|
+
entries = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ))
|
|
80
|
+
by_id = entries.to_h { |e| [e["js_identifier"], e] }
|
|
81
|
+
expect(by_id["categories"]["accepts_params"]).to be(false)
|
|
82
|
+
expect(by_id["searchUsers"]["accepts_params"]).to be(true)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "ignores non-query routes (controller not under the dispatch namespace)" do
|
|
86
|
+
rs = route_set([
|
|
87
|
+
query_route("posts", "create", "/posts"),
|
|
88
|
+
query_route("#{prefix}catalog_q", "categories", "/q/categories")
|
|
89
|
+
])
|
|
90
|
+
entries = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ))
|
|
91
|
+
expect(entries.map { |e| e["js_identifier"] }).to eq(%w[categories])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "skips a query route whose class cannot be resolved (nil resolver result)" do
|
|
95
|
+
rs = route_set([query_route("#{prefix}gone_q", "categories", "/q/categories")])
|
|
96
|
+
expect(described_class.collect(rs, query_class_for: resolver({}))).to eq([])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "raises a query×query collision naming both origins" do
|
|
100
|
+
rs = route_set([
|
|
101
|
+
query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers"),
|
|
102
|
+
query_route("#{prefix}people_q", "search_users", "/q/searchUsers")
|
|
103
|
+
])
|
|
104
|
+
map = { "#{prefix}catalog_q" => CatalogQ, "#{prefix}people_q" => PeopleQ }
|
|
105
|
+
expect { described_class.collect(rs, query_class_for: resolver(map)) }
|
|
106
|
+
.to raise_error(Ruact::ConfigurationError, /CatalogQ#search_users and .*PeopleQ#search_users/m)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Story 9.5 (Task 2) — the merged JS namespace (route entries + query
|
|
112
|
+
# entries share it). The route×query side is detected at the codegen
|
|
113
|
+
# combine point.
|
|
114
|
+
RSpec.describe ".detect_merged_namespace_collisions!", :story_9_5 do
|
|
115
|
+
def action_entry(js_id, controller, action)
|
|
116
|
+
{ "js_identifier" => js_id, "kind" => "action", "controller" => controller, "action" => action }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def query_entry(js_id, controller, action)
|
|
120
|
+
{ "js_identifier" => js_id, "kind" => "query", "controller" => controller, "action" => action }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "raises on a route×query collision naming both origins + the rename escape hatch" do
|
|
124
|
+
entries = [
|
|
125
|
+
action_entry("categories", "posts", "categories"),
|
|
126
|
+
query_entry("categories", "CatalogQuery", "categories")
|
|
127
|
+
]
|
|
128
|
+
expect { ServerFunctions.detect_merged_namespace_collisions!(entries) }
|
|
129
|
+
.to raise_error(Ruact::ConfigurationError,
|
|
130
|
+
/posts#categories and CatalogQuery#categories.*ruact_function_name/m)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "does not raise when the rename makes the identifiers distinct" do
|
|
134
|
+
entries = [
|
|
135
|
+
action_entry("listCategories", "posts", "categories"), # renamed via ruact_function_name
|
|
136
|
+
query_entry("categories", "CatalogQuery", "categories")
|
|
137
|
+
]
|
|
138
|
+
expect { ServerFunctions.detect_merged_namespace_collisions!(entries) }.not_to raise_error
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -125,6 +125,73 @@ module Ruact
|
|
|
125
125
|
end
|
|
126
126
|
end
|
|
127
127
|
|
|
128
|
+
RSpec.describe "Ruact::ServerFunctions.write_v2_snapshot! — queries (Story 9.5)", :story_9_5 do
|
|
129
|
+
# `ruact_queries` + query dispatch live behind these requires (loaded
|
|
130
|
+
# explicitly here as the railtie would at boot).
|
|
131
|
+
require "ruact/routing"
|
|
132
|
+
require "ruact/query"
|
|
133
|
+
|
|
134
|
+
around do |example|
|
|
135
|
+
Dir.mktmpdir { |dir| @tmpdir = dir and example.run }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
before do
|
|
139
|
+
stub_const("V2QueryParentController", Class.new(ActionController::Base))
|
|
140
|
+
Ruact.configure { |c| c.query_parent_controller = "V2QueryParentController" }
|
|
141
|
+
stub_const("V2CatalogQuery", Class.new(Ruact::Query) do
|
|
142
|
+
def categories; end
|
|
143
|
+
def search(term:); end
|
|
144
|
+
end)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def route_set
|
|
148
|
+
route_set = ActionDispatch::Routing::RouteSet.new
|
|
149
|
+
route_set.draw { ruact_queries V2CatalogQuery }
|
|
150
|
+
route_set
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def write!(routes = route_set)
|
|
154
|
+
Ruact::ServerFunctions.write_v2_snapshot!(route_set: routes, root: Pathname.new(@tmpdir))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
let(:next_ts) { File.join(@tmpdir, "app/javascript/.ruact/server-functions.next.ts") }
|
|
158
|
+
|
|
159
|
+
it "emits query entries (route-truth) merged into the v2 snapshot" do
|
|
160
|
+
entries = write!
|
|
161
|
+
expect(entries.map { |e| e["js_identifier"] }).to match_array(%w[categories search])
|
|
162
|
+
expect(entries).to all(include("kind" => "query", "http_method" => "GET"))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "renders _makeQuery refs + the useQuery re-export into the .next TS" do
|
|
166
|
+
write!
|
|
167
|
+
ts = File.read(next_ts)
|
|
168
|
+
expect(ts).to include('import { _makeQuery } from "ruact/server-functions-runtime";')
|
|
169
|
+
expect(ts).to include('_makeQuery({ path: "/q/categories", kind: "query" });')
|
|
170
|
+
expect(ts).to include("export const categories: () => Promise<unknown> =")
|
|
171
|
+
expect(ts).to include("export const search: (params: Record<string, unknown>) => Promise<unknown> =")
|
|
172
|
+
expect(ts).to include('export { useQuery } from "ruact/server-functions-runtime";')
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it "raises a route×query collision when an action and a query share a js_identifier" do
|
|
176
|
+
stub_const("CategoriesController", Class.new(ActionController::Base) { include Ruact::Server })
|
|
177
|
+
stub_const("CollideQuery", Class.new(Ruact::Query) { def categories; end })
|
|
178
|
+
rs = ActionDispatch::Routing::RouteSet.new
|
|
179
|
+
rs.draw do
|
|
180
|
+
# POST /categories#create would derive js_identifier "createCategory" — to
|
|
181
|
+
# force a clash, use a custom collection route named to collide head-on.
|
|
182
|
+
post "categories", to: "categories#categories"
|
|
183
|
+
ruact_queries CollideQuery
|
|
184
|
+
end
|
|
185
|
+
# Rename the action's js_identifier to exactly "categories" so it collides
|
|
186
|
+
# with the query method.
|
|
187
|
+
CategoriesController.define_singleton_method(:__ruact_function_name_overrides) do
|
|
188
|
+
{ "categories" => "categories" }
|
|
189
|
+
end
|
|
190
|
+
expect { Ruact::ServerFunctions.write_v2_snapshot!(route_set: rs, root: Pathname.new(@tmpdir)) }
|
|
191
|
+
.to raise_error(Ruact::ConfigurationError, /both map to JS identifier "categories".*ruact_function_name/m)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
128
195
|
RSpec.describe "Ruact::Railtie registry-clear hook (Story 8.1)", :story_8_1 do
|
|
129
196
|
# The Railtie attaches a `before_class_unload` callback that clears both
|
|
130
197
|
# registries before Zeitwerk tears down constants — this prevents removed
|
|
@@ -87,6 +87,40 @@ export function _makeServerFunction(descriptor: {
|
|
|
87
87
|
) => Promise<unknown>) &
|
|
88
88
|
((formData: RuactFormData) => Promise<void>);
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Story 9.5 — the read-side (query) accessor. The codegen emits
|
|
92
|
+
* `_makeQuery({ path, kind: "query" })` for every method on a mounted
|
|
93
|
+
* `Ruact::Query` subclass. The returned callable issues a GET to the named
|
|
94
|
+
* query route (`GET /q/<jsId>`), serializing `params` into the query string
|
|
95
|
+
* (FR88: string / number / boolean / null only). Reads are CSRF-free: no
|
|
96
|
+
* body, no `X-CSRF-Token`.
|
|
97
|
+
*
|
|
98
|
+
* Usually consumed through {@link useQuery}, but callable directly in
|
|
99
|
+
* imperative code. The emitted module narrows the param surface per query
|
|
100
|
+
* (`() => Promise<unknown>` when the Ruby method declares no kwargs;
|
|
101
|
+
* `(params: Record<string, unknown>) => Promise<unknown>` when it does).
|
|
102
|
+
*/
|
|
103
|
+
export function _makeQuery(descriptor: {
|
|
104
|
+
path: string;
|
|
105
|
+
kind?: string;
|
|
106
|
+
}): (params?: Record<string, unknown>) => Promise<unknown>;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Story 9.5 — React hook for reading a server query. Pass a query reference
|
|
110
|
+
* (the codegen-emitted `_makeQuery` accessor) and optional params; issues
|
|
111
|
+
* `GET /q/<jsId>` on mount (and whenever the serialized params change) and
|
|
112
|
+
* returns `{ data, loading, error }`. `loading` is true until the first
|
|
113
|
+
* resolution; `error` carries the structured {@link RuactActionError} on
|
|
114
|
+
* failure. A superseded in-flight response is dropped.
|
|
115
|
+
*
|
|
116
|
+
* Request de-duplication across components is Story 9.6; this hook fetches
|
|
117
|
+
* once per mount.
|
|
118
|
+
*/
|
|
119
|
+
export function useQuery<T = unknown>(
|
|
120
|
+
reference: (params?: Record<string, unknown>) => Promise<unknown>,
|
|
121
|
+
params?: Record<string, unknown>,
|
|
122
|
+
): { data: T | undefined; loading: boolean; error: unknown };
|
|
123
|
+
|
|
90
124
|
/**
|
|
91
125
|
* Story 8.2 — issues a Flight refetch of the supplied path (or the
|
|
92
126
|
* current URL when omitted) and swaps the React tree in place. Mirrors
|