Class: Tina4::Response

Inherits:
Object
  • Object
show all
Defined in:
lib/tina4/response.rb

Constant Summary collapse

MIME_TYPES =
{
  ".html" => "text/html", ".htm" => "text/html",
  ".css" => "text/css", ".js" => "application/javascript",
  ".json" => "application/json", ".xml" => "application/xml",
  ".txt" => "text/plain", ".csv" => "text/csv",
  ".png" => "image/png", ".jpg" => "image/jpeg",
  ".jpeg" => "image/jpeg", ".gif" => "image/gif",
  ".svg" => "image/svg+xml", ".ico" => "image/x-icon",
  ".webp" => "image/webp", ".pdf" => "application/pdf",
  ".zip" => "application/zip", ".woff" => "font/woff",
  ".woff2" => "font/woff2", ".ttf" => "font/ttf",
  ".eot" => "application/vnd.ms-fontobject",
  ".mp3" => "audio/mpeg", ".mp4" => "video/mp4",
  ".webm" => "video/webm"
}.freeze
JSON_CONTENT_TYPE =

Pre-frozen header values

"application/json; charset=utf-8"
HTML_CONTENT_TYPE =
"text/html; charset=utf-8"
TEXT_CONTENT_TYPE =
"text/plain; charset=utf-8"
XML_CONTENT_TYPE =
"application/xml; charset=utf-8"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeResponse

Returns a new instance of Response.



62
63
64
65
66
67
# File 'lib/tina4/response.rb', line 62

def initialize
  @status_code = 200
  @headers = { "content-type" => HTML_CONTENT_TYPE }
  @body = ""
  @cookies = nil  # Lazy -- most responses have no cookies
end

Instance Attribute Details

#bodyObject

Returns the value of attribute body.



60
61
62
# File 'lib/tina4/response.rb', line 60

def body
  @body
end

#cookiesObject

Returns the value of attribute cookies.



60
61
62
# File 'lib/tina4/response.rb', line 60

def cookies
  @cookies
end

#headersObject

Returns the value of attribute headers.



60
61
62
# File 'lib/tina4/response.rb', line 60

def headers
  @headers
end

#status_codeObject

Returns the value of attribute status_code.



60
61
62
# File 'lib/tina4/response.rb', line 60

def status_code
  @status_code
end

Class Method Details

.auto_detect(result, response) ⇒ Object



432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
# File 'lib/tina4/response.rb', line 432

def self.auto_detect(result, response)
  case result
  when Tina4::Response
    result
  when Hash, Array
    response.json(result)
  when String
    if result.start_with?("<")
      response.html(result)
    else
      response.text(result)
    end
  when Integer
    response.status_code = result
    response.body = ""
    response
  when NilClass
    response.status_code = 204
    response.body = ""
    response
  else
    response.json(result.respond_to?(:to_hash) ? result.to_hash : { data: result.to_s })
  end
end

.error_response(code, message, status = 400) ⇒ Object

Build a standard error envelope hash (class method).

Usage:

response.json(Tina4::Response.error_response("NOT_FOUND", "Resource not found", 404), status: 404)


278
279
280
# File 'lib/tina4/response.rb', line 278

def self.error_response(code, message, status = 400)
  { error: true, code: code, message: message, status: status }
end

Instance Method Details

#add_cors_headers(origin: "*", methods: "GET, POST, PUT, PATCH, DELETE, OPTIONS", headers_list: "Content-Type, Authorization, Accept", credentials: false) ⇒ Object



319
320
321
322
323
324
325
326
327
# File 'lib/tina4/response.rb', line 319

def add_cors_headers(origin: "*", methods: "GET, POST, PUT, PATCH, DELETE, OPTIONS",
                     headers_list: "Content-Type, Authorization, Accept", credentials: false)
  @headers["access-control-allow-origin"] = origin
  @headers["access-control-allow-methods"] = methods
  @headers["access-control-allow-headers"] = headers_list
  @headers["access-control-allow-credentials"] = "true" if credentials
  @headers["access-control-max-age"] = "86400"
  self
end

#add_header(key, value) ⇒ Object



314
315
316
317
# File 'lib/tina4/response.rb', line 314

