Class: Rhales::Context

Inherits:
Object
  • Object
show all
Defined in:
lib/rhales/core/context.rb

Overview

RSFCContext provides a clean interface for RSFC templates to access server-side data. Follows the established pattern from InitScriptContext and EnvironmentContext for focused, single-responsibility context objects.

The context provides three layers of data: 1. Request: Framework-provided data (CSRF tokens, authentication, config) 2. Server: Template-only variables (page titles, HTML content, etc.) 3. Client: Application data that gets serialized to window state

Request data and server data are accessible in templates. Client data takes precedence over server data for variable resolution. Only client data is serialized to the browser via sections.

One RSFCContext instance is created per page render and shared across the main template and all partials to maintain security boundaries.

Defined Under Namespace

Classes: MinimalRequest

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(req, client: {}, server: {}, config: nil) ⇒ Context

Returns a new instance of Context.



28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/rhales/core/context.rb', line 28

def initialize(req, client: {}, server: {}, config: nil)
  @req           = req
  @config        = config || Rhales.configuration

  # Normalize keys to strings for consistent access and expose with clean names
  @client_data = normalize_keys(client).freeze
  @client = @client_data  # Public accessor

  # Build context layers (three-layer model: request + server + client)
  # Server data is merged with built-in request/app data
  @server_data = build_app_data.merge(normalize_keys(server)).freeze
  @server = @server_data  # Public accessor

  # Pre-compute all_data before freezing
  # Client takes precedence over server, and add app namespace for backward compatibility
  @all_data = @server_data.merge(@client_data).merge({ 'app' => @server_data }).freeze

  # Make context immutable after creation
  freeze
end

Instance Attribute Details

#all_dataObject (readonly)

Get all available data (runtime + business + computed)



83
84
85
# File 'lib/rhales/core/context.rb', line 83

def all_data
  @all_data
end

#clientObject (readonly)

Returns the value of attribute client.



26
27
28
# File 'lib/rhales/core/context.rb', line 26

def client
  @client
end

#configObject (readonly)

Returns the value of attribute config.



26
27
28
# File 'lib/rhales/core/context.rb', line 26

def config
  @config
end

#reqObject (readonly)

Returns the value of attribute req.



26
27
28
# File 'lib/rhales/core/context.rb', line 26

def req
  @req
end

#serverObject (readonly)

Returns the value of attribute server.



26
27
28
# File 'lib/rhales/core/context.rb', line 26

def server
  @server
end

Class Method Details

.for_view(req, client: {}, server: {}, config: nil, **additional_client) ⇒ Object

Create context with business data for a specific view



337
338
339
340
# File 'lib/rhales/core/context.rb', line 337

def for_view(req, client: {}, server: {}, config: nil, **additional_client)
  all_client = client.merge(additional_client)
  new(req, client: all_client, server: server, config: config)
end

.minimal(client: {}, server: {}, config: nil, env: nil) ⇒ Object

Create minimal context for testing with optional env override



343
344
345
346
# File 'lib/rhales/core/context.rb', line 343

def minimal(client: {}, server: {}, config: nil, env: nil)
  req = env ? MinimalRequest.new(env) : nil
  new(req, client: client, server: server, config: config)
end

Instance Method Details

#authenticated?Boolean (private)

Check if user is authenticated

Returns:

  • (Boolean)


232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/rhales/core/context.rb', line 232

def authenticated?
  # Try Otto strategy_result first (framework integration)
  if req&.respond_to?(:env)
    strategy_result = req.env['otto.strategy_result']
    if strategy_result&.respond_to?(:authenticated?)
      return strategy_result.authenticated? && valid_user_present?
    end
  end

  # Fall back to checking session and user directly
  sess&.authenticated? && valid_user_present?
end

#available_variablesObject

Get list of all available variable paths (for validation)



91
92
93
# File 'lib/rhales/core/context.rb', line 91

def available_variables
  collect_variable_paths(all_data)
end

#build_app_dataObject (private)

Build framework-provided server data



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/rhales/core/context.rb', line 191

