ruact 0.0.3 → 0.0.5
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 +4 -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 +36 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +276 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +9 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +368 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2f8e264e2e404e5257d0fe7457cef545f4104288dc16db01e1c8e5762d361f99
|
|
4
|
+
data.tar.gz: 4a2ed748edb281b7d8bb0c20ce5e404db6ce7872f82c8db1545349ab709677d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 36eb5942d50d3761874bd912a9cfae5ad3538500b8e08a79b196310db8db66bc86fa8a6fc78262c8a2e73ba659feaa1dd821c344fe89ecbddf247c2e05924153
|
|
7
|
+
data.tar.gz: f8902075c57bb47e42f404d43a864829719bddc4f497371097f0c1576ef1565312b45e628f1d7a3e9a3811db497e1aa8325f8e935da4e7b4941ae2bdc468d72e
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
11
|
|
|
12
|
+
- **`useQuery` request de-duplication** ([Story 9.6](../_bmad-output/implementation-artifacts/9-6-automatic-request-deduplication-for-usequery.md)). Identical concurrent `useQuery` calls now share ONE network request: when three components mount `useQuery(categories)` with the same params while a request is in flight, exactly one `GET /q/categories` is issued — all of them receive the same resolved `data`, and an error propagates to every sharer. The dedup key derives from the **query reference identity + the serialized params**, with order-independent param serialization (`{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` share a request; different params do not). Scope is **in-flight only** — there is no TTL cache and no stale-while-revalidate: once a request settles its shared entry is dropped, so a fresh mount refetches. Runtime-only change (`useQuery` hook); the `_makeQuery` GET accessor, codegen, and the Ruby query dispatch are unchanged. Runtime package version `0.3.0` → `0.4.0`; coverage in `usequery.test.mjs`.
|
|
13
|
+
|
|
14
|
+
- **Queries in codegen + `useQuery` hook + FR88 kwargs sanitization** ([Story 9.5](../_bmad-output/implementation-artifacts/9-5-queries-in-codegen-usequery-re-pointed-kwargs-params.md)). The read-side of the route-driven redesign is complete: `import { categories, useQuery } from "@/.ruact/server-functions"` and `useQuery(categories)` / `useQuery(searchUsers, { q: input })` now work against `Ruact::Query` classes — same mental model as mutations, zero new concepts. **Codegen.** A new `Ruact::ServerFunctions::QuerySource` derives v2 query entries from the **drawn route table** (the GET routes the `ruact_queries` macro mounted under the generated query-dispatch namespace) — route-truth-consistent with dispatch, so only classes actually mounted in `routes.rb` are exposed (no over-exposure, and no `app/queries` force-load needed). Query entries join the **same merged JS namespace** as mutations; collisions fail loudly at boot naming both origins (route×query is caught at the codegen combine point; `query×query` by the source; the `ruact_function_name` rename macro on the mutation controller — or renaming the query method — is the escape hatch). The emitted module binds each query to `_makeQuery({ path, kind: "query" })` with a per-query TS signature (`() => Promise<unknown>` when the method declares no kwargs, `(params: Record<string, unknown>) => Promise<unknown>` when it does) and re-exports `useQuery` from the runtime (only when ≥1 query exists, so action-only/empty v2 modules stay byte-identical to Story 9.3). The Ruby↔JS byte-equality parity test covers query entries. **Runtime.** New `useQuery(reference, params?)` React hook → `{ data, loading, error }` (loading until first resolution; structured `RuactActionError` into `error`; superseded in-flight responses dropped; refetch on value-changed params); new `_makeQuery` GET helper issues `GET /q/<jsId>` with params in the query string — **no body, no CSRF** (reads are CSRF-free; the stale `POST /__ruact/fn/:id` query mechanism is voided). The runtime gains `react` as a **peerDependency** (its first React import; the mutation path stays React-free); package version `0.2.0` → `0.3.0`. **FR88 sanitization.** `query_dispatch.rb#__ruact_query_kwargs` now enforces the authoritative kwargs allowlist on `request.query_parameters`: only `string | number | boolean | null` are accepted — arrays (`?q[]=`) and objects (`?q[k]=`) are **rejected with a descriptive error naming the key and the allowlist**; a **missing required kwarg → 400** naming it; an **unknown param → 400** (rejected, not silently dropped). All raise the new `Ruact::BadRequestError` → HTTP 400 via the Story 8.4 structured payload. 9.5 writes ONLY the parallel `.next` codegen target (ownership flip is 9.8); `useQuery` request de-duplication is 9.6; the v1 substrate is untouched (demolition is 9.9). New files `lib/ruact/server_functions/query_source.rb`; new specs `query_source_spec.rb`, `:story_9_5` cases in `codegen_spec.rb` / `railtie_integration_spec.rb` / `query_request_spec.rb`; new runtime vitest `usequery.test.mjs` + query parity cases. ADR addendum (2026-06-10).
|
|
15
|
+
|
|
12
16
|
- **`Ruact::Query` base class + `ruact_queries` route macro** ([Story 9.4](../_bmad-output/implementation-artifacts/9-4-ruact-query-base-class-and-ruact-queries-route-macro.md)). Server QUERIES are now plain classes under `app/queries/` (`class CatalogQuery < ApplicationQuery`, `ApplicationQuery < Ruact::Query`) — each public method defined directly on the subclass is one query — mounted with one line in `routes.rb`: `ruact_queries CatalogQuery` draws one **named GET route per public method** (`def search_users` → `GET /q/searchUsers`, named `ruact_query_searchUsers`), all visible in `rails routes` (no hidden endpoint; the route table stays the single source of truth). The prefix is configurable via the new `Ruact.config.query_route_prefix` (default `"/q"`). Dispatch goes through an internal gem controller — one generated subclass per query class — inheriting the new `Ruact.config.query_parent_controller` (default `"ApplicationController"`, constantized lazily at route-draw), so the host's REAL callback chain (`authenticate_user!`, tenant scoping, Pundit) runs **before** the query class is instantiated. The query instance is fresh per request and receives its context via the constructor: `current_user` / `params` / `request` / `session` delegate to the dispatching controller (so `current_user` IS the host's own method), and `CatalogQuery.new(fake_context).categories` is unit-testable with no Rails boot. Per-query callback opt-out via the new `ruact_skip_before_action` class macro (mirrors Rails' `skip_before_action` signature incl. `only:`/`except:`/`raise: false`; scoped to the declaring query class by construction). Queries are GET — no CSRF; the method's return value is serialized through the same `ruact_props` / `Ruact::Serializable` / `strict_serialization` policy as Bucket 2 (new `BucketTwoPayload.serialize_value` extraction; `nil` → JSON `null` with 200), and a query raise renders the salvaged structured-error payload with the same 422/403/413/500 mapping (the GET gate is overridden for query dispatch — `Ruact::ConfigurationError` still re-raises loudly). Keyword arguments are passed best-effort from GET query params by name (the strict FR88 sanitization wire contract ships with `useQuery` in Story 9.5; codegen of query entries is also 9.5). New files `lib/ruact/query.rb`, `lib/ruact/routing.rb`, `lib/ruact/server_functions/query_dispatch.rb`, `lib/ruact/server_functions/query_context.rb`. New specs `query_spec.rb`, `query_context_spec.rb`, `query_request_spec.rb` + extensions to `configuration_spec.rb` / `bucket_two_payload_spec.rb`, all tagged `:story_9_4`.
|
|
13
17
|
|
|
14
18
|
- **Route-driven codegen for mutations + runtime re-target** ([Story 9.3](../_bmad-output/implementation-artifacts/9-3-route-driven-codegen-for-mutations-and-runtime-re-target.md)). The generated server-functions module is now derived from the **Rails route table** instead of the v1 registries: every non-GET routed action (POST/PUT/PATCH/DELETE) on a controller that `include Ruact::Server` becomes a callable server function — `resources :posts` is the only declaration, no `routes.rb` additions, no synthetic endpoint. New `Ruact::ServerFunctions::RouteSource` collects entries from the route set; the locked naming derivation table (recorded in the ADR) covers the RESTful writes (`posts#create` → `createPost`), custom member routes (`posts#publish` → `publishPost`), custom collection routes (`posts#publish_all` → `publishAllPosts`), singular resources (`resource :session` → `createSession`), and namespaced controllers with a **prefix** scheme (`admin/posts#create` → `createAdminPost`) so the merged JS namespace is collision-free by construction. Collisions fail loudly at boot naming both origins; the new `ruact_function_name :action, as: "jsId"` macro on `Ruact::Server` is the per-action rename escape hatch. The build always logs `[ruact] codegen: exposing …` (transparency over silence). The runtime gains `_makeServerFunction({ method, path, segments })` — it targets the real route + verb (e.g. `POST /posts`, `PUT /posts/:id`), interpolating `:id`-style path segments by name from the single FormData/object argument, and follows a Bucket-2 `{ "$redirect": "<path>" }` response client-side (via `globalThis.__ruact_navigate`, `window.location.assign` fallback) — the client half of the contract Story 9.2 emitted. The salvaged 8.1/8.2 behaviors (FormData branching, CSRF meta injection, text-first parsing, `RuactActionError`, `redirect: "error"`, the intersection action signature, `revalidate()`) are preserved via a shared `ruactInvoke` core; the v1 `_makeRef` accessor is byte-behavior-identical. The v2 codegen writes to a **parallel** target (`server-functions.next.{json,ts}`) for parity tests + inspection only — never imported by application code — so the v1 `server-functions.ts` is untouched (the per-app cutover to route-driven codegen is Story 9.8). The Ruby↔JS codegen byte-equality parity test is retained and extended for v2. New specs: `route_source_spec.rb`, `server_function_name_spec.rb`, v2 cases in `codegen_spec.rb` / `snapshot_spec.rb` / `railtie_integration_spec.rb` (tagged `:story_9_3`); +11 runtime vitest characterization tests; +5 codegen parity vitest cases.
|
|
@@ -1678,3 +1678,102 @@ model never reads or populates it.
|
|
|
1678
1678
|
**Deferred (noted, not ACs here):** install-generator scaffolding for
|
|
1679
1679
|
`app/queries/application_query.rb`; codegen + `useQuery` + FR88 kwargs (9.5);
|
|
1680
1680
|
dedup (9.6); docs rewrite (9.7); playground migration (9.8).
|
|
1681
|
+
|
|
1682
|
+
---
|
|
1683
|
+
|
|
1684
|
+
## 2026-06-10 — Story 9.5 — Queries in codegen, `useQuery` re-pointed, kwargs sanitized
|
|
1685
|
+
|
|
1686
|
+
Closes the read-side of the route-driven redesign: queries now appear in the
|
|
1687
|
+
v2 codegen module, `useQuery` issues a real `GET /q/<jsId>`, and the FR88
|
|
1688
|
+
kwargs contract is enforced server-side. Append-only addendum to the
|
|
1689
|
+
2026-06-02 (route-driven) and 2026-06-09 (Story 9.4) entries.
|
|
1690
|
+
|
|
1691
|
+
1. **Query codegen source = the drawn route table (route-truth), not the set
|
|
1692
|
+
of `Ruact::Query` subclasses (AC1).** `Ruact::ServerFunctions::QuerySource`
|
|
1693
|
+
enumerates drawn GET routes whose controller path is under
|
|
1694
|
+
`ruact/server_functions/query_dispatch/` (the generated dispatch
|
|
1695
|
+
controllers from Story 9.4) and emits one v2 query entry each. This is the
|
|
1696
|
+
sibling of `RouteSource` (mutations) and was chosen over enumerating
|
|
1697
|
+
`Ruact::Query.subclasses` deliberately: a host exposes a query ONLY by
|
|
1698
|
+
mounting it with `ruact_queries` in `routes.rb`, so the route table is the
|
|
1699
|
+
single source of truth. Reading subclasses would over-expose query classes
|
|
1700
|
+
that are defined but never mounted (and `useQuery` would 404 on their
|
|
1701
|
+
non-existent routes). **Consequence:** the "force-load `app/queries/` at
|
|
1702
|
+
`config.to_prepare`" gap flagged in the story is *moot* — mounting a class
|
|
1703
|
+
in `routes.rb` already autoloads it, so no force-load was added (avoiding
|
|
1704
|
+
the over-exposure a blind force-load-then-enumerate would cause). `QuerySource`
|
|
1705
|
+
is pure: the route set + a `query_class_for` resolver are injected (the
|
|
1706
|
+
railtie passes the real `__ruact_query_class` lookup; specs inject a lambda).
|
|
1707
|
+
|
|
1708
|
+
2. **Entry shape carries `accepts_params` for the TS signature (AC1).** A query
|
|
1709
|
+
entry is `{ js_identifier, kind: "query", http_method: "GET", path,
|
|
1710
|
+
segments: [], accepts_params, controller, action }`. `accepts_params` is
|
|
1711
|
+
true iff the query method declares any keyword argument
|
|
1712
|
+
(`:key`/`:keyreq`/`:keyrest`) and drives the emitted signature:
|
|
1713
|
+
`(params: Record<string, unknown>) => Promise<unknown>` when true,
|
|
1714
|
+
`() => Promise<unknown>` when false. `NameBridge.to_js_identifier` is reused
|
|
1715
|
+
verbatim (single source of truth for snake→camel), so the codegen jsId is
|
|
1716
|
+
byte-identical to the route the `ruact_queries` macro drew.
|
|
1717
|
+
|
|
1718
|
+
3. **Merged JS namespace + collision detection (AC1, Task 2).** Action entries
|
|
1719
|
+
(`RouteSource`) and query entries (`QuerySource`) share ONE namespace.
|
|
1720
|
+
`RouteSource` rejects action×action and `QuerySource` rejects query×query
|
|
1721
|
+
intra-kind; `ServerFunctions.detect_merged_namespace_collisions!` (run at
|
|
1722
|
+
the `write_v2_snapshot!` combine point) rejects route×query. All three name
|
|
1723
|
+
BOTH origins (`posts#categories and CatalogQuery#categories`). **Escape
|
|
1724
|
+
hatch:** the `ruact_function_name :<action>, as: "<id>"` rename macro on the
|
|
1725
|
+
*mutation controller* (Story 9.3) resolves a route×query clash; a
|
|
1726
|
+
query×query clash is resolved by renaming a query method. The query side has
|
|
1727
|
+
no rename macro because `routing.rb#draw_query_routes` derives the jsId
|
|
1728
|
+
purely from the method name via `NameBridge` (kept read-only this story) —
|
|
1729
|
+
renaming the method is the single, consistent lever, and keeps the route the
|
|
1730
|
+
macro draws and the codegen jsId in lockstep by construction.
|
|
1731
|
+
|
|
1732
|
+
4. **`useQuery` issues `GET /q/<jsId>` — the stale `POST /__ruact/fn/:id`
|
|
1733
|
+
mechanism is VOIDED (AC2).** The 2026-05-13 ADR clarification #5 ("hook reads
|
|
1734
|
+
`$$id`, POSTs `/__ruact/fn/:id`") is dead: Story 9.4 mounts queries as real
|
|
1735
|
+
named GET routes and the 2026-06-02 addendum restored HTTP GET semantics for
|
|
1736
|
+
reads (CSRF-free). The codegen emits `export const <id> = _makeQuery({ path,
|
|
1737
|
+
kind: "query" })`; `useQuery(reference, params?) → { data, loading, error }`
|
|
1738
|
+
invokes that reference inside a `useEffect`, tracking state and dropping a
|
|
1739
|
+
superseded in-flight response (params changed / unmounted). The reference is
|
|
1740
|
+
also directly callable for imperative use. `useQuery` and `_makeQuery` are
|
|
1741
|
+
re-exported from `@/.ruact/server-functions` by the codegen — `useQuery` only
|
|
1742
|
+
when the snapshot has ≥1 query entry, which keeps action-only and empty v2
|
|
1743
|
+
modules **byte-identical to Story 9.3** (minimal-churn import list:
|
|
1744
|
+
`_makeServerFunction` and/or `_makeQuery` are imported only when used).
|
|
1745
|
+
|
|
1746
|
+
5. **`useQuery` wire format = primitives in the query string; server validation
|
|
1747
|
+
is authoritative (AC3, FR88).** The client serializes `params` into the GET
|
|
1748
|
+
query string: `string`/`number`/`boolean` → `key=value`, `null` → `key=`
|
|
1749
|
+
(empty), and arrays/objects throw a client-side `TypeError` for immediate
|
|
1750
|
+
feedback. The SERVER is the authority: `query_dispatch.rb#__ruact_query_kwargs`
|
|
1751
|
+
reads `request.query_parameters` (the raw client params — NOT `params`, which
|
|
1752
|
+
carries Rails' `controller`/`action`/`format` defaults that must not count as
|
|
1753
|
+
"unknown") and enforces, in order: (a) **allowlist** — a value Rack parsed as
|
|
1754
|
+
an Array (`?q[]=`) or Hash (`?q[k]=`) is rejected (a scalar arrives as a
|
|
1755
|
+
String, the only non-`nil` primitive on the wire); (b) **unknown param** — a
|
|
1756
|
+
key matching no declared kwarg (and no `**rest`) is rejected, not dropped;
|
|
1757
|
+
(c) **missing required** — a `keyreq` the client omitted. All three raise the
|
|
1758
|
+
new `Ruact::BadRequestError` (`< Ruact::Error`) → **HTTP 400** via a new case
|
|
1759
|
+
in `__ruact_status_for`, rendered through the same Story 8.4 structured
|
|
1760
|
+
payload (`_ruact_server_action_error: true`, `error_class`, `message` naming
|
|
1761
|
+
the offending key + allowlist). Values are delivered to the query method as
|
|
1762
|
+
Strings (GET semantics, unchanged from 9.4's best-effort) — FR88 governs the
|
|
1763
|
+
*shape* on the wire, not type coercion, which stays the method's concern.
|
|
1764
|
+
|
|
1765
|
+
6. **React becomes a `peerDependency` of the runtime package.** `useQuery` is
|
|
1766
|
+
the runtime's first React import (`useState`/`useEffect`). React is declared
|
|
1767
|
+
as a `peerDependency` (`">=18"`) — every host already has it; the runtime
|
|
1768
|
+
never bundles its own copy. The mutation path (`_makeRef` /
|
|
1769
|
+
`_makeServerFunction`) stays React-free. Package version `0.2.0` → `0.3.0`.
|
|
1770
|
+
The hook is covered by jsdom + `@testing-library/react` vitest
|
|
1771
|
+
(`usequery.test.mjs`); those dev-only deps live in the runtime package and
|
|
1772
|
+
run via its own `npm test` (the vite-plugin parity run globs only the
|
|
1773
|
+
node-environment `index.test.mjs`).
|
|
1774
|
+
|
|
1775
|
+
7. **Scope guards (unchanged from the story plan).** 9.5 writes ONLY the
|
|
1776
|
+
parallel `.next` codegen target (the ownership flip to the real
|
|
1777
|
+
`server-functions.ts` is 9.8). Request de-duplication for `useQuery` is 9.6
|
|
1778
|
+
(the hook fetches once per mount; a `useSyncExternalStore` refactor can layer
|
|
1779
|
+
dedup later). The v1 substrate is untouched (demolition is 9.9).
|
data/lib/ruact/errors.rb
CHANGED
|
@@ -63,6 +63,17 @@ module Ruact
|
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
# Story 9.5 (FR88) — raised by the query dispatch controller when a
|
|
67
|
+
# `useQuery` request's parameters violate the kwargs allowlist: a complex
|
|
68
|
+
# value (array / object) where only `string | number | boolean | null` is
|
|
69
|
+
# accepted, a missing required keyword, or an unknown parameter that matches
|
|
70
|
+
# no declared kwarg. Inherits from `Ruact::Error` so the Story 8.4
|
|
71
|
+
# `rescue_from StandardError` chain on the dispatch controller catches it
|
|
72
|
+
# cleanly; `__ruact_status_for` maps it to HTTP 400 (Bad Request). The
|
|
73
|
+
# message names the offending key and the allowlist so the React dev can
|
|
74
|
+
# fix the call site without reading the server source.
|
|
75
|
+
class BadRequestError < Error; end
|
|
76
|
+
|
|
66
77
|
# Story 8.5 — raised by `EndpointController#__ruact_enforce_upload_limit!`
|
|
67
78
|
# when an inbound multipart / urlencoded request's `Content-Length` exceeds
|
|
68
79
|
# `Ruact.config.max_upload_bytes`. The exception inherits from
|
|
@@ -62,12 +62,26 @@ module Ruact
|
|
|
62
62
|
"& ((formData: FormData) => Promise<void>)"
|
|
63
63
|
QUERY_SIGNATURE = "() => Promise<unknown>"
|
|
64
64
|
|
|
65
|
+
# Story 9.5 — a query method that declares keyword arguments (FR88
|
|
66
|
+
# params) gets the param-accepting signature; one with no kwargs keeps
|
|
67
|
+
# the bare {QUERY_SIGNATURE}. Queries are read-only (never reachable via
|
|
68
|
+
# `<form action>`), so neither widens to the action intersection.
|
|
69
|
+
QUERY_PARAMS_SIGNATURE = "(params: Record<string, unknown>) => Promise<unknown>"
|
|
70
|
+
|
|
65
71
|
# Story 8.2 — fixed re-export appended AFTER the per-function block.
|
|
66
72
|
# Emitted in BOTH branches (empty + populated registry) so
|
|
67
73
|
# `import { revalidate } from "@/.ruact/server-functions"` works on
|
|
68
74
|
# day one of any host app. Ruby + JS codegens emit byte-identically.
|
|
69
75
|
REVALIDATE_REEXPORT = "export { revalidate } from #{RUNTIME_IMPORT};\n".freeze
|
|
70
76
|
|
|
77
|
+
# Story 9.5 — the `useQuery` hook re-export, appended (after
|
|
78
|
+
# {REVALIDATE_REEXPORT}) ONLY when the v2 snapshot carries query entries.
|
|
79
|
+
# Gating on query presence keeps the action-only and empty v2 modules
|
|
80
|
+
# byte-identical to their Story 9.3 output (minimal churn); a host that
|
|
81
|
+
# has no queries cannot call `useQuery` on anything anyway. Ruby + JS
|
|
82
|
+
# codegens emit this byte-identically.
|
|
83
|
+
USEQUERY_REEXPORT = "export { useQuery } from #{RUNTIME_IMPORT};\n".freeze
|
|
84
|
+
|
|
71
85
|
# JS identifier shape — same as `NameBridge::VALID_SYMBOL` but expressed
|
|
72
86
|
# in JS-identifier terms (leading letter / underscore / `$`, then alnum
|
|
73
87
|
# / underscore / `$`). The codegen validates every entry it consumes
|
|
@@ -20,23 +20,39 @@ module Ruact
|
|
|
20
20
|
# LINE_TERMINATORS, VALID_JS_IDENTIFIER) are reused from {Codegen} via
|
|
21
21
|
# lexical scope.
|
|
22
22
|
module V2
|
|
23
|
-
# Verbs a v2 entry may carry (mirrors {RouteSource::MUTATION_VERBS}).
|
|
23
|
+
# Verbs a v2 ACTION entry may carry (mirrors {RouteSource::MUTATION_VERBS}).
|
|
24
24
|
HTTP_METHODS = %w[POST PUT PATCH DELETE].to_set.freeze
|
|
25
25
|
|
|
26
|
+
# Story 9.5 — the verb a v2 QUERY entry may carry. Queries are reads,
|
|
27
|
+
# mounted by {Ruact::Routing#ruact_queries} as named GET routes; the
|
|
28
|
+
# 2026-06-02 ADR addendum voided the old `POST /__ruact/fn/:id` query
|
|
29
|
+
# mechanism, so a query entry is GET-only.
|
|
30
|
+
QUERY_HTTP_METHODS = %w[GET].to_set.freeze
|
|
31
|
+
|
|
26
32
|
class << self
|
|
27
33
|
# @param version [Integer, String]
|
|
28
34
|
# @param generated_at [String]
|
|
29
|
-
# @param functions [Array<Hash>] route-derived entries
|
|
35
|
+
# @param functions [Array<Hash>] route-derived entries (actions AND,
|
|
36
|
+
# Story 9.5, queries).
|
|
30
37
|
# @return [String] TS module bytes (single trailing newline).
|
|
31
38
|
# @raise [Ruact::ConfigurationError] on any trust-boundary violation.
|
|
32
39
|
def render(version, generated_at, functions)
|
|
33
40
|
validate_functions!(functions)
|
|
34
41
|
|
|
42
|
+
has_query = functions.any? { |entry| fetch(entry, "kind").to_s == "query" }
|
|
43
|
+
has_action = functions.any? { |entry| fetch(entry, "kind").to_s == "action" }
|
|
44
|
+
|
|
45
|
+
# Import only what is used. Keep `_makeServerFunction` in the empty
|
|
46
|
+
# case so the no-functions module stays byte-identical to Story 9.3.
|
|
47
|
+
imports = []
|
|
48
|
+
imports << "_makeServerFunction" if has_action || functions.empty?
|
|
49
|
+
imports << "_makeQuery" if has_query
|
|
50
|
+
|
|
35
51
|
io = +""
|
|
36
52
|
io << "// AUTO-GENERATED by vite-plugin-ruact (Story 9.3). DO NOT EDIT.\n"
|
|
37
53
|
io << "// Source: Rails route table (version #{version})\n"
|
|
38
54
|
io << "// Generated at: #{generated_at}\n"
|
|
39
|
-
io << "import {
|
|
55
|
+
io << "import { #{imports.join(', ')} } from #{RUNTIME_IMPORT};\n"
|
|
40
56
|
|
|
41
57
|
if functions.empty?
|
|
42
58
|
io << "\n// (no server functions exposed yet — add a non-GET route on a Ruact::Server controller)\n"
|
|
@@ -48,12 +64,15 @@ module Ruact
|
|
|
48
64
|
|
|
49
65
|
io << "\n"
|
|
50
66
|
io << REVALIDATE_REEXPORT
|
|
67
|
+
io << USEQUERY_REEXPORT if has_query
|
|
51
68
|
io
|
|
52
69
|
end
|
|
53
70
|
|
|
54
71
|
private
|
|
55
72
|
|
|
56
73
|
def render_export(entry)
|
|
74
|
+
return render_query_export(entry) if fetch(entry, "kind").to_s == "query"
|
|
75
|
+
|
|
57
76
|
js_id = fetch(entry, "js_identifier")
|
|
58
77
|
method = fetch(entry, "http_method")
|
|
59
78
|
path = fetch(entry, "path")
|
|
@@ -66,6 +85,20 @@ module Ruact
|
|
|
66
85
|
"export const #{js_id}: #{ACTION_SIGNATURE} =\n _makeServerFunction(#{descriptor});\n"
|
|
67
86
|
end
|
|
68
87
|
|
|
88
|
+
# Story 9.5 — a query export binds a `_makeQuery` accessor carrying
|
|
89
|
+
# its GET descriptor `{ path, kind: "query" }`. `useQuery(<id>, …)`
|
|
90
|
+
# consumes it (the SUPERSEDED `POST /__ruact/fn/:id` mechanism is
|
|
91
|
+
# gone — reads go to `GET /q/<jsId>`). The signature accepts params
|
|
92
|
+
# only when the query method declares kwargs (FR88).
|
|
93
|
+
def render_query_export(entry)
|
|
94
|
+
js_id = fetch(entry, "js_identifier")
|
|
95
|
+
path = fetch(entry, "path")
|
|
96
|
+
signature = fetch(entry, "accepts_params") ? QUERY_PARAMS_SIGNATURE : QUERY_SIGNATURE
|
|
97
|
+
|
|
98
|
+
descriptor = "{ path: #{JSON.dump(path)}, kind: \"query\" }"
|
|
99
|
+
"export const #{js_id}: #{signature} =\n _makeQuery(#{descriptor});\n"
|
|
100
|
+
end
|
|
101
|
+
|
|
69
102
|
def validate_functions!(functions)
|
|
70
103
|
unless functions.is_a?(Array)
|
|
71
104
|
raise Ruact::ConfigurationError,
|
|
@@ -84,8 +117,9 @@ module Ruact
|
|
|
84
117
|
|
|
85
118
|
js_id = fetch(entry, "js_identifier")
|
|
86
119
|
validate_identifier!(js_id, seen)
|
|
87
|
-
|
|
88
|
-
|
|
120
|
+
kind = fetch(entry, "kind").to_s
|
|
121
|
+
validate_kind!(js_id, kind)
|
|
122
|
+
validate_method!(js_id, kind, fetch(entry, "http_method"))
|
|
89
123
|
path = fetch(entry, "path")
|
|
90
124
|
validate_path!(js_id, path)
|
|
91
125
|
validate_segments!(js_id, path, fetch(entry, "segments"))
|
|
@@ -112,19 +146,21 @@ module Ruact
|
|
|
112
146
|
end
|
|
113
147
|
|
|
114
148
|
def validate_kind!(js_id, kind)
|
|
115
|
-
return if kind
|
|
149
|
+
return if %w[action query].include?(kind)
|
|
116
150
|
|
|
117
151
|
raise Ruact::ConfigurationError,
|
|
118
152
|
"ruact server-function codegen: v2 snapshot entry #{js_id.inspect} has invalid " \
|
|
119
|
-
"kind #{kind.inspect} (v2 entries are
|
|
153
|
+
"kind #{kind.inspect} (v2 entries are \"action\" or \"query\")."
|
|
120
154
|
end
|
|
121
155
|
|
|
122
|
-
|
|
123
|
-
|
|
156
|
+
# Story 9.5 — actions carry a mutation verb; queries are GET-only.
|
|
157
|
+
def validate_method!(js_id, kind, method)
|
|
158
|
+
allowed = kind == "query" ? QUERY_HTTP_METHODS : HTTP_METHODS
|
|
159
|
+
return if allowed.include?(method)
|
|
124
160
|
|
|
125
161
|
raise Ruact::ConfigurationError,
|
|
126
162
|
"ruact server-function codegen: v2 snapshot entry #{js_id.inspect} has invalid " \
|
|
127
|
-
"http_method #{method.inspect} (must be one of #{
|
|
163
|
+
"http_method #{method.inspect} (must be one of #{allowed.to_a.inspect})."
|
|
128
164
|
end
|
|
129
165
|
|
|
130
166
|
def validate_path!(js_id, path)
|
|
@@ -119,6 +119,7 @@ module Ruact
|
|
|
119
119
|
end
|
|
120
120
|
|
|
121
121
|
# Story 8.4 — Status mapping per AC1:
|
|
122
|
+
# - `Ruact::BadRequestError` → 400 (Story 9.5 — FR88 kwargs rejection)
|
|
122
123
|
# - `ActiveRecord::RecordInvalid` → 422
|
|
123
124
|
# - `ActionController::InvalidAuthenticityToken` → 403
|
|
124
125
|
# - `Ruact::UploadTooLargeError` → 413
|
|
@@ -127,6 +128,7 @@ module Ruact
|
|
|
127
128
|
# at load time (parity with {ErrorSuggestion}).
|
|
128
129
|
def __ruact_status_for(error)
|
|
129
130
|
case error.class.name
|
|
131
|
+
when "Ruact::BadRequestError" then 400
|
|
130
132
|
when "ActiveRecord::RecordInvalid" then 422
|
|
131
133
|
when "ActionController::InvalidAuthenticityToken" then 403
|
|
132
134
|
when "Ruact::UploadTooLargeError" then 413
|
|
@@ -47,16 +47,21 @@ module Ruact
|
|
|
47
47
|
|
|
48
48
|
# Story 8.2 (2026-05-17 review patches R2 + R12) — names already
|
|
49
49
|
# bound at the top of `app/javascript/.ruact/server-functions.ts`,
|
|
50
|
-
# either by
|
|
51
|
-
# import (`_makeRef`). A
|
|
52
|
-
# `ruact_action :
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
50
|
+
# either by a helper re-export (`revalidate`, `useQuery`) or a runtime
|
|
51
|
+
# import (`_makeRef`, `_makeServerFunction`, `_makeQuery`). A
|
|
52
|
+
# `ruact_action :revalidate` / a query method `use_query` would emit a
|
|
53
|
+
# clashing `export const` / duplicate export next to the existing
|
|
54
|
+
# binding and crash the generated module at load time. The rule fires
|
|
55
|
+
# at controller-class load / route-draw so the failure surfaces during
|
|
56
|
+
# boot, not at first request.
|
|
57
|
+
# Story 9.5 added `_makeQuery` (the v2 query import) and `useQuery` (the
|
|
58
|
+
# query hook re-export) to this list.
|
|
56
59
|
RESERVED_BY_RUACT = %w[
|
|
60
|
+
_makeQuery
|
|
57
61
|
_makeRef
|
|
58
62
|
_makeServerFunction
|
|
59
63
|
revalidate
|
|
64
|
+
useQuery
|
|
60
65
|
].to_set.freeze
|
|
61
66
|
|
|
62
67
|
class << self
|
|
@@ -40,6 +40,14 @@ module Ruact
|
|
|
40
40
|
# The Method#parameters types that mark keyword arguments (D7).
|
|
41
41
|
KEYWORD_PARAM_TYPES = %i[key keyreq].freeze
|
|
42
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
|
+
|
|
43
51
|
private
|
|
44
52
|
|
|
45
53
|
# The action body of every query action: fresh context + fresh query
|
|
@@ -55,19 +63,76 @@ module Ruact
|
|
|
55
63
|
render json: ActiveSupport::JSON.encode(serialized)
|
|
56
64
|
end
|
|
57
65
|
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
# (
|
|
61
|
-
#
|
|
62
|
-
#
|
|
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.
|
|
63
86
|
def __ruact_query_kwargs(query, query_method)
|
|
64
|
-
query.method(query_method).parameters
|
|
65
|
-
|
|
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
|
|
66
105
|
|
|
67
|
-
|
|
106
|
+
provided.each_with_object({}) do |(key, value), kwargs|
|
|
107
|
+
kwargs[key.to_sym] = value if declared.include?(key) || accepts_rest
|
|
68
108
|
end
|
|
69
109
|
end
|
|
70
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
|
+
|
|
71
136
|
# D5 — the {Ruact::Server} mutation gate returns false for GET/HEAD so
|
|
72
137
|
# GET *pages* keep stock Rails errors; query dispatch requests are GET
|
|
73
138
|
# *function calls*, so the structured 8.4 payload must render here.
|
|
@@ -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
|