ruact 0.0.2 → 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/.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 +88 -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 +1779 -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 +100 -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 +344 -0
- data/lib/ruact/server_functions/codegen_v2.rb +212 -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 +190 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +118 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +313 -0
- data/lib/ruact/server_functions/query_source.rb +150 -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 +111 -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 +598 -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 +508 -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 +212 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/query_source_spec.rb +142 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -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 +173 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -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 +804 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
- metadata +91 -5
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module ServerFunctions
|
|
5
|
+
# Story 9.4 (D3) — per-request execution context injected into a
|
|
6
|
+
# {Ruact::Query} subclass by the internal query dispatch controller. A
|
|
7
|
+
# fresh instance is built per request (NFR8) wrapping the DISPATCHING
|
|
8
|
+
# controller instance; because that controller inherits
|
|
9
|
+
# `Ruact.config.query_parent_controller`, every delegated reader resolves
|
|
10
|
+
# to the host's own machinery — `current_user` IS the host's method
|
|
11
|
+
# (Devise / Pundit / hand-rolled), not a gem-side resolver lambda.
|
|
12
|
+
#
|
|
13
|
+
# Mirrors the SHAPE of the Story-8.3 `StandaloneContext` (plain accessors,
|
|
14
|
+
# per-request instance) while sourcing everything from the controller —
|
|
15
|
+
# the resolver-lambda pattern is superseded by the 2026-06-02 ADR
|
|
16
|
+
# addendum (Decision 2).
|
|
17
|
+
class QueryContext
|
|
18
|
+
# @param controller [ActionController::Base] the dispatching controller
|
|
19
|
+
# instance (a generated subclass of `Ruact.config.query_parent_controller`).
|
|
20
|
+
def initialize(controller:)
|
|
21
|
+
@controller = controller
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The host's authenticated user. Resolved through the controller's own
|
|
25
|
+
# `current_user` — public OR private (hand-rolled apps commonly define
|
|
26
|
+
# it `private`). When the host chain defines no `current_user` at all,
|
|
27
|
+
# raises a NoMethodError that names the fix instead of a bare
|
|
28
|
+
# "undefined method" from deep inside a query body.
|
|
29
|
+
#
|
|
30
|
+
# @return [Object, nil]
|
|
31
|
+
# @raise [NoMethodError] when the parent controller chain defines no
|
|
32
|
+
# `current_user`.
|
|
33
|
+
def current_user
|
|
34
|
+
unless @controller.respond_to?(:current_user, true)
|
|
35
|
+
raise NoMethodError,
|
|
36
|
+
"ruact: the query dispatch controller (inheriting " \
|
|
37
|
+
"#{@controller.class.superclass.name}, via Ruact.config.query_parent_controller) " \
|
|
38
|
+
"does not define `current_user`. Define it on the parent controller, or point " \
|
|
39
|
+
"`query_parent_controller` at a controller that does."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@controller.send(:current_user)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [ActionController::Parameters] the request params (query-string
|
|
46
|
+
# parameters on a GET query route).
|
|
47
|
+
def params
|
|
48
|
+
@controller.params
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [ActionDispatch::Request] the live request.
|
|
52
|
+
def request
|
|
53
|
+
@controller.request
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [ActionDispatch::Request::Session] the host middleware's session.
|
|
57
|
+
def session
|
|
58
|
+
@controller.session
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_controller"
|
|
4
|
+
|
|
5
|
+
require_relative "error_rendering"
|
|
6
|
+
require_relative "bucket_two_payload"
|
|
7
|
+
require_relative "query_context"
|
|
8
|
+
|
|
9
|
+
module Ruact
|
|
10
|
+
module ServerFunctions
|
|
11
|
+
# Story 9.4 (D2) — generates the INTERNAL dispatch controller backing the
|
|
12
|
+
# routes the `ruact_queries` macro draws: one controller subclass PER
|
|
13
|
+
# {Ruact::Query} subclass, with one action per public query method. The
|
|
14
|
+
# per-class shape is what makes the AC4 callback opt-out scopable — a
|
|
15
|
+
# `ruact_skip_before_action` on one query class lands on that class's
|
|
16
|
+
# controller only, and `only:`/`except:` options scope it further to
|
|
17
|
+
# individual query methods (each method is one action).
|
|
18
|
+
#
|
|
19
|
+
# The controller inherits `Ruact.config.query_parent_controller`
|
|
20
|
+
# (default `ApplicationController`), resolved LAZILY here — at route-draw
|
|
21
|
+
# time, when the host's constants exist — never at gem-load or configure
|
|
22
|
+
# time. The host's REAL callback chain therefore runs before the query
|
|
23
|
+
# class is instantiated (FR89). The dev never sees this controller; it is
|
|
24
|
+
# named under this module (e.g.
|
|
25
|
+
# `Ruact::ServerFunctions::QueryDispatch::CatalogQueryController`) only so
|
|
26
|
+
# Rails' string-based route resolution (`to: "…/catalog_query#categories"`)
|
|
27
|
+
# and `rails routes` output stay legible.
|
|
28
|
+
#
|
|
29
|
+
# Regeneration is idempotent: every `ruact_queries` evaluation (boot AND
|
|
30
|
+
# every dev-mode routes reload) rebuilds the constant from the query
|
|
31
|
+
# class's CURRENT state, and the query class itself is re-constantized per
|
|
32
|
+
# request, so code reloading never serves a stale class.
|
|
33
|
+
module QueryDispatch
|
|
34
|
+
# Instance-level dispatch plumbing shared by every generated controller.
|
|
35
|
+
# Included into the generated subclass, so these definitions override
|
|
36
|
+
# anything the parent chain provides (notably the {Ruact::Server} gates,
|
|
37
|
+
# when the host's ApplicationController happens to include the mutation
|
|
38
|
+
# concern).
|
|
39
|
+
module Dispatching
|
|
40
|
+
# The Method#parameters types that mark keyword arguments (D7).
|
|
41
|
+
KEYWORD_PARAM_TYPES = %i[key keyreq].freeze
|
|
42
|
+
|
|
43
|
+
# Story 9.5 (FR88) — the wire is GET query-string values, so every
|
|
44
|
+
# primitive the client sends arrives as a String (`?limit=5` →
|
|
45
|
+
# `"5"`); `nil` is the only non-String primitive Rack can produce for
|
|
46
|
+
# a scalar param. Arrays (`?q[]=`) and Hashes (`?q[k]=`) are the
|
|
47
|
+
# rejected complex shapes. Membership here = "this is a primitive the
|
|
48
|
+
# allowlist accepts".
|
|
49
|
+
PRIMITIVE_PARAM_CLASSES = [String, NilClass].freeze
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# The action body of every query action: fresh context + fresh query
|
|
54
|
+
# instance per request (NFR8), return value serialized through the
|
|
55
|
+
# SAME policy Bucket 2 applies to ivars (D6). Encoded explicitly so a
|
|
56
|
+
# scalar String/nil return still renders valid JSON (`"hi"` / `null`),
|
|
57
|
+
# which `render json:` alone would pass through raw.
|
|
58
|
+
def __ruact_dispatch_query(query_method)
|
|
59
|
+
query_class = self.class.__ruact_query_class
|
|
60
|
+
query = query_class.new(QueryContext.new(controller: self))
|
|
61
|
+
result = query.public_send(query_method, **__ruact_query_kwargs(query, query_method))
|
|
62
|
+
serialized = BucketTwoPayload.serialize_value(result, strict: Ruact.config.strict_serialization)
|
|
63
|
+
render json: ActiveSupport::JSON.encode(serialized)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Story 9.5 (FR88) — the AUTHORITATIVE kwargs sanitization. The query
|
|
67
|
+
# method's declared keyword arguments are the contract; the client's
|
|
68
|
+
# `useQuery(ref, params)` call sends those as GET query-string values.
|
|
69
|
+
# Reads the RAW client params from `request.query_parameters` (not
|
|
70
|
+
# `params`, which is polluted with Rails' `controller`/`action`/
|
|
71
|
+
# `format` routing defaults — those must not count as "unknown
|
|
72
|
+
# parameters"). Enforces, in order:
|
|
73
|
+
#
|
|
74
|
+
# 1. **Allowlist** — every provided value must be a primitive
|
|
75
|
+
# (`string | number | boolean | null`; on the wire: String or
|
|
76
|
+
# nil). An Array (`?q[]=`) or Hash (`?q[k]=`) is rejected with a
|
|
77
|
+
# descriptive `Ruact::BadRequestError` naming the offending key
|
|
78
|
+
# and the allowlist → 400.
|
|
79
|
+
# 2. **Unknown param** — a provided key matching no declared kwarg
|
|
80
|
+
# (and the method has no `**rest`) is rejected, not silently
|
|
81
|
+
# dropped → 400.
|
|
82
|
+
# 3. **Missing required** — a declared `keyreq` the client did not
|
|
83
|
+
# send → 400 naming the missing parameter.
|
|
84
|
+
#
|
|
85
|
+
# Returns the symbol-keyed kwargs hash to splat into the query method.
|
|
86
|
+
def __ruact_query_kwargs(query, query_method)
|
|
87
|
+
signature = query.method(query_method).parameters
|
|
88
|
+
required = signature.filter_map { |type, name| name.to_s if type == :keyreq }
|
|
89
|
+
optional = signature.filter_map { |type, name| name.to_s if type == :key }
|
|
90
|
+
accepts_rest = signature.any? { |type, _name| type == :keyrest }
|
|
91
|
+
declared = required + optional
|
|
92
|
+
|
|
93
|
+
provided = request.query_parameters
|
|
94
|
+
|
|
95
|
+
provided.each do |key, value|
|
|
96
|
+
__ruact_validate_query_param!(query_method, key, value, declared, accepts_rest)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
missing = required.reject { |name| provided.key?(name) }
|
|
100
|
+
unless missing.empty?
|
|
101
|
+
raise Ruact::BadRequestError,
|
|
102
|
+
"ruact query :#{query_method} is missing required parameter(s) " \
|
|
103
|
+
"#{missing.map { |n| n.to_sym.inspect }.join(', ')}."
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
provided.each_with_object({}) do |(key, value), kwargs|
|
|
107
|
+
kwargs[key.to_sym] = value if declared.include?(key) || accepts_rest
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# FR88 per-param gate — see {#__ruact_query_kwargs}. Order matters:
|
|
112
|
+
# the unknown-param check precedes the type check so an unknown
|
|
113
|
+
# complex param is reported as "unknown" (the more actionable error).
|
|
114
|
+
#
|
|
115
|
+
# `accepts_rest` (the method declares `**rest`) relaxes ONLY the
|
|
116
|
+
# named-parameter restriction — a `**rest` signature is the author's
|
|
117
|
+
# explicit opt-in to arbitrary kwargs, so no provided key is "unknown".
|
|
118
|
+
# The TYPE allowlist below STILL runs for every param including the
|
|
119
|
+
# rest-captured ones, so the FR88 security boundary (reject
|
|
120
|
+
# arrays/objects) holds regardless of `**rest`.
|
|
121
|
+
def __ruact_validate_query_param!(query_method, key, value, declared, accepts_rest)
|
|
122
|
+
unless declared.include?(key) || accepts_rest
|
|
123
|
+
raise Ruact::BadRequestError,
|
|
124
|
+
"ruact query :#{query_method} received unknown parameter #{key.to_sym.inspect} " \
|
|
125
|
+
"— it matches no keyword argument of the query method."
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
return if PRIMITIVE_PARAM_CLASSES.any? { |klass| value.is_a?(klass) }
|
|
129
|
+
|
|
130
|
+
raise Ruact::BadRequestError,
|
|
131
|
+
"ruact query :#{query_method} parameter #{key.to_sym.inspect} must be a " \
|
|
132
|
+
"string, number, boolean, or null — arrays and objects are rejected " \
|
|
133
|
+
"(got #{value.class})."
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# D5 — the {Ruact::Server} mutation gate returns false for GET/HEAD so
|
|
137
|
+
# GET *pages* keep stock Rails errors; query dispatch requests are GET
|
|
138
|
+
# *function calls*, so the structured 8.4 payload must render here.
|
|
139
|
+
# `Ruact::ConfigurationError` still re-raises — a misconfiguration is
|
|
140
|
+
# a loud setup failure, never a disguised runtime 500 (the same rule
|
|
141
|
+
# the mutation concern enforces).
|
|
142
|
+
def __ruact_render_structured_error?(error)
|
|
143
|
+
!error.is_a?(Ruact::ConfigurationError)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
class << self
|
|
148
|
+
# Builds (or rebuilds) the dispatch controller for +query_class+ and
|
|
149
|
+
# installs it under this module's namespace, where the route target
|
|
150
|
+
# string from {.route_target_for} resolves to it.
|
|
151
|
+
#
|
|
152
|
+
# @param query_class [Class] a {Ruact::Query} subclass
|
|
153
|
+
# @return [Class] the generated controller
|
|
154
|
+
# @raise [Ruact::ConfigurationError] when the parent controller cannot
|
|
155
|
+
# be resolved or +query_class+ is anonymous
|
|
156
|
+
def controller_for(query_class)
|
|
157
|
+
*namespace_segments, base = constant_segments(query_class)
|
|
158
|
+
namespace = ensure_namespace(namespace_segments)
|
|
159
|
+
const_name = "#{base}Controller"
|
|
160
|
+
namespace.send(:remove_const, const_name) if namespace.const_defined?(const_name, false)
|
|
161
|
+
namespace.const_set(const_name, build_controller(query_class))
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The `to:` route target for +query_class+'s generated controller —
|
|
165
|
+
# the underscored constant path Rails camelizes back at dispatch time.
|
|
166
|
+
# The query class's namespace is PRESERVED (review round 4): a nested
|
|
167
|
+
# path, never flattened, so two classes whose names differ only in
|
|
168
|
+
# namespace boundary (`Admin::CatalogQuery` vs `AdminCatalogQuery`)
|
|
169
|
+
# map to DISTINCT controllers and can never cross-wire — collision is
|
|
170
|
+
# impossible by construction, across any number of RouteSets / engines.
|
|
171
|
+
#
|
|
172
|
+
# @param query_class [Class] a {Ruact::Query} subclass
|
|
173
|
+
# @return [String] e.g. `"ruact/server_functions/query_dispatch/admin/catalog_query"`
|
|
174
|
+
def route_target_for(query_class)
|
|
175
|
+
"ruact/server_functions/query_dispatch/#{path_segments(query_class).join('/')}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
# The underscored route-path segments for +query_class+
|
|
181
|
+
# (`Admin::CatalogQuery` → `["admin", "catalog_query"]`).
|
|
182
|
+
def path_segments(query_class)
|
|
183
|
+
base_segments(query_class).map(&:underscore)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The generated controller's constant-name segments — derived from the
|
|
187
|
+
# SAME underscored path the route target uses, then `camelize`d
|
|
188
|
+
# (review round 5). Deriving both directions from one underscored form
|
|
189
|
+
# via the shared global inflector guarantees the route target Rails
|
|
190
|
+
# `camelize`s at dispatch time resolves to EXACTLY this constant,
|
|
191
|
+
# regardless of how the query class spelled an acronym or how the host
|
|
192
|
+
# configured `inflect.acronym` (`APIProbe::CatalogQuery` and the route
|
|
193
|
+
# `.../api_probe/catalog_query` both canonicalize identically). Using
|
|
194
|
+
# the raw class spelling instead would 404 acronym constants with the
|
|
195
|
+
# default inflector.
|
|
196
|
+
def constant_segments(query_class)
|
|
197
|
+
path_segments(query_class).map(&:camelize)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The query class's fully-qualified name split into constant segments
|
|
201
|
+
# (`Admin::CatalogQuery` → `["Admin", "CatalogQuery"]`). The namespace
|
|
202
|
+
# is preserved so the generated controller lives at a nested,
|
|
203
|
+
# collision-free constant path under {QueryDispatch}.
|
|
204
|
+
def base_segments(query_class)
|
|
205
|
+
name = query_class.name
|
|
206
|
+
unless name
|
|
207
|
+
raise Ruact::ConfigurationError,
|
|
208
|
+
"ruact_queries cannot mount an anonymous Ruact::Query subclass — " \
|
|
209
|
+
"assign it to a constant (e.g. `class CatalogQuery < ApplicationQuery`)."
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
name.split("::")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Walks (creating as needed) the nested module path under {QueryDispatch}
|
|
216
|
+
# that mirrors the query class's namespace, returning the innermost
|
|
217
|
+
# module the controller constant is set on. Idempotent — reuses existing
|
|
218
|
+
# modules so repeated draws (boot + dev reloads) never duplicate them.
|
|
219
|
+
def ensure_namespace(segments)
|
|
220
|
+
segments.reduce(self) do |mod, segment|
|
|
221
|
+
if mod.const_defined?(segment, false)
|
|
222
|
+
mod.const_get(segment, false)
|
|
223
|
+
else
|
|
224
|
+
mod.const_set(segment, Module.new)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Lazy resolution of `Ruact.config.query_parent_controller` (AC2). Both
|
|
230
|
+
# failure shapes are configuration-time errors raised at route-draw —
|
|
231
|
+
# a typo'd name or a non-controller class must never reach a request.
|
|
232
|
+
def resolve_parent_controller
|
|
233
|
+
name = Ruact.config.query_parent_controller
|
|
234
|
+
parent = begin
|
|
235
|
+
name.constantize
|
|
236
|
+
rescue NameError
|
|
237
|
+
raise Ruact::ConfigurationError,
|
|
238
|
+
"ruact_queries: Ruact.config.query_parent_controller = #{name.inspect} does not " \
|
|
239
|
+
"resolve to a constant. Define that controller, or point query_parent_controller " \
|
|
240
|
+
"at an existing one in config/initializers/ruact.rb."
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
unless parent.is_a?(Class) && parent <= ActionController::Metal
|
|
244
|
+
raise Ruact::ConfigurationError,
|
|
245
|
+
"ruact_queries: Ruact.config.query_parent_controller = #{name.inspect} resolved to " \
|
|
246
|
+
"#{parent.inspect}, which is not an ActionController class."
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
parent
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def build_controller(query_class)
|
|
253
|
+
query_class_name = query_class.name
|
|
254
|
+
|
|
255
|
+
# Mixins applied on the built class (not inside a `Class.new do … end`
|
|
256
|
+
# block) so YARD's static MixinHandler does not emit "Undocumentable
|
|
257
|
+
# mixin … for class" for an anonymous class body — the
|
|
258
|
+
# `--fail-on-warning` docs gate treats that as an error. Runtime is
|
|
259
|
+
# identical.
|
|
260
|
+
controller = Class.new(resolve_parent_controller)
|
|
261
|
+
controller.include(Ruact::ServerFunctions::ErrorRendering)
|
|
262
|
+
controller.include(Dispatching)
|
|
263
|
+
|
|
264
|
+
controller.define_singleton_method(:__ruact_query_class) { query_class_name.constantize }
|
|
265
|
+
|
|
266
|
+
# AC5 — the salvaged 8.4 error chain, with the same front-loading
|
|
267
|
+
# trick as Ruact::Server: handlers the parent chain registered
|
|
268
|
+
# (inherited OR declared later) stay more recent and keep precedence;
|
|
269
|
+
# the structured renderer only catches what the host did not.
|
|
270
|
+
inherited_handlers = controller.rescue_handlers
|
|
271
|
+
controller.rescue_from(StandardError, with: :__ruact_render_action_error)
|
|
272
|
+
controller.rescue_handlers = (controller.rescue_handlers - inherited_handlers) + inherited_handlers
|
|
273
|
+
|
|
274
|
+
define_query_actions(controller, query_class)
|
|
275
|
+
apply_skips(controller, query_class)
|
|
276
|
+
controller
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Review round 1 (finding 1) — a query method whose name already exists
|
|
280
|
+
# anywhere on the generated controller chain (`params`, `render`,
|
|
281
|
+
# `session`, `process`, the gem's own `__ruact_*` plumbing, …) would
|
|
282
|
+
# OVERRIDE that method when installed as an action, corrupting request
|
|
283
|
+
# handling (e.g. `def params` shadows `ActionController#params` and
|
|
284
|
+
# recurses through the dispatch path). Reject at route-draw with a
|
|
285
|
+
# legible error instead of failing at the first request.
|
|
286
|
+
def define_query_actions(controller, query_class)
|
|
287
|
+
query_class.public_instance_methods(false).each do |query_method|
|
|
288
|
+
if controller.method_defined?(query_method) || controller.private_method_defined?(query_method)
|
|
289
|
+
raise Ruact::ConfigurationError,
|
|
290
|
+
"ruact_queries: query method :#{query_method} on #{query_class.name} is already " \
|
|
291
|
+
"defined on the dispatch controller chain (#{controller.superclass.name} / " \
|
|
292
|
+
"ActionController / ruact plumbing) and would shadow it — rename the query method."
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
controller.define_method(query_method) do
|
|
296
|
+
__ruact_dispatch_query(query_method)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# AC4 / D1 — forwards every recorded `ruact_skip_before_action` to
|
|
302
|
+
# Rails' own `skip_before_action` on the generated controller. An
|
|
303
|
+
# unknown callback raises here (route-draw time) unless the query
|
|
304
|
+
# passed `raise: false`, mirroring stock Rails behavior.
|
|
305
|
+
def apply_skips(controller, query_class)
|
|
306
|
+
query_class.__ruact_skipped_callbacks.each do |callbacks, options|
|
|
307
|
+
controller.skip_before_action(*callbacks, **options)
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/inflections"
|
|
4
|
+
require "ruact/server_functions/name_bridge"
|
|
5
|
+
|
|
6
|
+
module Ruact
|
|
7
|
+
module ServerFunctions
|
|
8
|
+
# Story 9.5 — derives v2 QUERY entries for the route-driven codegen, the
|
|
9
|
+
# read-side sibling of {RouteSource} (which derives the non-GET mutation
|
|
10
|
+
# actions). Queries come from {Ruact::Query} subclasses mounted via the
|
|
11
|
+
# `ruact_queries` routing macro (Story 9.4).
|
|
12
|
+
#
|
|
13
|
+
# ## Why read the drawn route table, not all Ruact::Query subclasses
|
|
14
|
+
#
|
|
15
|
+
# The route table is the single source of truth (FR61): a host exposes a
|
|
16
|
+
# query ONLY by mounting its class with `ruact_queries` in `routes.rb`.
|
|
17
|
+
# Enumerating every `Ruact::Query` subclass would over-expose query classes
|
|
18
|
+
# that are defined but never mounted (and would 404 when `useQuery` fetched
|
|
19
|
+
# their non-existent routes). Reading the routes the `ruact_queries` macro
|
|
20
|
+
# actually drew keeps codegen route-truth-consistent with dispatch by
|
|
21
|
+
# construction — and means there is no `app/queries` force-load gap to
|
|
22
|
+
# paper over (mounting a class in `routes.rb` already autoloads it).
|
|
23
|
+
#
|
|
24
|
+
# Every generated query dispatch controller lives under the
|
|
25
|
+
# {QUERY_CONTROLLER_PREFIX} namespace (see
|
|
26
|
+
# {QueryDispatch.route_target_for}), so the GET routes this module consumes
|
|
27
|
+
# are unambiguous: any drawn route whose controller path starts with that
|
|
28
|
+
# prefix is a mounted query method.
|
|
29
|
+
#
|
|
30
|
+
# Pure by construction: {.collect} takes the route set and a resolver
|
|
31
|
+
# callable (controller-path → the backing {Ruact::Query} subclass). The
|
|
32
|
+
# railtie passes the real constant-resolving implementation; unit specs
|
|
33
|
+
# inject a lambda so the derivation is testable without booting controllers.
|
|
34
|
+
#
|
|
35
|
+
# @see RouteSource the mutation (action) sibling
|
|
36
|
+
# @see Ruact::Routing#ruact_queries the macro that draws the routes read here
|
|
37
|
+
module QuerySource
|
|
38
|
+
# The controller-path prefix every generated query dispatch controller
|
|
39
|
+
# lives under (mirrors {QueryDispatch.route_target_for}). A drawn GET
|
|
40
|
+
# route whose controller starts with this prefix is a mounted query.
|
|
41
|
+
QUERY_CONTROLLER_PREFIX = "ruact/server_functions/query_dispatch/"
|
|
42
|
+
|
|
43
|
+
# `Method#parameters` types that mark keyword arguments — the FR88 query
|
|
44
|
+
# parameters (mirrors {QueryDispatch::Dispatching::KEYWORD_PARAM_TYPES}).
|
|
45
|
+
KEYWORD_PARAM_TYPES = %i[key keyreq keyrest].freeze
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
# Collects v2 query entries from +route_set+.
|
|
49
|
+
#
|
|
50
|
+
# @param route_set [#routes] anything exposing `#routes` (an
|
|
51
|
+
# `ActionDispatch::Routing::RouteSet`, or `Rails.application.routes`).
|
|
52
|
+
# @param query_class_for [#call, nil] `controller_path(String) ->
|
|
53
|
+
# (Class | nil)` — resolves a query dispatch controller path to the
|
|
54
|
+
# {Ruact::Query} subclass it backs. Defaults to real constant
|
|
55
|
+
# resolution (reads the generated controller's `__ruact_query_class`).
|
|
56
|
+
# @return [Array<Hash>] query entries (string keys) sorted by
|
|
57
|
+
# `js_identifier`; shape: `js_identifier`, `kind` (always `"query"`),
|
|
58
|
+
# `http_method` (always `"GET"`), `path`, `segments` (always `[]`),
|
|
59
|
+
# `accepts_params` (Boolean — does the method declare kwargs?),
|
|
60
|
+
# `controller` (the query class name — for collision origins),
|
|
61
|
+
# `action` (the Ruby method name).
|
|
62
|
+
# @raise [Ruact::ConfigurationError] on a query×query naming collision.
|
|
63
|
+
def collect(route_set, query_class_for: nil)
|
|
64
|
+
query_class_for ||= method(:default_query_class_for)
|
|
65
|
+
|
|
66
|
+
entries = []
|
|
67
|
+
route_set.routes.each do |route|
|
|
68
|
+
controller = route.defaults[:controller]
|
|
69
|
+
action = route.defaults[:action]
|
|
70
|
+
next if controller.nil? || action.nil?
|
|
71
|
+
next unless controller.to_s.start_with?(QUERY_CONTROLLER_PREFIX)
|
|
72
|
+
|
|
73
|
+
query_class = query_class_for.call(controller.to_s)
|
|
74
|
+
next if query_class.nil?
|
|
75
|
+
|
|
76
|
+
entries << build_entry(route, action.to_s, query_class)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
entries = entries.sort_by { |entry| entry["js_identifier"] }
|
|
80
|
+
detect_collisions!(entries)
|
|
81
|
+
entries
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def build_entry(route, action, query_class)
|
|
87
|
+
{
|
|
88
|
+
"js_identifier" => NameBridge.to_js_identifier(action),
|
|
89
|
+
"kind" => "query",
|
|
90
|
+
"http_method" => "GET",
|
|
91
|
+
"path" => clean_path(route),
|
|
92
|
+
"segments" => [],
|
|
93
|
+
"accepts_params" => accepts_params?(query_class, action),
|
|
94
|
+
"controller" => query_class.name,
|
|
95
|
+
"action" => action
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Does the query method declare any keyword arguments (FR88 params)?
|
|
100
|
+
# Drives the emitted TS signature: `(params) => Promise<unknown>` when
|
|
101
|
+
# true, `() => Promise<unknown>` when false (AC1).
|
|
102
|
+
def accepts_params?(query_class, action)
|
|
103
|
+
query_class.instance_method(action).parameters.any? do |(type, _name)|
|
|
104
|
+
KEYWORD_PARAM_TYPES.include?(type)
|
|
105
|
+
end
|
|
106
|
+
rescue NameError
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# query×query collision — two mounted query classes whose methods map
|
|
111
|
+
# to the SAME JS identifier (e.g. `CatalogQuery#search_users` and
|
|
112
|
+
# `PeopleQuery#search_users`). Fail loudly at boot naming both origins.
|
|
113
|
+
# The route×query side of the merged namespace is detected at the
|
|
114
|
+
# codegen combine point (see {ServerFunctions.write_v2_snapshot!}).
|
|
115
|
+
def detect_collisions!(entries)
|
|
116
|
+
entries.group_by { |entry| entry["js_identifier"] }.each do |js_id, group|
|
|
117
|
+
next if group.size < 2
|
|
118
|
+
|
|
119
|
+
origins = group.map { |entry| "#{entry['controller']}##{entry['action']}" }
|
|
120
|
+
raise Ruact::ConfigurationError,
|
|
121
|
+
"server-function naming collision: #{origins.join(' and ')} " \
|
|
122
|
+
"both map to JS identifier \"#{js_id}\" — rename one of the query methods."
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# `/q/categories(.:format)` → `/q/categories`. Mirrors
|
|
127
|
+
# {RouteSource#clean_path}: drops the trailing format optional and any
|
|
128
|
+
# remaining optional `( … )` group.
|
|
129
|
+
def clean_path(route)
|
|
130
|
+
spec = route.path.spec.to_s
|
|
131
|
+
spec = spec.delete_suffix("(.:format)")
|
|
132
|
+
spec.gsub(/\([^)]*\)/, "")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Real resolver — used in the railtie/rake paths. The generated query
|
|
136
|
+
# dispatch controller exposes `__ruact_query_class` (a singleton method
|
|
137
|
+
# set by {QueryDispatch.controller_for}); resolve the controller
|
|
138
|
+
# constant from its path and read that back.
|
|
139
|
+
def default_query_class_for(controller)
|
|
140
|
+
klass = "#{controller}_controller".camelize.safe_constantize
|
|
141
|
+
return nil unless klass.respond_to?(:__ruact_query_class)
|
|
142
|
+
|
|
143
|
+
klass.__ruact_query_class
|
|
144
|
+
rescue StandardError
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module ServerFunctions
|
|
5
|
+
# In-memory storage for server-function entries. One instance backs
|
|
6
|
+
# `Ruact.action_registry`; another backs `Ruact.query_registry` — kept
|
|
7
|
+
# separate so the JSON snapshot can emit a `kind` field per entry without
|
|
8
|
+
# the call sites having to thread an extra parameter through. Cross-registry
|
|
9
|
+
# JS-identifier collisions are detected by {Ruact::ServerFunctions::Snapshot}
|
|
10
|
+
# at snapshot time (a single registry only sees its own entries).
|
|
11
|
+
#
|
|
12
|
+
# Thread-safety: not thread-safe by design. Registration happens at
|
|
13
|
+
# controller-class load time (`config.to_prepare` in dev, eager-load in
|
|
14
|
+
# production), single-threaded. Reads from {#entries} return a frozen
|
|
15
|
+
# snapshot of the internal hash so concurrent readers cannot observe a
|
|
16
|
+
# partial registration.
|
|
17
|
+
class Registry
|
|
18
|
+
# The only kinds the codegen knows how to emit. Story 8.1 owns `:action`,
|
|
19
|
+
# Story 9.1 owns `:query`. Any other value is rejected at registration
|
|
20
|
+
# time — silent acceptance would otherwise let an unknown kind fall
|
|
21
|
+
# through and be emitted as an action signature.
|
|
22
|
+
ALLOWED_KINDS = %i[action query].freeze
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@entries = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Adds +symbol+ to the registry.
|
|
29
|
+
#
|
|
30
|
+
# @param symbol [Symbol] the Ruby identifier (snake_case).
|
|
31
|
+
# @param kind [Symbol] `:action` or `:query`. Other values raise.
|
|
32
|
+
# @param controller [Class, nil] the controller class registering the
|
|
33
|
+
# function. Used in collision-error messages.
|
|
34
|
+
# @yield the implementation body; stored verbatim for Story 8.1 / 9.1 to
|
|
35
|
+
# invoke. May be nil for Story 8.0a's bootstrap (registries are empty
|
|
36
|
+
# until 8.1 and 9.1 land).
|
|
37
|
+
# @return [Ruact::ServerFunctions::RegistryEntry] the entry just inserted.
|
|
38
|
+
# @raise [Ruact::ConfigurationError] when +symbol+ fails the naming-bridge
|
|
39
|
+
# rule, when +kind+ is not in {ALLOWED_KINDS}, or when a different Ruby
|
|
40
|
+
# symbol already maps to the same JS identifier in THIS registry. Cross-
|
|
41
|
+
# registry collisions (one action + one query sharing a JS identifier)
|
|
42
|
+
# are detected later by {Ruact::ServerFunctions::Snapshot.functions_payload}.
|
|
43
|
+
def register(symbol, kind:, controller: nil, &block)
|
|
44
|
+
validate_kind!(symbol, kind, controller)
|
|
45
|
+
js_identifier = translate_symbol(symbol, controller)
|
|
46
|
+
detect_collision!(symbol, js_identifier, controller)
|
|
47
|
+
|
|
48
|
+
entry = RegistryEntry.new(
|
|
49
|
+
ruby_symbol: symbol,
|
|
50
|
+
js_identifier: js_identifier,
|
|
51
|
+
kind: kind,
|
|
52
|
+
controller: controller,
|
|
53
|
+
block: block
|
|
54
|
+
)
|
|
55
|
+
@entries[symbol] = entry
|
|
56
|
+
entry
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Hash{Symbol => Ruact::ServerFunctions::RegistryEntry}] frozen
|
|
60
|
+
# snapshot of the current entries, ordered by insertion.
|
|
61
|
+
def entries
|
|
62
|
+
@entries.dup.freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Wipes the registry. Used by `config.to_prepare` (between dev reloads) and
|
|
66
|
+
# by tests that need a clean slate.
|
|
67
|
+
#
|
|
68
|
+
# @return [self]
|
|
69
|
+
def clear!
|
|
70
|
+
@entries.clear
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Integer] number of registered entries.
|
|
75
|
+
def size
|
|
76
|
+
@entries.size
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Boolean] whether the registry has no entries.
|
|
80
|
+
def empty?
|
|
81
|
+
@entries.empty?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def validate_kind!(symbol, kind, controller)
|
|
87
|
+
return if ALLOWED_KINDS.include?(kind)
|
|
88
|
+
|
|
89
|
+
raise Ruact::ConfigurationError,
|
|
90
|
+
"invalid server-function symbol :#{symbol} in #{describe_controller(controller)}: " \
|
|
91
|
+
"kind #{kind.inspect} is not one of #{ALLOWED_KINDS.inspect}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Wraps the NameBridge call to attach controller context to the raised
|
|
95
|
+
# error (the AC7 "invalid server-function symbol :SYMBOL in CONTROLLER"
|
|
96
|
+
# shape). NameBridge itself is controller-agnostic; the wrap lives at the
|
|
97
|
+
# registry boundary because that is where controller context exists.
|
|
98
|
+
def translate_symbol(symbol, controller)
|
|
99
|
+
NameBridge.to_js_identifier(symbol)
|
|
100
|
+
rescue Ruact::ConfigurationError => e
|
|
101
|
+
raise Ruact::ConfigurationError,
|
|
102
|
+
"invalid server-function symbol :#{symbol} in #{describe_controller(controller)} — #{e.message}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def detect_collision!(symbol, js_identifier, controller)
|
|
106
|
+
# Re-run-3 (2026-05-15) — TWO failure shapes:
|
|
107
|
+
#
|
|
108
|
+
# (a) Different Ruby symbols, same JS identifier (`:foo_bar` and
|
|
109
|
+
# `:fooBar` both → "fooBar"). Filtered by `js_identifier ==`.
|
|
110
|
+
# (b) Same Ruby symbol declared on TWO different controllers
|
|
111
|
+
# (e.g., `ruact_action :create_post` in both `PostsController`
|
|
112
|
+
# AND `AdminPostsController`). Pre-batch this silently
|
|
113
|
+
# overwrote `@entries[symbol]` with the last-loaded one, so
|
|
114
|
+
# dispatch routed to whichever controller Zeitwerk happened
|
|
115
|
+
# to load last — non-deterministic in dev, surprise breakage
|
|
116
|
+
# when refactoring. Detect by checking the existing entry's
|
|
117
|
+
# `controller` against the one trying to register.
|
|
118
|
+
existing = @entries[symbol]
|
|
119
|
+
if existing && existing.controller != controller
|
|
120
|
+
raise Ruact::ConfigurationError,
|
|
121
|
+
"server-function naming collision: " \
|
|
122
|
+
":#{symbol} is declared in BOTH " \
|
|
123
|
+
"#{describe_controller(existing.controller)} and " \
|
|
124
|
+
"#{describe_controller(controller)}. Each `ruact_action` " \
|
|
125
|
+
"symbol must be unique across the whole registry — pick a " \
|
|
126
|
+
"more specific name (e.g. :admin_create_post) on one side."
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
collision = @entries.values.find do |e|
|
|
130
|
+
e.js_identifier == js_identifier && e.ruby_symbol != symbol
|
|
131
|
+
end
|
|
132
|
+
return unless collision
|
|
133
|
+
|
|
134
|
+
raise Ruact::ConfigurationError,
|
|
135
|
+
"server-function naming collision: " \
|
|
136
|
+
":#{symbol} (in #{describe_controller(controller)}) and " \
|
|
137
|
+
":#{collision.ruby_symbol} (in #{describe_controller(collision.controller)}) " \
|
|
138
|
+
"both map to JS identifier \"#{js_identifier}\""
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def describe_controller(controller)
|
|
142
|
+
return "unknown controller" if controller.nil?
|
|
143
|
+
|
|
144
|
+
controller.respond_to?(:name) && controller.name ? controller.name : controller.inspect
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|