def build_app_data
  app = {}

  # Request context (from current runtime_data)
  if req && req.respond_to?(:env) && req.env
    app['csrf_token'] = req.env.fetch(@config.csrf_token_name, nil)
    app['nonce'] = get_or_generate_nonce
    app['request_id'] = req.env.fetch('request_id', nil)
    app['domain_strategy'] = req.env.fetch('domain_strategy', :default)
    app['display_domain'] = req.env.fetch('display_domain', nil)
  else
    # Generate nonce even without request if CSP is enabled
    app['nonce'] = get_or_generate_nonce
  end

  # Configuration (from both layers)
  app['environment'] = @config.app_environment
  app['api_base_url'] = @config.api_base_url
  app['features'] = @config.features
  app['development'] = @config.development?

  # Authentication & UI (from current computed_data)
  app['authenticated'] = authenticated?
  app['theme_class'] = determine_theme_class

  app
end

#collect_variable_paths(data, prefix = '') ⇒ Object (private)

Recursively collect all variable paths from nested data



290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
# File 'lib/rhales/core/context.rb', line 290

def collect_variable_paths(data, prefix = '')
  paths = []

  case data
  when Hash
    data.each do |key, value|
      current_path = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
      paths << current_path

      if value.is_a?(Hash) || value.is_a?(Object)
        paths.concat(collect_variable_paths(value, current_path))
      end
    end
  when Object
    # For objects, collect method names that look like attributes
    data.public_methods(false).each do |method|
      method_name = method.to_s
      next if method_name.end_with?('=') # Skip setters
      next if method_name.start_with?('_') # Skip private-ish methods

      current_path = prefix.empty? ? method_name : "#{prefix}.#{method_name}"
      paths << current_path
    end
  end

  paths
end

#csp_nonce_required?Boolean (private)

Check if CSP policy requires nonce

Returns:

  • (Boolean)


266
267
268
269
270
271
# File 'lib/rhales/core/context.rb', line 266

def csp_nonce_required?
  return false unless @config.csp_enabled

  csp = CSP.new(@config)
  csp.nonce_required?
end

#determine_theme_classObject (private)

Determine theme class for CSS



220
221
222
223
224
225
226
227
228
229
# File 'lib/rhales/core/context.rb', line 220

def determine_theme_class
  # Default theme logic - can be overridden by business data
  if @client_data['theme']
    "theme-#{@client_data['theme']}"
  elsif user && user.respond_to?(:theme_preference)
    "theme-#{user.theme_preference}"
  else
    'theme-light'
  end
end

#get(variable_path) ⇒ Object

Get variable value with dot notation support (e.g., “user.id”, “features.account_creation”)



50
51
52
53
54
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
# File 'lib/rhales/core/context.rb', line 50

def get(variable_path)
  path_parts    = variable_path.split('.')
  current_value = all_data

  path_parts.each do |part|
    case current_value
    when Hash
      if current_value.key?(part)
        current_value = current_value[part]
      elsif current_value.key?(part.to_sym)
        current_value = current_value[part.to_sym]
      else
        return nil
      end
    when Object
      if current_value.respond_to?(part)
        current_value = current_value.public_send(part)
      elsif current_value.respond_to?("#{part}?")
        current_value = current_value.public_send("#{part}?")
      else
        return nil
      end
    else
      return nil
    end

    return nil if current_value.nil?
  end

  current_value
end

#get_or_generate_nonceObject (private)

Get or generate CSP nonce



251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/rhales/core/context.rb', line 251

def get_or_generate_nonce
  # Try to get existing nonce from request env
  if req && req.respond_to?(:env) && req.env
    existing_nonce = req.env.fetch(@config.nonce_header_name, nil)
    return existing_nonce if existing_nonce
  end

  # Generate new nonce if auto_nonce is enabled or CSP is enabled
  return CSP.generate_nonce if @config.auto_nonce || (@config.csp_enabled && csp_nonce_required?)

  # Return nil if nonce is not needed
  nil
end

#localeString

Get locale from request

Parses locale from HTTP_ACCEPT_LANGUAGE or rhales.locale env variable

Returns:

  • (String)

    Locale code



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/rhales/core/context.rb', line 136

