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.
- checksums.yaml +4 -4
- data/README.md +105 -16
- data/lib/tina4/cli.rb +154 -27
- data/lib/tina4/database.rb +101 -0
- data/lib/tina4/dev_mailbox.rb +1 -1
- data/lib/tina4/frond.rb +166 -5
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +314 -16
- data/lib/tina4/messenger.rb +111 -33
- data/lib/tina4/orm.rb +134 -12
- data/lib/tina4/queue.rb +125 -5
- data/lib/tina4/rack_app.rb +18 -5
- data/lib/tina4/response_cache.rb +446 -29
- data/lib/tina4/templates/errors/404.twig +2 -2
- data/lib/tina4/templates/errors/500.twig +1 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +63 -2
- metadata +29 -1
data/lib/tina4/messenger.rb
CHANGED
|
@@ -1,38 +1,80 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require "net/
|
|
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
|
|
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:
|
|
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"]
|
|
19
|
-
@port = (port || ENV["SMTP_PORT"]
|
|
20
|
-
@username = username || ENV["SMTP_USERNAME"]
|
|
21
|
-
@password = password || ENV["SMTP_PASSWORD"]
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@
|
|
25
|
-
|
|
26
|
-
@
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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["
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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::
|
|
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 —
|
|
57
|
-
#
|
|
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::
|
|
215
|
+
@backend = backend || Tina4::Queue.resolve_backend
|
|
96
216
|
@max_retries = max_retries
|
|
97
217
|
@handlers = []
|
|
98
218
|
@running = false
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -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
|
-
|
|
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")}",
|