tina4ruby 3.0.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,38 +1,80 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "net/smtp"
4
- require "net/imap"
3
+ begin
4
+ require "net/smtp"
5
+ rescue LoadError
6
+ # net-smtp gem needed on Ruby >= 4.0: gem install net-smtp
7
+ end
8
+ begin
9
+ require "net/imap"
10
+ rescue LoadError
11
+ # net-imap gem needed on Ruby >= 4.0: gem install net-imap
12
+ end
5
13
  require "base64"
6
14
  require "securerandom"
7
15
  require "time"
8
16
 
9
17
  module Tina4
18
+ # Tina4 Messenger — Email sending (SMTP) and reading (IMAP).
19
+ #
20
+ # Unified .env-driven configuration with constructor override.
21
+ # Priority: constructor params > .env (TINA4_MAIL_* with SMTP_* fallback) > sensible defaults
22
+ #
23
+ # # .env
24
+ # TINA4_MAIL_HOST=smtp.gmail.com
25
+ # TINA4_MAIL_PORT=587
26
+ # TINA4_MAIL_USERNAME=user@gmail.com
27
+ # TINA4_MAIL_PASSWORD=app-password
28
+ # TINA4_MAIL_FROM=noreply@myapp.com
29
+ # TINA4_MAIL_ENCRYPTION=tls
30
+ # TINA4_MAIL_IMAP_HOST=imap.gmail.com
31
+ # TINA4_MAIL_IMAP_PORT=993
32
+ #
33
+ # mail = Messenger.new # reads from .env
34
+ # mail = Messenger.new(host: "smtp.office365.com", port: 587) # override
35
+ # mail.send(to: "user@test.com", subject: "Welcome", body: "<h1>Hello!</h1>", html: true, text: "Hello!")
36
+ #
10
37
  class Messenger
11
38
  attr_reader :host, :port, :username, :from_address, :from_name,
12
- :imap_host, :imap_port, :use_tls
39
+ :imap_host, :imap_port, :use_tls, :encryption
13
40
 
14
- # Initialize with SMTP config, falls back to ENV vars
41
+ # Initialize with SMTP config.
42
+ # Priority: constructor params > ENV (TINA4_MAIL_* with SMTP_* fallback) > sensible defaults
15
43
  def initialize(host: nil, port: nil, username: nil, password: nil,
16
- from_address: nil, from_name: nil, use_tls: true,
44
+ from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
17
45
  imap_host: nil, imap_port: nil)
18
- @host = host || ENV["SMTP_HOST"] || "localhost"
19
- @port = (port || ENV["SMTP_PORT"] || 587).to_i
20
- @username = username || ENV["SMTP_USERNAME"]
21
- @password = password || ENV["SMTP_PASSWORD"]
22
- @from_address = from_address || ENV["SMTP_FROM"] || @username
23
- @from_name = from_name || ENV["SMTP_FROM_NAME"] || ""
24
- @use_tls = use_tls
25
- @imap_host = imap_host || ENV["IMAP_HOST"] || @host
26
- @imap_port = (imap_port || ENV["IMAP_PORT"] || 993).to_i
46
+ @host = host || ENV["TINA4_MAIL_HOST"] || ENV["SMTP_HOST"] || "localhost"
47
+ @port = (port || ENV["TINA4_MAIL_PORT"] || ENV["SMTP_PORT"] || 587).to_i
48
+ @username = username || ENV["TINA4_MAIL_USERNAME"] || ENV["SMTP_USERNAME"]
49
+ @password = password || ENV["TINA4_MAIL_PASSWORD"] || ENV["SMTP_PASSWORD"]
50
+
51
+ resolved_from = from_address || ENV["TINA4_MAIL_FROM"] || ENV["SMTP_FROM"]
52
+ @from_address = resolved_from || @username || "noreply@localhost"
53
+
54
+ @from_name = from_name || ENV["TINA4_MAIL_FROM_NAME"] || ENV["SMTP_FROM_NAME"] || ""
55
+
56
+ # Encryption: constructor > .env > backward-compat use_tls > default "tls"
57
+ env_encryption = encryption || ENV["TINA4_MAIL_ENCRYPTION"]
58
+ if env_encryption
59
+ @encryption = env_encryption.downcase
60
+ elsif !use_tls.nil?
61
+ @encryption = use_tls ? "tls" : "none"
62
+ else
63
+ @encryption = "tls"
64
+ end
65
+ @use_tls = %w[tls starttls].include?(@encryption)
66
+
67
+ @imap_host = imap_host || ENV["TINA4_MAIL_IMAP_HOST"] || ENV["IMAP_HOST"] || @host
68
+ @imap_port = (imap_port || ENV["TINA4_MAIL_IMAP_PORT"] || ENV["IMAP_PORT"] || 993).to_i
27
69
  end
