Module: LcpRuby::Metadata::PathTemplate
- Defined in:
- lib/lcp_ruby/metadata/path_template.rb
Overview
Path templating for menu YAML strings.
Substitutes ‘]` placeholders in URL, label, aria_label, and `defaults:` values. The namespace is a closed whitelist:
- `current_user.<a>[.<b>[.<c>]]` — chained `public_send` on the
current user, 1–3 hops. e.g. `{current_user.id}`,
`{current_user.organization.slug}`,
`{current_user.profile.address.city}`.
- `lcp_ruby.<helper>` — route helper from
`LcpRuby::Engine.routes.url_helpers`. 1 hop only.
e.g. `{lcp_ruby.destroy_user_session_path}`.
- `application.<helper>` — route helper from
`Rails.application.routes.url_helpers`. 1 hop only.
e.g. `{application.root_path}`.
Validation runs in two stages:
1. **Eager** (`validate_eager`) — structural checks (token
grammar, namespace allowlist, hop count). Called inline from
`ConfigurationValidator` at config-load time.
2. **Deferred** (`validate_deferred` / `run_deferred_checks!`) —
route-helper resolvability + arity check. Wired into
`Rails.application.config.to_prepare` so it runs at boot AND
on every `reload_routes!` (LCP's SchemaManager triggers
reloads when YAML models change at runtime, which can add or
remove route helpers).
Templates collected during eager validation are stashed in a module-level registry so the deferred pass can drain them. Call ‘reset!` at the start of each eager walk to avoid stale entries accumulating across reloads.
Constant Summary collapse
- TOKEN_RE =
1–3 hops on ‘namespace.member1[.member2]`.
/\{(\w+)((?:\.\w+){1,3})\}/.freeze
- ALLOWED_NAMESPACES =
%w[current_user lcp_ruby application].freeze
Class Method Summary collapse
- .collect_for_deferred(template) ⇒ Object
- .collected_templates ⇒ Object
- .reset! ⇒ Object
-
.resolve(template, current_user:, request: nil) ⇒ Object
Runtime resolution.
- .run_deferred_checks! ⇒ Object
-
.validate_deferred(template) ⇒ Object
Deferred validation: route-helper resolvability + arity check.
-
.validate_eager(template) ⇒ Object
Eager validation: pure structural (no route tables needed).
Class Method Details
.collect_for_deferred(template) ⇒ Object
164 165 166 167 168 169 170 171 |
# File 'lib/lcp_ruby/metadata/path_template.rb', line 164 def collect_for_deferred(template) return unless template.is_a?(String) && template.include?("{") REGISTRY_MUTEX.synchronize do @templates ||= [] @templates << template end end |
.collected_templates ⇒ Object
173 174 175 |
# File 'lib/lcp_ruby/metadata/path_template.rb', line 173 def collected_templates REGISTRY_MUTEX.synchronize { (@templates || []).dup } end |
.reset! ⇒ Object
160 161 162 |
# File 'lib/lcp_ruby/metadata/path_template.rb', line 160 def reset! REGISTRY_MUTEX.synchronize { @templates = [] } end |
.resolve(template, current_user:, request: nil) ⇒ Object
Runtime resolution. Substitutes every ‘nsns.m…` token in `template` with the resolved value. Returns the template unchanged when no substitution is needed (non-String input, no `{` present).
Raises ‘LcpRuby::MenuRenderError` when a `current_user.*` chain hits nil mid-chain or calls a method the receiver doesn’t respond to. Raises ‘LcpRuby::MetadataError` for boot-validatable shapes that somehow escape the eager pass. Centralized rescue lives in `LayoutHelper#menu_item_url` etc. — partials must NOT rescue.
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
# File 'lib/lcp_ruby/metadata/path_template.rb', line 55 def resolve(template, current_user:, request: nil) return template unless template.is_a?(String) && template.include?("{") template.gsub(TOKEN_RE) do namespace = Regexp.last_match(1) chain = Regexp.last_match(2).split(".").reject(&:empty?) case namespace when "current_user" raise MenuRenderError, "no current_user available for template #{template.inspect}" unless current_user resolve_chain(current_user, chain, namespace: "current_user") when "lcp_ruby" if chain.size != 1 raise MetadataError, "lcp_ruby. namespace allows only 1 hop, got #{chain.size}" end LcpRuby::Engine.routes.url_helpers.public_send(chain.first).to_s when "application" if chain.size != 1 raise MetadataError, "application. namespace allows only 1 hop, got #{chain.size}" end Rails.application.routes.url_helpers.public_send(chain.first).to_s else raise MetadataError, "unknown template namespace: #{namespace}" end end end |
.run_deferred_checks! ⇒ Object
177 178 179 180 181 182 183 184 185 186 187 188 189 190 |
# File 'lib/lcp_ruby/metadata/path_template.rb', line 177 def run_deferred_checks! snapshot = REGISTRY_MUTEX.synchronize { (@templates || []).dup } errors = [] snapshot.each do |t| begin validate_deferred(t) rescue MetadataError => e errors << e. end end return if errors.empty? raise MetadataError, errors.join("\n") end |
.validate_deferred(template) ⇒ Object
Deferred validation: route-helper resolvability + arity check. Wired into ‘Rails.application.config.to_prepare` so both `LcpRuby::Engine.routes` and `Rails.application.routes` are fully drawn AND the check re-runs whenever LCP triggers `reload_routes!`. Uses `named_routes.helper_names` rather than `url_helpers.respond_to?(…)` because the helpers module is not always finalized at this stage, while `helper_names` is populated as soon as the route is drawn.
131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/lcp_ruby/metadata/path_template.rb', line 131 def validate_deferred(template) return unless template.is_a?(String) template.scan(TOKEN_RE) do |namespace, chain_str| chain = chain_str.split(".").reject(&:empty?) case namespace when "lcp_ruby" check_helper(LcpRuby::Engine.routes, chain.first, "engine") when "application" check_helper(Rails.application.routes, chain.first, "application") end end end |
.validate_eager(template) ⇒ Object
Eager validation: pure structural (no route tables needed). Called inline from ‘ConfigurationValidator#validate_menu_items`.
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 |
# File 'lib/lcp_ruby/metadata/path_template.rb', line 86 def validate_eager(template) return unless template.is_a?(String) # Reject any `{...}` substring that doesn't match the grammar. # Catches malformed tokens like `{}`, `{a}`, `{a..b}`, `{a.}`, # `{.b}`, `{a.b.c.d.e}` (4 hops). template.scan(/\{[^}]*\}/) do |raw| unless raw.match?(/\A\{(\w+)(?:\.\w+){1,3}\}\z/) raise MetadataError, "malformed path template token #{raw.inspect} " \ "(expected {namespace.member} with 1-3 dot-separated members)" end end template.scan(TOKEN_RE) do |namespace, chain_str| chain = chain_str.split(".").reject(&:empty?) case namespace when "current_user" # Types depend on runtime — leave to runtime resolve. when "lcp_ruby" if chain.size != 1 raise MetadataError, "lcp_ruby. namespace allows only 1 hop, got #{chain.size}" end when "application" if chain.size != 1 raise MetadataError, "application. namespace allows only 1 hop, got #{chain.size}" end else raise MetadataError, "unknown template namespace: #{namespace.inspect} " \ "(allowed: #{ALLOWED_NAMESPACES.join(', ')})" end end end |