def locale
  return @config.default_locale unless req

  if req.respond_to?(:env) && req.env
    # Check for custom rhales.locale first, then HTTP_ACCEPT_LANGUAGE
    custom_locale = req.env['rhales.locale']
    return custom_locale if custom_locale

    # Parse HTTP_ACCEPT_LANGUAGE header
    accept_language = req.env['HTTP_ACCEPT_LANGUAGE']
    if accept_language
      # Extract first locale from Accept-Language header (e.g., "en-US,en;q=0.9" -> "en-US")
      first_locale = accept_language.split(',').first&.strip&.split(';')&.first
      return first_locale if first_locale && !first_locale.empty?
    end

    @config.default_locale
  else
    @config.default_locale
  end
end

#merge_client(additional_client_data) ⇒ Object

Create a new context with merged client data



179
180
181
182
183
184
185
186
# File 'lib/rhales/core/context.rb', line 179

def merge_client(additional_client_data)
  self.class.new(
    @req,
    client: @client_data.merge(normalize_keys(additional_client_data)),
    server: @server_data,
    config: @config,
  )
end

#normalize_keys(data) ⇒ Object (private)

Normalize hash keys to strings recursively



276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/rhales/core/context.rb', line 276

def normalize_keys(data)
  case data
  when Hash
    data.each_with_object({}) do |(key, value), result|
      result[key.to_s] = normalize_keys(value)
    end
  when Array
    data.map { |item| normalize_keys(item) }
  else
    data
  end
end

#requestObject

Add accessor for request data (maps to @server_data for ‘app’ namespace compatibility)



101
102
103
# File 'lib/rhales/core/context.rb', line 101

def request
  @server_data
end

#resolve_variable(variable_path) ⇒ Object

Resolve variable (alias for get method for hydrator compatibility)



96
97
98
# File 'lib/rhales/core/context.rb', line 96

def resolve_variable(variable_path)
  get(variable_path)
end

#sessHash, AnonymousSession

Get session from request

Requires: Request object must respond to #session Provided by: Rack::Request extension in Onetime

Returns:

  • (Hash, AnonymousSession)

    Rack session hash or anonymous session



111
112
113
114
115
116
117
118
# File 'lib/rhales/core/context.rb', line 111

def sess
  return Adapters::AnonymousSession.new unless req&.respond_to?(:session)

  session = req.session
  # If session is a plain hash, wrap it in AnonymousSession
  return Adapters::AnonymousSession.new if session.is_a?(Hash) && !session.respond_to?(:authenticated?)
  session
end

#userObject, AnonymousAuth

Get user from request

Requires: Request object must respond to #user Provided by: Rack::Request extension in Onetime

Returns:

  • (Object, AnonymousAuth)

    User object or anonymous auth



126
127
128
129
# File 'lib/rhales/core/context.rb', line 126

def user
  return Adapters::AnonymousAuth.new unless req&.respond_to?(:user)
  req.user
end

#valid_user_present?Boolean (private)

Check if we have a valid (non-anonymous) user

Returns:

  • (Boolean)


246
247
248
# File 'lib/rhales/core/context.rb', line 246

def valid_user_present?
  user && !user.anonymous?
end

#variable?(variable_path) ⇒ Boolean

Check if variable exists

Returns:

  • (Boolean)


86
87
88
# File 'lib/rhales/core/context.rb', line 86

def variable?(variable_path)
  !get(variable_path).nil?
end

#with_client(new_client_data) ⇒ Object

Create a new context with updated client data



159
160
161
162
163
164
165
166
# File 'lib/rhales/core/context.rb', line 159

def with_client(new_client_data)
  self.class.new(
    @req,
    client: normalize_keys(new_client_data),
    server: @server_data,
    config: @config,
  )
end

#with_server(new_server_data) ⇒ Object

Create a new context with updated server data



169
170
171
172
173
174
175
176
# File 'lib/rhales/core/context.rb', line 169

def with_server(new_server_data)
  self.class.new(
    @req,
    client: @client_data,
    server: normalize_keys(new_server_data),
    config: @config,
  )
end