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

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_templatesObject



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

Raises:



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.message
    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