Class: Rhales::Middleware::JsonResponder

Inherits:
Object
  • Object
show all
Defined in:
lib/rhales/middleware/json_responder.rb

Overview

Rack middleware that returns hydration data as JSON when Accept: application/json

When a request has Accept: application/json header, this middleware intercepts the response and returns just the hydration data as JSON instead of rendering the full HTML template.

This enables: - API clients to fetch data from the same endpoints - Testing hydration data without parsing HTML - Development inspection of data flow - Mobile/native clients using the same routes

Examples:

Basic usage with Rack

use Rhales::Middleware::JsonResponder,
  enabled: true,
  include_metadata: false

With Roda

use Rhales::Middleware::JsonResponder,
  enabled: ENV['RACK_ENV'] != 'production',
  include_metadata: ENV['RACK_ENV'] == 'development'

Response format (single window)

GET /dashboard
Accept: application/json

{
  "user": {"id": 1, "name": "Alice"},
  "authenticated": true
}

Response format (multiple windows)

{
  "appData": {"user": {...}},
  "config": {"theme": "dark"}
}

Response with metadata (development)

{
  "template": "dashboard",
  "data": {"user": {...}}
}

Instance Method Summary collapse

Constructor Details

#initialize(app, options = {}) ⇒ JsonResponder

Initialize the middleware

Parameters:

  • app (#call)

    The Rack application

  • options (Hash) (defaults to: {})

    Configuration options

Options Hash (options):

  • :enabled (Boolean)

    Whether JSON responses are enabled (default: true)

  • :include_metadata (Boolean)

    Whether to include metadata in responses (default: false)



56
57
58
59
60
# File 'lib/rhales/middleware/json_responder.rb', line 56

def initialize(app, options = {})
  @app = app
  @enabled = options.fetch(:enabled, true)
  @include_metadata = options.fetch(:include_metadata, false)
end

Instance Method Details

#accepts_json?(env) ⇒ Boolean (private)

Check if request accepts JSON

Parses Accept header and checks for application/json. Handles weighted preferences (e.g., “application/json;q=0.9”)

Parameters:

  • env (Hash)

    Rack environment

Returns:

  • (Boolean)

    true if application/json is accepted



109
110
111
112
113
114
115
116
117
118
# File 'lib/rhales/middleware/json_responder.rb', line 109

def accepts_json?(env)
  accept = env['HTTP_ACCEPT']
  return false unless accept

  # Check if application/json is requested
  # Handle weighted preferences (e.g., "application/json;q=0.9")
  accept.split(',').any? do |type|
    type.strip.start_with?('application/json')
  end
end

#call(env) ⇒ Array

Process the Rack request

Parameters:

  • env (Hash)

    The Rack environment

Returns:

  • (Array)

    Rack response tuple [status, headers, body]



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/rhales/middleware/json_responder.rb', line 66

def call(env)
  return @app.call(env) unless @enabled
  return @app.call(env) unless accepts_json?(env)

  # Get the response from the app
  status, headers, body = @app.call(env)

  # Only process successful HTML responses
  return [status, headers, body] unless status == 200
  return [status, headers, body] unless html_response?(headers)

  # Extract hydration data from HTML
  html_body = extract_body(body)
  hydration_data = extract_hydration_data(html_body)

  # Return empty object if no hydration data found
  if hydration_data.empty?
    return json_response({}, env)
  end

  # Build response data
  response_data = if @include_metadata
    {
      template: env['rhales.template_name'],
      data: hydration_data
    }
  else
    # Flatten if single window, or return all windows
    hydration_data.size == 1 ? hydration_data.values.first : hydration_data
  end

  json_response(response_data, env)
end

#extract_body(body) ⇒ String (private)

Extract response body as string

Handles different Rack body types (Array, IO, String)

Parameters:

  • body (Array, IO, String)

    Rack response body

Returns:

  • (String)

    Body content as string



136
137
138
139
140
141
142
143
144
# File 'lib/rhales/middleware/json_responder.rb', line 136

def extract_body(body)
  if body.respond_to?(:each)
    body.each.to_a.join
  elsif body.respond_to?(:read)
    body.read
  else
    body.to_s
  end
end

#extract_hydration_data(html) ⇒ Hash (private)

Extract hydration JSON blocks from HTML

Looks for

Parameters:

  • html (String)

    HTML response body

Returns:

  • (Hash)

    Hydration data keyed by window variable name



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/rhales/middleware/json_responder.rb', line 153

def extract_hydration_data(html)
  hydration_blocks = {}

  # Match script tags with data-window attribute
  html.scan(/<script[^>]*type=["']application\/json["'][^>]*data-window=["']([^"']+)["'][^>]*>(.*?)<\/script>/m) do |window_var, json_content|
    begin
      hydration_blocks[window_var] = JSONSerializer.parse(json_content.strip)
    rescue JSON::ParserError => e
      # Skip malformed JSON blocks
      warn "Rhales::JsonResponder: Failed to parse hydration JSON for window.#{window_var}: #{e.message}"
    end
  end

  hydration_blocks
end

#html_response?(headers) ⇒ Boolean (private)

Check if response is HTML

Parameters:

  • headers (Hash)

    Response headers

Returns:

  • (Boolean)

    true if Content-Type is text/html



124
125
126
127
128
# File 'lib/rhales/middleware/json_responder.rb', line 124

def html_response?(headers)
  # Support both uppercase and lowercase header names for compatibility
  content_type = headers['content-type'] || headers['Content-Type']
  content_type && content_type.include?('text/html')
end

#json_response(data, env) ⇒ Array (private)

Build JSON response

Parameters:

  • data (Hash, Array, Object)

    Response data

  • env (Hash)

    Rack environment

Returns:

  • (Array)

    Rack response tuple



174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/rhales/middleware/json_responder.rb', line 174

def json_response(data, env)
  json_body = JSONSerializer.dump(data)

  [
    200,
    {
      'content-type' => 'application/json',
      'content-length' => json_body.bytesize.to_s,
      'cache-control' => 'no-cache'
    },
    [json_body]
  ]
end