def add_header(key, value)
  @headers[key] = value
  self
end

#call(data = nil, status_code = 200, content_type = nil) ⇒ Object

Callable response — auto-detects content type from data. Matches Python call / PHP __invoke / Node response() pattern.



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/tina4/response.rb', line 81

def call(data = nil, status_code = 200, content_type = nil)
  @status_code = status_code
  data = jsonable(data)
  if content_type
    @headers["content-type"] = content_type
    @body = data.to_s
  elsif data.is_a?(Hash) || data.is_a?(Array)
    @headers["content-type"] = JSON_CONTENT_TYPE
    @body = JSON.generate(data)
  else
    @headers["content-type"] = HTML_CONTENT_TYPE
    @body = data.to_s
  end
  self
end

Chainable cookie setter



293
294
295
# File 'lib/tina4/response.rb', line 293

def cookie(name, value, opts = {})
  set_cookie(name, value, opts)
end

#csv(content, filename: "export.csv", status: 200) ⇒ Object



144
145
146
147
148
149
150
# File 'lib/tina4/response.rb', line 144

def csv(content, filename: "export.csv", status: 200)
  @status_code = status
  @headers["content-type"] = "text/csv"
  @headers["content-disposition"] = "attachment; filename=\"#{filename}\""
  @body = content.to_s
  self
end


310
311
312
# File 'lib/tina4/response.rb', line 310

def delete_cookie(name, path: "/")
  set_cookie(name, "", max_age: 0, path: path)
end

#error(code, message, status_code = 400) ⇒ Object

Standard error response envelope.

Usage:

response.error("VALIDATION_FAILED", "Email is required", 400)


261
262
263
264
265
266
267
268
269
270
271
# File 'lib/tina4/response.rb', line 261

def error(code, message, status_code = 400)
  @status_code = status_code
  @headers["content-type"] = JSON_CONTENT_TYPE
  @body = JSON.generate({
    error: true,
    code: code,
    message: message,
    status: status_code
  })
  self
end

#file(path, content_type: nil, download: false, root: nil) ⇒ Object



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/tina4/response.rb', line 159

def file(path, content_type: nil, download: false, root: nil)
  # SECURITY: confine the read. The natural spelling of a download route,
  #
  #     response.file("downloads/" + name)   # name = "../secret.env"
  #
  # used to serve any file the process could read - measured at 200 with
  # the contents of a .env one directory above the intended one.
  #
  # TWO checks. Containment ALONE does not close it: that payload lands on
  # <project>/secret.env, which IS inside the project root, and the project
  # root is exactly where .env lives. Rejecting ".." on the way in is the
  # check that closes it; containment then catches absolute paths and
  # symlinks, neither of which carries a ".." segment.
  # Containment ONLY when a root is declared; defaulting to Dir.pwd broke
  # every legitimate absolute path.
  base = root ? ::File.expand_path(root) : nil
  forbidden = path.to_s.split(%r{[\\/]}).include?("..")

  unless forbidden
    candidate = (base.nil? || ::File.absolute_path?(path.to_s)) ? path.to_s : ::File.join(base, path.to_s)
    resolved =
      begin
        ::File.realpath(candidate)
      rescue Errno::ENOENT, Errno::ELOOP, Errno::ENAMETOOLONG, Errno::EACCES
        nil
      end
    if resolved && base && base != ::File::SEPARATOR &&
       resolved != base && !resolved.start_with?(base + ::File::SEPARATOR)
      forbidden = true
    end
    path = resolved || candidate
  end

  if forbidden
    # Refuse BEFORE reading: never load bytes we will not send.
    @status_code = 403
    @headers["content-type"] = "text/plain"
    @body = "Forbidden"
    return self
  end

  unless ::File.exist?(path)
    @status_code = 404
    @headers["content-type"] = "text/plain"
    @body = "File not found"
    return self
  end
  ext = ::File.extname(path).downcase
  @headers["content-type"] = content_type || MIME_TYPES[ext] || "application/octet-stream"
  if download
    @headers["content-disposition"] = "attachment; filename=\"#{::File.basename(path)}\""
  end
  @body = ::File.binread(path)
  self
end

#header(name, value = nil) ⇒ Object