28
70
 
29
71
  # Send email using Ruby's Net::SMTP
30
72
  # Returns { success: true/false, message: "...", id: "..." }
31
- def send(to:, subject:, body:, html: false, cc: [], bcc: [],
73
+ def send(to:, subject:, body:, html: false, text: nil, cc: [], bcc: [],
32
74
  reply_to: nil, attachments: [], headers: {})
33
75
  message_id = "<#{SecureRandom.uuid}@#{@host}>"
34
76
  raw = build_message(
35
- to: to, subject: subject, body: body, html: html,
77
+ to: to, subject: subject, body: body, html: html, text: text,
36
78
  cc: cc, bcc: bcc, reply_to: reply_to,
37
79
  attachments: attachments, headers: headers,
38
80
  message_id: message_id
@@ -179,10 +221,12 @@ module Tina4
179
221
  end
180
222
  end
181
223
 
182
- def build_message(to:, subject:, body:, html:, cc:, bcc:, reply_to:,
224
+ def build_message(to:, subject:, body:, html:, text: nil, cc:, bcc:, reply_to:,
183
225
  attachments:, headers:, message_id:)
184
226
  boundary = "----=_Tina4_#{SecureRandom.hex(16)}"
227
+ alt_boundary = "----=_Tina4Alt_#{SecureRandom.hex(16)}"
185
228
  date = Time.now.strftime("%a, %d %b %Y %H:%M:%S %z")
229
+ has_text_alt = !text.nil? && html
186
230
 
187
231
  parts = []
188
232
  parts << "From: #{format_address(@from_address, @from_name)}"
@@ -196,28 +240,61 @@ module Tina4
196
240
 
197
241
  headers.each { |k, v| parts << "#{k}: #{v}" }
198
242
 
199
- if attachments.empty?
200
- content_type = html ? "text/html" : "text/plain"
201
- parts << "Content-Type: #{content_type}; charset=UTF-8"
202
- parts << "Content-Transfer-Encoding: base64"
203
- parts << ""
204
- parts << Base64.encode64(body)
205
- else
243
+ if !attachments.empty?
206
244
  parts << "Content-Type: multipart/mixed; boundary=\"#{boundary}\""
207
245
  parts << ""
208
- # Body part
209
- content_type = html ? "text/html" : "text/plain"
210
246
  parts << "--#{boundary}"
211
- parts << "Content-Type: #{content_type}; charset=UTF-8"
212
- parts << "Content-Transfer-Encoding: base64"
213
- parts << ""
214
- parts << Base64.encode64(body)
247
+
248
+ # Body part (with optional text alternative)
249
+ if has_text_alt
250
+ parts << "Content-Type: multipart/alternative; boundary=\"#{alt_boundary}\""
251
+ parts << ""
252
+ parts << "--#{alt_boundary}"
253
+ parts << "Content-Type: text/plain; charset=UTF-8"
254
+ parts << "Content-Transfer-Encoding: base64"
255
+ parts << ""
256
+ parts << Base64.encode64(text)
257
+ parts << "--#{alt_boundary}"
258
+ parts << "Content-Type: text/html; charset=UTF-8"
259
+ parts << "Content-Transfer-Encoding: base64"
260
+ parts << ""
261
+ parts << Base64.encode64(body)
262
+ parts << "--#{alt_boundary}--"
263
+ else
264
+ content_type = html ? "text/html" : "text/plain"
265
+ parts << "Content-Type: #{content_type}; charset=UTF-8"
266
+ parts << "Content-Transfer-Encoding: base64"
267
+ parts << ""
268
+ parts << Base64.encode64(body)
269
+ end
270
+
215
271
  # Attachment parts
216
272
  attachments.each do |attachment|
217
273
  parts << "--#{boundary}"
218
274
  parts.concat(build_attachment_part(attachment))
219
275
  end
220
276
  parts << "--#{boundary}--"
277
+ elsif has_text_alt
278
+ # Text alternative without attachments
279
+ parts << "Content-Type: multipart/alternative; boundary=\"#{alt_boundary}\""
280
+ parts << ""
281
+ parts << "--#{alt_boundary}"
282
+ parts << "Content-Type: text/plain; charset=UTF-8"
283
+ parts << "Content-Transfer-Encoding: base64"
284
+ parts << ""
285
+ parts << Base64.encode64(text)
286
+ parts << "--#{alt_boundary}"
287
+ parts << "Content-Type: text/html; charset=UTF-8"
288
+ parts << "Content-Transfer-Encoding: base64"
289
+ parts << ""
290
+ parts << Base64.encode64(body)
291
+ parts << "--#{alt_boundary}--"
292
+ else
293
+ content_type = html ? "text/html" : "text/plain"
294
+ parts << "Content-Type: #{content_type}; charset=UTF-8"
295
+ parts << "Content-Transfer-Encoding: base64"
296
+ parts << ""
297
+ parts << Base64.encode64(body)
221
298
  end
222
299
 
223
300
  parts.join("\r\n")
@@ -440,7 +517,8 @@ module Tina4
440
517
  def self.create_messenger(**options)
441
518
  dev_mode = Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
442
519
 
443
- smtp_configured = ENV["SMTP_HOST"] && !ENV["SMTP_HOST"].empty?
520
+ smtp_configured = (ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty?) ||
521
+ (ENV["SMTP_HOST"] && !ENV["SMTP_HOST"].empty?)
444
522
 
445
523
  if dev_mode && !smtp_configured
446
524
  mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
@@ -457,8 +535,8 @@ module Tina4
457
535
 
458
536
  def initialize(mailbox, **options)
459
537
  @mailbox = mailbox
460
- @from_address = options[:from_address] || ENV["SMTP_FROM"] || "dev@localhost"
461
- @from_name = options[:from_name] || ENV["SMTP_FROM_NAME"] || "Dev Mailer"
538
+ @from_address = options[:from_address] || ENV["TINA4_MAIL_FROM"] || ENV["SMTP_FROM"] || "dev@localhost"
539
+ @from_name = options[:from_name] || ENV["TINA4_MAIL_FROM_NAME"] || ENV["SMTP_FROM_NAME"] || "Dev Mailer"
462
540
  end
463
541
 
464
542
  def send(to:, subject:, body:, html: false, cc: [], bcc: [],
data/lib/tina4/orm.rb CHANGED
@@ -85,19 +85,108 @@ module Tina4
85
85
  end
86
86
  end
87
87
 
88
- def find(id_or_filter = nil, filter = nil)
88
+ def find(id_or_filter = nil, filter = nil, **kwargs)
89
+ include_list = kwargs.delete(:include)
90
+
89
91
  # find(id) — find by primary key
90
92
  # find(filter_hash) — find by criteria
91
- if id_or_filter.is_a?(Hash)
93
+ # find(name: "Alice") — keyword args as filter hash
94
+ result = if id_or_filter.is_a?(Hash)
92
95
  find_by_filter(id_or_filter)
93
96
  elsif filter.is_a?(Hash)
94
97
  find_by_filter(filter)
98
+ elsif !kwargs.empty?
99
+ find_by_filter(kwargs)
95
100
  else
96
101
  find_by_id(id_or_filter)
97
102
  end
103
+
104
+ if include_list && result
105
+ instances = result.is_a?(Array) ? result : [result]
106
+ eager_load(instances, include_list)
107
+ end
108
+ result
109
+ end
110
+
111
+ # Eager load relationships for a collection of instances (prevents N+1).
112
+ # include is an array of relationship names, supporting dot notation for nesting.
113
+ def eager_load(instances, include_list)
114
+ return if instances.nil? || instances.empty?
115
+
116
+ # Group includes: top-level and nested
117
+ top_level = {}
118
+ include_list.each do |inc|
119
+ parts = inc.to_s.split(".", 2)
120
+ rel_name = parts[0].to_sym
121
+ top_level[rel_name] ||= []
122
+ top_level[rel_name] << parts[1] if parts.length > 1
123
+ end
124
+
125
+ top_level.each do |rel_name, nested|
126
+ rel = relationship_definitions[rel_name]
127
+ next unless rel
128
+
129
+ klass = Object.const_get(rel[:class_name])
130
+ pk = primary_key_field || :id
131
+
132
+ case rel[:type]
133
+ when :has_one, :has_many
134
+ fk = rel[:foreign_key] || "#{name.split('::').last.downcase}_id"
135
+ pk_values = instances.map { |inst| inst.__send__(pk) }.compact.uniq
136
+ next if pk_values.empty?
137
+
138
+ placeholders = pk_values.map { "?" }.join(",")
139
+ sql = "SELECT * FROM #{klass.table_name} WHERE #{fk} IN (#{placeholders})"
140
+ results = klass.db.fetch(sql, pk_values)
141
+ related_records = results.map { |row| klass.from_hash(row) }
142
+
143
+ # Eager load nested
144
+ klass.eager_load(related_records, nested) unless nested.empty?
145
+
146
+ # Group by FK
147
+ grouped = {}
148
+ related_records.each do |record|
149
+ fk_val = record.__send__(fk.to_sym) if record.respond_to?(fk.to_sym)
150
+ (grouped[fk_val] ||= []) << record
151
+ end
152
+
153
+ instances.each do |inst|
154
+ pk_val = inst.__send__(pk)
155
+ records = grouped[pk_val] || []
156
+ if rel[:type] == :has_one
157
+ inst.instance_variable_get(:@relationship_cache)[rel_name] = records.first
158
+ else
159
+ inst.instance_variable_get(:@relationship_cache)[rel_name] = records
160
+ end
161
+ end
162
+
163
+ when :belongs_to
164
+ fk = rel[:foreign_key] || "#{rel_name}_id"
165
+ fk_values = instances.map { |inst|
166
+ inst.respond_to?(fk.to_sym) ? inst.__send__(fk.to_sym) : nil
167
+ }.compact.uniq
168
+ next if fk_values.empty?
169
+
170
+ related_pk = klass.primary_key_field || :id
171
+ placeholders = fk_values.map { "?" }.join(",")
172
+ sql = "SELECT * FROM #{klass.table_name} WHERE #{related_pk} IN (#{placeholders})"
173
+ results = klass.db.fetch(sql, fk_values)
174
+ related_records = results.map { |row| klass.from_hash(row) }
175
+
176
+ klass.eager_load(related_records, nested) unless nested.empty?
177
+
178
+ lookup = {}
179
+ related_records.each { |r| lookup[r.__send__(related_pk)] = r }
180
+
181
+ instances.each do |inst|
182
+ fk_val = inst.respond_to?(fk.to_sym) ? inst.__send__(fk.to_sym) : nil
183
+ inst.instance_variable_get(:@relationship_cache)[rel_name] = lookup[fk_val]
184
+ end
185
+ end
186
+ end
98
187
  end
99
188
 
100
- def where(conditions, params = [])
189
+ def where(conditions, params = [], include: nil)
101
190
  sql = "SELECT * FROM #{table_name}"
102
191
  if soft_delete
103
192
  sql += " WHERE (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0) AND (#{conditions})"
@@ -105,10 +194,12 @@ module Tina4
105
194
  sql += " WHERE #{conditions}"
106
195
  end
107
196
  results = db.fetch(sql, params)
108
- results.map { |row| from_hash(row) }
197
+ instances = results.map { |row| from_hash(row) }
198
+ eager_load(instances, include) if include
199
+ instances
109
200
  end
110
201
 
111
- def all(limit: nil, offset: nil, skip: nil, order_by: nil)
202
+ def all(limit: nil, offset: nil, skip: nil, order_by: nil, include: nil)
112
203
  sql = "SELECT * FROM #{table_name}"
113
204
  if soft_delete
114
205
  sql += " WHERE #{soft_delete_field} IS NULL OR #{soft_delete_field} = 0"
@@ -116,12 +207,16 @@ module Tina4
116
207
  sql += " ORDER BY #{order_by}" if order_by
117
208
  effective_offset = offset || skip
118
209
  results = db.fetch(sql, [], limit: limit, skip: effective_offset)
119
- results.map { |row| from_hash(row) }
210
+ instances = results.map { |row| from_hash(row) }
211
+ eager_load(instances, include) if include
212
+ instances
120
213
  end
121
214
 
122
- def select(sql, params = [], limit: nil, skip: nil)
215
+ def select(sql, params = [], limit: nil, skip: nil, include: nil)
123
216
  results = db.fetch(sql, params, limit: limit, skip: skip)
124
- results.map { |row| from_hash(row) }
217
+ instances = results.map { |row| from_hash(row) }
218
+ eager_load(instances, include) if include
219
+ instances
125
220
  end
126
221
 
127
222
  def count(conditions = nil, params = [])
@@ -255,6 +350,7 @@ module Tina4
255
350
 
256
351
  def save
257
352
  @errors = []
353
+ @relationship_cache = {} # Clear relationship cache on save
258
354
  validate_fields
259
355
  return false unless @errors.empty?
260
356
 
@@ -342,6 +438,7 @@ module Tina4
342
438
  pk = self.class.primary_key_field || :id
343
439
  id ||= __send__(pk)
344
440
  return false unless id
441
+ @relationship_cache = {} # Clear relationship cache on reload
345
442
 
346
443
  result = self.class.db.fetch_one(
347
444
  "SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id]
@@ -366,12 +463,37 @@ module Tina4
366
463
  @errors
367
464
  end
368
465
 
369
- # Convert to hash using Ruby attribute names
370
- def to_h
466
+ # Convert to hash using Ruby attribute names.
467
+ # Optionally include relationships via the include keyword.
468
+ def to_h(include: nil)
371
469
  hash = {}
372
470
  self.class.field_definitions.each_key do |name|
373
471
  hash[name] = __send__(name)
374
472
  end
473
+
474
+ if include
475
+ # Group includes: top-level and nested
476
+ top_level = {}
477
+ include.each do |inc|
478
+ parts = inc.to_s.split(".", 2)
479
+ rel_name = parts[0].to_sym
480
+ top_level[rel_name] ||= []
481
+ top_level[rel_name] << parts[1] if parts.length > 1
482
+ end
483
+
484
+ top_level.each do |rel_name, nested|
485
+ next unless self.class.relationship_definitions.key?(rel_name)
486
+ related = __send__(rel_name)
487
+ if related.nil?
488
+ hash[rel_name] = nil
489
+ elsif related.is_a?(Array)
490
+ hash[rel_name] = related.map { |r| r.to_h(include: nested.empty? ? nil : nested) }
491
+ else
492
+ hash[rel_name] = related.to_h(include: nested.empty? ? nil : nested)
493
+ end
494
+ end
495
+ end
496
+
375
497
  hash
376
498
  end
377
499
 
@@ -385,8 +507,8 @@ module Tina4
385
507
 
386
508
  alias to_list to_array
387
509
 
388
- def to_json(*_args)
389
- JSON.generate(to_h)
510
+ def to_json(include: nil, **_args)
511
+ JSON.generate(to_h(include: include))
390
512
  end
391
513
 
392
514
  def to_s
data/lib/tina4/queue.rb CHANGED
@@ -38,7 +38,7 @@ module Tina4
38
38
 
39
39
  class Producer
40
40
  def initialize(backend: nil)
41
- @backend = backend || Tina4::QueueBackends::LiteBackend.new
41
+ @backend = backend || Tina4::Queue.resolve_backend
42
42
  end
43
43
 
44
44
  def publish(topic, payload)
@@ -51,17 +51,44 @@ module Tina4
51
51
  def publish_batch(topic, payloads)
52
52
  payloads.map { |p| publish(topic, p) }
53
53
  end
54
+
55
+ # Unified push method matching the cross-framework API.
56
+ def push(topic, payload)
57
+ publish(topic, payload)
58
+ end
54
59
  end
55
60
 
56
- # Queue — convenience wrapper for queue management operations.
57
- # Provides dead letter inspection, purging, and retry capabilities.
61
+ # Queue — unified wrapper for queue management operations.
62
+ # Auto-detects backend from TINA4_QUEUE_BACKEND env var.
63
+ #
64
+ # Usage:
65
+ # # Auto-detect from env (default: lite/file backend)
66
+ # queue = Queue.new(topic: "tasks")
67
+ #
68
+ # # Explicit backend
69
+ # queue = Queue.new(topic: "tasks", backend: :rabbitmq)
70
+ #
71
+ # # Or pass a backend instance directly (legacy)
72
+ # queue = Queue.new(topic: "tasks", backend: my_backend)
58
73
  class Queue
59
74
  attr_reader :topic, :max_retries
60
75
 
61
76
  def initialize(topic:, backend: nil, max_retries: 3)
62
77
  @topic = topic
63
- @backend = backend || Tina4::QueueBackends::LiteBackend.new
64
78
  @max_retries = max_retries
79
+ @backend = resolve_backend_arg(backend)
80
+ end
81
+
82
+ # Push a job onto the queue. Returns the QueueMessage.
83
+ def push(payload)
84
+ message = QueueMessage.new(topic: @topic, payload: payload)
85
+ @backend.enqueue(message)
86
+ message
87
+ end
88
+
89
+ # Pop the next available job. Returns QueueMessage or nil.
90
+ def pop
91
+ @backend.dequeue(@topic)
65
92
  end
66
93
 
67
94
  # Get dead letter jobs — messages that exceeded max retries.
@@ -87,12 +114,105 @@ module Tina4
87
114
  def size
88
115
  @backend.size(@topic)
89
116
  end
117
+
118
+ # Get the underlying backend instance.
119
+ def backend
120
+ @backend
121
+ end
122
+
123
+ # Resolve the default backend from env vars.
124
+ # Class method so Producer can also use it.
125
+ def self.resolve_backend(name = nil)
126
+ chosen = name || ENV.fetch("TINA4_QUEUE_BACKEND", "lite").downcase.strip
127
+
128
+ case chosen.to_s
129
+ when "lite", "file", "default"
130
+ Tina4::QueueBackends::LiteBackend.new
131
+ when "rabbitmq"
132
+ config = resolve_rabbitmq_config
133
+ Tina4::QueueBackends::RabbitmqBackend.new(config)
134
+ when "kafka"
135
+ config = resolve_kafka_config
136
+ Tina4::QueueBackends::KafkaBackend.new(config)
137
+ else
138
+ raise ArgumentError, "Unknown queue backend: #{chosen.inspect}. Use 'lite', 'rabbitmq', or 'kafka'."
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def resolve_backend_arg(backend)
145
+ # If a backend instance is passed directly (legacy), use it
146
+ return backend if backend && !backend.is_a?(Symbol) && !backend.is_a?(String)
147
+ # If a symbol or string name is passed, resolve it
148
+ Queue.resolve_backend(backend)
149
+ end
150
+
151
+ def self.resolve_rabbitmq_config
152
+ config = {}
153
+ url = ENV["TINA4_QUEUE_URL"]
154
+ if url
155
+ config = parse_amqp_url(url)
156
+ end
157
+ config[:host] ||= ENV.fetch("TINA4_RABBITMQ_HOST", "localhost")
158
+ config[:port] ||= (ENV["TINA4_RABBITMQ_PORT"] || 5672).to_i
159
+ config[:username] ||= ENV.fetch("TINA4_RABBITMQ_USERNAME", "guest")
160
+ config[:password] ||= ENV.fetch("TINA4_RABBITMQ_PASSWORD", "guest")
161
+ config[:vhost] ||= ENV.fetch("TINA4_RABBITMQ_VHOST", "/")
162
+ config
163
+ end
164
+
165
+ def self.resolve_kafka_config
166
+ config = {}
167
+ url = ENV["TINA4_QUEUE_URL"]
168
+ if url
169
+ config[:brokers] = url.sub("kafka://", "")
170
+ end
171
+ brokers = ENV["TINA4_KAFKA_BROKERS"]
172
+ config[:brokers] = brokers if brokers
173
+ config[:brokers] ||= "localhost:9092"
174
+ config[:group_id] = ENV.fetch("TINA4_KAFKA_GROUP_ID", "tina4_consumer_group")
175
+ config
176
+ end
177
+
178
+ def self.parse_amqp_url(url)
179
+ config = {}
180
+ url = url.sub("amqp://", "").sub("amqps://", "")
181
+
182
+ if url.include?("@")
183
+ creds, rest = url.split("@", 2)
184
+ if creds.include?(":")
185
+ config[:username], config[:password] = creds.split(":", 2)
186
+ else
187
+ config[:username] = creds
188
+ end
189
+ else
190
+ rest = url
191
+ end
192
+
193
+ if rest.include?("/")
194
+ hostport, vhost = rest.split("/", 2)
195
+ config[:vhost] = vhost.start_with?("/") ? vhost : "/#{vhost}" if vhost && !vhost.empty?
196
+ else
197
+ hostport = rest
198
+ end
199
+
200
+ if hostport.include?(":")
201
+ host, port = hostport.split(":", 2)
202
+ config[:host] = host
203
+ config[:port] = port.to_i
204
+ elsif hostport && !hostport.empty?
205
+ config[:host] = hostport
206
+ end
207
+
208
+ config
209
+ end
90
210
  end
91
211
 
92
212
  class Consumer
93
213
  def initialize(topic:, backend: nil, max_retries: 3)
94
214
  @topic = topic
95
- @backend = backend || Tina4::QueueBackends::LiteBackend.new
215
+ @backend = backend || Tina4::Queue.resolve_backend
96
216
  @max_retries = max_retries
97
217
  @handlers = []
98
218
  @running = false
@@ -90,7 +90,7 @@ module Tina4
90
90
 
91
91
  rack_response
92
92
  rescue => e
93
- handle_500(e)
93
+ handle_500(e, env)
94
94
  end
95
95
 
96
96
  private
@@ -112,8 +112,21 @@ module Tina4
112
112
  end
113
113
  end
114
114
 
115
- # Execute handler
116
- result = route.handler.call(request, response)
115
+ # Execute handler — support (), (response), (request), or (request, response) signatures
116
+ # When 1 param: if named :request or :req, pass request; otherwise pass response
117
+ result = case route.handler.arity
118
+ when 0
119
+ route.handler.call
120
+ when 1
121
+ param_name = route.handler.parameters.first&.last
122
+ if param_name == :request || param_name == :req
123
+ route.handler.call(request)
124
+ else
125
+ route.handler.call(response)
126
+ end
127
+ else
128
+ route.handler.call(request, response)
129
+ end
117
130
 
118
131
  # Template rendering: when a template is set and the handler returned a Hash,
119
132
  # render the template with the hash as data and return the HTML response.
@@ -388,12 +401,12 @@ module Tina4
388
401
  [200, { "content-type" => "text/html; charset=utf-8" }, [html]]
389
402
  end
390
403
 
391
- def handle_500(error)
404
+ def handle_500(error, env = nil)
392
405
  Tina4::Log.error("500 Internal Server Error: #{error.message}")
393
406
  Tina4::Log.error(error.backtrace&.first(10)&.join("\n"))
394
407
  if dev_mode?
395
408
  # Rich error overlay with stack trace, source context, and line numbers
396
- body = Tina4::ErrorOverlay.render(error)
409
+ body = Tina4::ErrorOverlay.render(error, request: env)
397
410
  else
398
411
  body = Tina4::Template.render_error(500, {
399
412
  "error_message" => "#{error.message}\n#{error.backtrace&.first(10)&.join("\n")}",