Chainable header setter



283
284
285
286
287
288
289
290
# File 'lib/tina4/response.rb', line 283

def header(name, value = nil)
  if value.nil?
    @headers[name]
  else
    @headers[name] = value
    self
  end
end

#html(content, status_or_opts = nil, status: nil) ⇒ Object



123
124
125
126
127
128
# File 'lib/tina4/response.rb', line 123

def html(content, status_or_opts = nil, status: nil)
  @status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
  @headers["content-type"] = HTML_CONTENT_TYPE
  @body = content.to_s
  self
end

#json(data, status_or_opts = nil, status: nil) ⇒ Object



97
98
99
100
101
102
103
# File 'lib/tina4/response.rb', line 97

def json(data, status_or_opts = nil, status: nil)
  @status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
  @headers["content-type"] = JSON_CONTENT_TYPE
  data = jsonable(data)
  @body = data.is_a?(String) ? data : JSON.generate(data)
  self
end

#redirect(url, status_or_opts = nil, status: nil) ⇒ Object



152
153
154
155
156
157
# File 'lib/tina4/response.rb', line 152

def redirect(url, status_or_opts = nil, status: nil)
  @status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 302)
  @headers["location"] = url
  @body = ""
  self
end

#render(template_path, data = {}, status: 200, template_dir: nil) ⇒ Object

Render a Frond/Twig template file with data and return self. Tries the user template directory first, falling back to the framework's built-in templates. Sets the response body to the rendered HTML.



218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/tina4/response.rb', line 218

def render(template_path, data = {}, status: 200, template_dir: nil)
  @status_code = status
  @headers["content-type"] = HTML_CONTENT_TYPE

  engine = template_dir ? Tina4::Frond.new(template_dir: template_dir) : Tina4.get_frond

  # Try user templates first
  begin
    @body = engine.render(template_path, data)
    return self
  rescue Errno::ENOENT
    # Not found in user templates — try framework templates
  rescue => e
    @body = "<pre>Template error: #{e.message}</pre>"
    @status_code = 500
    return self
  end

  # Fallback: framework templates
  fw_engine = Tina4.get_framework_frond
  if fw_engine
    begin
      @body = fw_engine.render(template_path, data)
      return self
    rescue Errno::ENOENT
      # Not found in framework templates either
    rescue => e
      @body = "<pre>Template error: #{e.message}</pre>"
      @status_code = 500
      return self
    end
  end

  @body = "<pre>Template not found: #{template_path}</pre>"
  @status_code = 404
  self
end

#send(data = nil, status_code: nil, content_type: nil) ⇒ Object

Finalize and return the response — matches Python/Node API.



368
369
370
371
372
373
374
375
376
377
378
379
# File 'lib/tina4/response.rb', line 368

def send(data = nil, status_code: nil, content_type: nil)
  if data
    if data.is_a?(Hash) || data.is_a?(Array)
      return json(data, status_code || 200)
    end
    @headers["content-type"] = content_type if content_type
    @body = data.to_s
    @status_code = status_code if status_code
    return self
  end
  to_rack
end


297
298
299
300
301
302
303
304
305
306
307
308
# File 'lib/tina4/response.rb', line 297

def set_cookie(name, value, opts = {})
  cookie_str = "#{name}=#{URI.encode_www_form_component(value)}"
  cookie_str += "; Path=#{opts[:path] || '/'}"
  cookie_str += "; HttpOnly" if opts.fetch(:http_only, true)
  cookie_str += "; Secure" if opts[:secure]
  cookie_str += "; SameSite=#{opts[:same_site] || 'Lax'}"
  cookie_str += "; Max-Age=#{opts[:max_age]}" if opts[:max_age]
  cookie_str += "; Expires=#{opts[:expires].httpdate}" if opts[:expires]
  @cookies ||= []
  @cookies << cookie_str
  self
end

#status(code = nil) ⇒ Object

Chainable status setter



70
71
72
73
74
75
76
77
# File 'lib/tina4/response.rb', line 70

def status(code = nil)
  if code.nil?
    @status_code
  else
    @status_code = code
    self
  end
end

#stream(generator = nil, content_type: "text/event-stream") {|Enumerator::Yielder| ... } ⇒ self

Stream a response for Server-Sent Events (SSE) / chunked transfer.

Two equivalent call styles (cross-framework parity — Python/PHP/Node pass a generator positionally; Ruby additionally supports a block):

# 1. Positional generator (Enumerator, or anything responding to
#    #each or #call that yields string chunks):
gen = Enumerator.new do |y|
10.times { |i| y << "data: message #{i}\n\n" }
end
response.stream(gen)

# 2. Block form (unchanged):
Tina4::Router.get "/events" do |request, response|
response.stream do |out|
  10.times do |i|
    out << "data: message #{i}\n\n"
    sleep 1
  end
end
end

Parameters:

  • generator (#each, #call, nil) (defaults to: nil)

    Optional source of string chunks.

  • content_type (String) (defaults to: "text/event-stream")

    Content type (default: text/event-stream)

Yields:

  • (Enumerator::Yielder)

    Block receives a yielder to push chunks

Returns:

  • (self)


355
356
357
358
359
360
361
362
363
364
365
# File 'lib/tina4/response.rb', line 355

def stream(generator = nil, content_type: "text/event-stream", &block)
  @status_code = @status_code || 200
  @headers["content-type"] = content_type
  @headers["cache-control"] = "no-cache"
  @headers["connection"] = "keep-alive"
  @headers["x-accel-buffering"] = "no"
  @_streaming = true
  @_stream_generator = generator
  @_stream_block = block
  self
end

#text(content, status_or_opts = nil, status: nil) ⇒ Object



130
131
132
133
134
135
# File 'lib/tina4/response.rb', line 130

def text(content, status_or_opts = nil, status: nil)
  @status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
  @headers["content-type"] = TEXT_CONTENT_TYPE
  @body = content.to_s
  self
end

#to_rackObject



381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
# File 'lib/tina4/response.rb', line 381

def to_rack
  final_headers = @headers.dup
  final_headers["set-cookie"] = @cookies.join("\n") if @cookies && !@cookies.empty?

  if @_streaming
    # Streaming mode — return an Enumerator as the Rack body. A positional
    # generator wins over a block when both are somehow present.
    gen = @_stream_generator
    blk = @_stream_block
    body = Enumerator.new do |yielder|
      # SSE hardening: a streaming source that raises mid-stream (a
      # generator/block error, or the client disconnecting and the server
      # tearing the body down) must NEVER crash the worker. We catch the
      # error, log it, and end the stream cleanly — the chunks emitted
      # before the failure are still delivered.
      #
      # A client disconnect surfaces in a hijack/Puma streaming body as a
      # write-side IOError/Errno on the socket; that is propagated up as a
      # normal stop and re-raised so Rack/Puma can close the connection,
      # while a *source* error is swallowed after logging.
      begin
        if gen
          if gen.respond_to?(:each)
            # Enumerator / array / any Enumerable of string chunks
            gen.each { |chunk| yielder << chunk }
          elsif gen.respond_to?(:call)
            # Callable that receives the yielder, like the block form
            gen.call(yielder)
          else
            yielder << gen.to_s
          end
        elsif blk
          blk.call(yielder)
        end
      rescue IOError, Errno::EPIPE, Errno::ECONNRESET => e
        # Client disconnected mid-stream — stop cleanly, do not crash, and
        # do not log loudly (a normal browser closing an SSE stream).
        Tina4::Log.debug("SSE/stream client disconnected: #{e.class}: #{e.message}") if defined?(Tina4::Log)
      rescue StandardError => e
        # The source (generator/block) itself raised mid-stream. Log it and
        # end the stream cleanly rather than crashing the handler/worker.
        Tina4::Log.error("SSE/stream source error: #{e.class}: #{e.message}") if defined?(Tina4::Log)
      end
    end
    return [@status_code, final_headers, body]
  end

  # Normal buffered response
  [@status_code, final_headers, [@body.to_s]]
end

#xml(content, status: 200) ⇒ Object



137
138
139
140
141
142
# File 'lib/tina4/response.rb', line 137

def xml(content, status: 200)
  @status_code = status
  @headers["content-type"] = XML_CONTENT_TYPE
  @body = content.to_s
  self
end