tina4ruby 3.13.37 → 3.13.39
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 +7 -7
- data/lib/tina4/api.rb +43 -1
- data/lib/tina4/auth.rb +118 -7
- data/lib/tina4/cli.rb +110 -2
- data/lib/tina4/database.rb +407 -52
- data/lib/tina4/dev_admin.rb +47 -14
- data/lib/tina4/drivers/sqlite_driver.rb +23 -0
- data/lib/tina4/env.rb +40 -4
- data/lib/tina4/events.rb +54 -8
- data/lib/tina4/field_types.rb +5 -2
- data/lib/tina4/graphql.rb +68 -12
- data/lib/tina4/html_element.rb +55 -7
- data/lib/tina4/log.rb +86 -10
- data/lib/tina4/mcp.rb +35 -8
- data/lib/tina4/messenger.rb +130 -25
- data/lib/tina4/metrics.rb +351 -73
- data/lib/tina4/middleware.rb +136 -13
- data/lib/tina4/migration.rb +113 -24
- data/lib/tina4/orm.rb +196 -32
- data/lib/tina4/query_builder.rb +22 -3
- data/lib/tina4/queue_backends/kafka_backend.rb +39 -2
- data/lib/tina4/rack_app.rb +22 -10
- data/lib/tina4/response.rb +31 -11
- data/lib/tina4/router.rb +34 -4
- data/lib/tina4/seeder.rb +433 -84
- data/lib/tina4/session.rb +94 -17
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +458 -21
- data/lib/tina4/wsdl.rb +25 -2
- data/lib/tina4.rb +91 -12
- metadata +6 -47
data/lib/tina4/orm.rb
CHANGED
|
@@ -13,6 +13,26 @@ module Tina4
|
|
|
13
13
|
name.to_s.gsub(/([A-Z])/) { "_#{$1.downcase}" }.sub(/^_/, "")
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Singularize a plural relationship name to derive a class name.
|
|
17
|
+
#
|
|
18
|
+
# has_many :posts → "Post", has_many :categories → "Category". The old
|
|
19
|
+
# derivation was a naive sub(/s$/) that turned "categories" into
|
|
20
|
+
# "Categorie" — a NameError waiting to happen. This handles the common
|
|
21
|
+
# English plural endings before falling back to stripping a trailing "s".
|
|
22
|
+
# (When class_name: is passed explicitly, this is never consulted.)
|
|
23
|
+
def self.singularize(word)
|
|
24
|
+
s = word.to_s
|
|
25
|
+
if s =~ /ies\z/i
|
|
26
|
+
s.sub(/ies\z/i, "y")
|
|
27
|
+
elsif s =~ /(ss|sh|ch|x|z)es\z/i
|
|
28
|
+
s.sub(/es\z/i, "")
|
|
29
|
+
elsif s =~ /s\z/i && s !~ /ss\z/i
|
|
30
|
+
s.sub(/s\z/i, "")
|
|
31
|
+
else
|
|
32
|
+
s
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
16
36
|
class ORM
|
|
17
37
|
include Tina4::FieldTypes
|
|
18
38
|
|
|
@@ -144,7 +164,12 @@ module Tina4
|
|
|
144
164
|
def has_many(name, class_name: nil, foreign_key: nil)
|
|
145
165
|
relationship_definitions[name] = {
|
|
146
166
|
type: :has_many,
|
|
147
|
-
|
|
167
|
+
# Derive the target class from the (plural) relationship name via a
|
|
168
|
+
# proper singularizer — "posts" → "Post", "categories" → "Category"
|
|
169
|
+
# — instead of the naive sub(/s$/) that produced "Categorie". The FK
|
|
170
|
+
# auto-wire path (foreign_key_field) always passes class_name:
|
|
171
|
+
# explicitly, so this default only applies to a hand-written has_many.
|
|
172
|
+
class_name: class_name || Tina4.singularize(name).split("_").map(&:capitalize).join,
|
|
148
173
|
foreign_key: foreign_key
|
|
149
174
|
}
|
|
150
175
|
|
|
@@ -326,9 +351,17 @@ module Tina4
|
|
|
326
351
|
result[:cnt].to_i
|
|
327
352
|
end
|
|
328
353
|
|
|
354
|
+
# Create a new instance, save it, and return it.
|
|
355
|
+
#
|
|
356
|
+
# Returns the saved instance on success. v3.13.39: if the underlying #save
|
|
357
|
+
# fails (validation errors or a driver error), create returns false — it
|
|
358
|
+
# does NOT hand back a possibly-unsaved instance, so a failed insert can
|
|
359
|
+
# never masquerade as a success. The failure cause is logged and available
|
|
360
|
+
# on the (discarded) instance's #get_error via the same path save uses.
|
|
361
|
+
# Parity with the Python master.
|
|
329
362
|
def create(attributes = {})
|
|
330
363
|
instance = new(attributes)
|
|
331
|
-
instance.save
|
|
364
|
+
return false if instance.save == false
|
|
332
365
|
instance
|
|
333
366
|
end
|
|
334
367
|
|
|
@@ -425,17 +458,20 @@ module Tina4
|
|
|
425
458
|
translator_engine = %w[postgres postgresql].include?(engine) ? "postgresql" : engine
|
|
426
459
|
sql = SQLTranslator.auto_increment_syntax(sql, translator_engine)
|
|
427
460
|
|
|
428
|
-
# Don't claim success when the DDL failed. db.execute()
|
|
429
|
-
#
|
|
430
|
-
# any DDL error)
|
|
431
|
-
#
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
461
|
+
# Don't claim success when the DDL failed. db.execute() now RAISES on a
|
|
462
|
+
# SQL error (it no longer swallows it into get_error() and returns
|
|
463
|
+
# false), so a bad type (or any DDL error) surfaces here as an
|
|
464
|
+
# exception. create_table keeps its documented bool contract: catch the
|
|
465
|
+
# raise, log the cause, and return false so callers that test the return
|
|
466
|
+
# still see a clean failure instead of a thrown error.
|
|
467
|
+
begin
|
|
468
|
+
db.execute(sql)
|
|
469
|
+
db.commit
|
|
470
|
+
true
|
|
471
|
+
rescue => e
|
|
472
|
+
Tina4::Log.error("create_table failed for #{table_name}: #{db.get_error || e.message}", { sql: sql })
|
|
473
|
+
false
|
|
437
474
|
end
|
|
438
|
-
true
|
|
439
475
|
end
|
|
440
476
|
|
|
441
477
|
def scope(name, filter_sql, params = [])
|
|
@@ -470,6 +506,17 @@ module Tina4
|
|
|
470
506
|
select_one(sql, [id])
|
|
471
507
|
end
|
|
472
508
|
|
|
509
|
+
# Return true if a record with the given primary key exists.
|
|
510
|
+
#
|
|
511
|
+
# Cross-framework parity with Python's MyModel.exists(pk_value),
|
|
512
|
+
# PHP's Model::exists($id), and Node's Model.exists(pk). Honours the
|
|
513
|
+
# soft-delete filter the same way find_by_id does (it routes through it).
|
|
514
|
+
# Used by #save to decide INSERT vs UPDATE for natural (non-auto-increment)
|
|
515
|
+
# primary keys — see the note on #save.
|
|
516
|
+
def exists(id)
|
|
517
|
+
!find_by_id(id).nil?
|
|
518
|
+
end
|
|
519
|
+
|
|
473
520
|
# Clear the relationship cache on all loaded instances (class-level helper).
|
|
474
521
|
# Useful after bulk operations when you want to force relationship re-loads.
|
|
475
522
|
def clear_rel_cache # -> nil
|
|
@@ -533,6 +580,11 @@ module Tina4
|
|
|
533
580
|
def initialize(attributes = {})
|
|
534
581
|
@persisted = false
|
|
535
582
|
@errors = []
|
|
583
|
+
# Cause of the most recent failed #save (validation message or DB error).
|
|
584
|
+
# nil when the most recent save succeeded. Mirrors db.get_error so a caller
|
|
585
|
+
# that checks `return false unless model.save` can still recover the real
|
|
586
|
+
# cause via #get_error / #last_error — the failure never vanishes silently.
|
|
587
|
+
@last_error = nil
|
|
536
588
|
@relationship_cache = {}
|
|
537
589
|
# Accept a JSON object string (parity with Python/PHP/Node):
|
|
538
590
|
# Widget.new('{"id":1,"name":"alpha"}')
|
|
@@ -563,42 +615,137 @@ module Tina4
|
|
|
563
615
|
end
|
|
564
616
|
end
|
|
565
617
|
|
|
618
|
+
# Insert or update. Returns self on success (fluent), false on failure.
|
|
619
|
+
#
|
|
620
|
+
# Fails loud, never silent (the same principle db.execute already follows
|
|
621
|
+
# by raising). On *any* failure path save returns false — keeping the
|
|
622
|
+
# contract callers rely on (`return false unless model.save`) — but it also
|
|
623
|
+
# (a) logs the real cause via Tina4::Log.error with model/table context and
|
|
624
|
+
# (b) records the cause on a retrievable per-model error (#last_error /
|
|
625
|
+
# #get_error, mirroring db.get_error) plus #errors, so a caller can recover
|
|
626
|
+
# it after the fact. It never raises and never changes the self/false
|
|
627
|
+
# return shape. On success it returns self (was `true` pre-v3.13.39 — Ruby
|
|
628
|
+
# was the sole framework returning a bare boolean here) and clears the
|
|
629
|
+
# error.
|
|
630
|
+
#
|
|
631
|
+
# Two distinct failure paths, both loud:
|
|
632
|
+
#
|
|
633
|
+
# * Validation (v3.13.39): #validate runs FIRST. If it returns errors,
|
|
634
|
+
# save records them on @errors + @last_error, logs them, and returns
|
|
635
|
+
# false WITHOUT touching the database — an invalid model never reaches
|
|
636
|
+
# the driver. (Ruby already enforced validate-on-save; this adds the
|
|
637
|
+
# loud log + recoverable last_error to the failure path.)
|
|
638
|
+
# * Database (v3.13.39): a driver error (NOT NULL, duplicate PK, missing
|
|
639
|
+
# table, …) is rolled back by db.transaction, then captured (db.get_error
|
|
640
|
+
# falling back to the exception text) onto @last_error, logged with
|
|
641
|
+
# model/table context, and returns false — the cause is no longer
|
|
642
|
+
# swallowed silently.
|
|
643
|
+
#
|
|
644
|
+
# INSERT vs UPDATE (bug B, parity with the Python master): for a NATURAL
|
|
645
|
+
# (non-auto-increment) primary key that is set, the decision is made on
|
|
646
|
+
# whether the ROW EXISTS (via self.class.exists), not on @persisted alone.
|
|
647
|
+
# Pre-v3.13.39 a re-save of a manually-PK'd record that had @persisted set
|
|
648
|
+
# would UPDATE — but a freshly built (not-yet-persisted) natural-key record
|
|
649
|
+
# whose row already existed could double-INSERT, or a `new`-then-`save` of a
|
|
650
|
+
# natural key would INSERT then a second save UPDATE a phantom. Probing
|
|
651
|
+
# existence makes the choice correct regardless of @persisted. Auto-increment
|
|
652
|
+
# PKs keep the legacy @persisted-based decision (a nil PK means "new row,
|
|
653
|
+
# let the engine assign an id").
|
|
566
654
|
def save
|
|
567
655
|
@errors = []
|
|
568
656
|
@relationship_cache = {} # Clear relationship cache on save
|
|
569
|
-
|
|
570
|
-
|
|
657
|
+
|
|
658
|
+
# ── validate() is ENFORCED. An invalid model never reaches the driver —
|
|
659
|
+
# fail loud (record + log), return false. ──
|
|
660
|
+
validation_errors = validate
|
|
661
|
+
unless validation_errors.empty?
|
|
662
|
+
@errors = validation_errors
|
|
663
|
+
@last_error = validation_errors.join("; ")
|
|
664
|
+
Tina4::Log.error(
|
|
665
|
+
"#{self.class.name}.save refused: validation failed — #{@last_error}"
|
|
666
|
+
)
|
|
667
|
+
return false
|
|
668
|
+
end
|
|
571
669
|
|
|
572
670
|
data = to_db_hash(exclude_nil: true)
|
|
573
671
|
pk = self.class.primary_key_field || :id
|
|
574
672
|
pk_value = __send__(pk)
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
673
|
+
pk_opts = self.class.field_definitions[pk] || {}
|
|
674
|
+
auto_increment = pk_opts[:auto_increment]
|
|
675
|
+
|
|
676
|
+
# Decide INSERT vs UPDATE.
|
|
677
|
+
is_update =
|
|
678
|
+
if pk_value.nil?
|
|
679
|
+
false
|
|
680
|
+
elsif auto_increment
|
|
681
|
+
# Auto-increment: legacy behaviour — a set PK on a persisted instance
|
|
682
|
+
# means UPDATE.
|
|
683
|
+
@persisted ? true : false
|
|
584
684
|
else
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
685
|
+
# Natural key: probe row existence so a re-save never double-inserts
|
|
686
|
+
# and a first save of a never-seen key still inserts. If the probe
|
|
687
|
+
# itself fails (e.g. table missing), fall back to INSERT so the caller
|
|
688
|
+
# sees the real driver error rather than a silent no-op UPDATE.
|
|
689
|
+
begin
|
|
690
|
+
self.class.exists(pk_value)
|
|
691
|
+
rescue StandardError
|
|
692
|
+
false
|
|
588
693
|
end
|
|
589
|
-
@persisted = true
|
|
590
694
|
end
|
|
695
|
+
|
|
696
|
+
begin
|
|
697
|
+
self.class.db.transaction do |db|
|
|
698
|
+
if is_update
|
|
699
|
+
filter = { pk => pk_value }
|
|
700
|
+
data.delete(pk)
|
|
701
|
+
# Remove mapped primary key too
|
|
702
|
+
mapped_pk = self.class.field_mapping[pk.to_s]
|
|
703
|
+
data.delete(mapped_pk.to_sym) if mapped_pk
|
|
704
|
+
db.update(self.class.table_name, data, filter)
|
|
705
|
+
else
|
|
706
|
+
result = db.insert(self.class.table_name, data)
|
|
707
|
+
# Only adopt the engine-assigned id for an auto-increment PK. A
|
|
708
|
+
# natural-key PK was set by the caller; don't overwrite it with the
|
|
709
|
+
# driver's last_insert_id (which may be a sequence value that
|
|
710
|
+
# doesn't apply here).
|
|
711
|
+
if auto_increment && result[:last_id] && respond_to?("#{pk}=")
|
|
712
|
+
__send__("#{pk}=", result[:last_id])
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
rescue => e
|
|
717
|
+
# ── Fail loud, never silent. db.transaction already rolled back and
|
|
718
|
+
# re-raised. Keep the false return contract, but capture the REAL cause
|
|
719
|
+
# (prefer db.get_error, which insert/update/execute populate, falling
|
|
720
|
+
# back to the exception text) on @last_error + @errors so it survives,
|
|
721
|
+
# and log it with model/table context. ──
|
|
722
|
+
cause = (self.class.db.get_error rescue nil) || e.message
|
|
723
|
+
@last_error = cause
|
|
724
|
+
@errors = [cause]
|
|
725
|
+
Tina4::Log.error(
|
|
726
|
+
"#{self.class.name}.save failed for table " \
|
|
727
|
+
"'#{self.class.table_name}': #{cause}"
|
|
728
|
+
)
|
|
729
|
+
return false
|
|
591
730
|
end
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
@
|
|
595
|
-
|
|
731
|
+
|
|
732
|
+
@persisted = true
|
|
733
|
+
@last_error = nil
|
|
734
|
+
self
|
|
596
735
|
end
|
|
597
736
|
|
|
737
|
+
# Delete this record (soft or hard).
|
|
738
|
+
#
|
|
739
|
+
# v3.13.39 (bug D): RAISES on a missing primary key, matching #force_delete
|
|
740
|
+
# (which already raised). Previously delete returned false on a nil PK while
|
|
741
|
+
# force_delete raised — an inconsistent contract where "couldn't delete" and
|
|
742
|
+
# "deleted nothing" were indistinguishable on one path but loud on the other.
|
|
743
|
+
# Both now fail loud: deleting a record with no PK is a programmer error, not
|
|
744
|
+
# a quiet no-op. Returns true on a successful delete.
|
|
598
745
|
def delete
|
|
599
746
|
pk = self.class.primary_key_field || :id
|
|
600
747
|
pk_value = __send__(pk)
|
|
601
|
-
|
|
748
|
+
raise "Cannot delete: no primary key value" unless pk_value
|
|
602
749
|
|
|
603
750
|
self.class.db.transaction do |db|
|
|
604
751
|
if self.class.soft_delete
|
|
@@ -700,6 +847,23 @@ module Tina4
|
|
|
700
847
|
@errors
|
|
701
848
|
end
|
|
702
849
|
|
|
850
|
+
# Cause of the most recent failed #save (validation message or DB error),
|
|
851
|
+
# or nil when the last save succeeded.
|
|
852
|
+
def last_error
|
|
853
|
+
@last_error
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# Return the cause of the most recent failed #save, or nil.
|
|
857
|
+
#
|
|
858
|
+
# Mirrors db.get_error. After save returns false — whether from validation
|
|
859
|
+
# or a driver error — the real cause is retrievable here (and on
|
|
860
|
+
# #last_error) so a caller using the `return false unless model.save`
|
|
861
|
+
# contract can still surface it. Cleared to nil on a successful save.
|
|
862
|
+
# Cross-framework parity with Python/PHP/Node get_error().
|
|
863
|
+
def get_error
|
|
864
|
+
@last_error
|
|
865
|
+
end
|
|
866
|
+
|
|
703
867
|
# Convert to hash using Ruby attribute names.
|
|
704
868
|
# Optionally include relationships via the include keyword.
|
|
705
869
|
# case: "camel" converts snake_case keys to camelCase (parity with
|
data/lib/tina4/query_builder.rb
CHANGED
|
@@ -155,6 +155,14 @@ module Tina4
|
|
|
155
155
|
|
|
156
156
|
# Execute the query and return the database result.
|
|
157
157
|
#
|
|
158
|
+
# v3.13.39: with no `.limit()` set, get returns ALL matching rows. It
|
|
159
|
+
# previously applied a silent default `LIMIT 100` — a data-loss-on-read
|
|
160
|
+
# footgun where the 101st row vanished without a trace. An explicit
|
|
161
|
+
# `.limit(n)` is still honoured; `to_sql` never injects a default LIMIT
|
|
162
|
+
# either. When no limit was requested we pass `limit: nil` to db.fetch —
|
|
163
|
+
# its "no truncation" sentinel (fetch only appends LIMIT when `limit` is
|
|
164
|
+
# truthy and the SQL doesn't already carry one).
|
|
165
|
+
#
|
|
158
166
|
# @return [Object] The result from db.fetch.
|
|
159
167
|
def get
|
|
160
168
|
ensure_db!
|
|
@@ -164,7 +172,7 @@ module Tina4
|
|
|
164
172
|
@db.fetch(
|
|
165
173
|
sql,
|
|
166
174
|
all_params.empty? ? [] : all_params,
|
|
167
|
-
limit: @limit_val
|
|
175
|
+
limit: @limit_val,
|
|
168
176
|
offset: @offset_val || 0
|
|
169
177
|
)
|
|
170
178
|
end
|
|
@@ -322,8 +330,19 @@ module Tina4
|
|
|
322
330
|
return [{ field => { mongo_op => val } }, param_index + 1]
|
|
323
331
|
end
|
|
324
332
|
|
|
325
|
-
#
|
|
326
|
-
|
|
333
|
+
# v3.13.39: no silent $where fallback. Previously an unparseable
|
|
334
|
+
# condition was wrapped as `{ "$where" => <raw condition string> }` — a
|
|
335
|
+
# raw-JS sink that is both injection-shaped (the WHERE string runs as
|
|
336
|
+
# JavaScript on the server) and silently different semantics from the
|
|
337
|
+
# SQL the caller wrote. Fail loud instead: name the clause so the caller
|
|
338
|
+
# fixes it rather than shipping a surprise $where.
|
|
339
|
+
raise ArgumentError,
|
|
340
|
+
"QueryBuilder#to_mongo: cannot translate WHERE clause to a " \
|
|
341
|
+
"MongoDB filter: #{cond.inspect}. Supported forms: " \
|
|
342
|
+
"'<field> <op> ?' (=, !=, <>, >, >=, <, <=), '<field> LIKE ?', " \
|
|
343
|
+
"'<field> [NOT] IN (?)', '<field> IS [NOT] NULL'. " \
|
|
344
|
+
"Rewrite the condition in one of those forms (to_mongo will not " \
|
|
345
|
+
"silently emit a raw $where JavaScript expression)."
|
|
327
346
|
end
|
|
328
347
|
|
|
329
348
|
# Merge multiple single-field mongo condition hashes into one.
|
|
@@ -8,9 +8,11 @@ module Tina4
|
|
|
8
8
|
@brokers = options[:brokers] || "localhost:9092"
|
|
9
9
|
@group_id = options[:group_id] || "tina4_consumer_group"
|
|
10
10
|
|
|
11
|
+
security = self.class._security_config
|
|
12
|
+
|
|
11
13
|
producer_config = {
|
|
12
14
|
"bootstrap.servers" => @brokers
|
|
13
|
-
}
|
|
15
|
+
}.merge(security)
|
|
14
16
|
@producer = Rdkafka::Config.new(producer_config).producer
|
|
15
17
|
|
|
16
18
|
consumer_config = {
|
|
@@ -18,13 +20,48 @@ module Tina4
|
|
|
18
20
|
"group.id" => @group_id,
|
|
19
21
|
"auto.offset.reset" => "earliest",
|
|
20
22
|
"enable.auto.commit" => "false"
|
|
21
|
-
}
|
|
23
|
+
}.merge(security)
|
|
22
24
|
@consumer = Rdkafka::Config.new(consumer_config).consumer
|
|
23
25
|
@subscribed_topics = []
|
|
24
26
|
rescue LoadError
|
|
25
27
|
raise "Kafka backend requires the 'rdkafka' gem. Install with: gem install rdkafka"
|
|
26
28
|
end
|
|
27
29
|
|
|
30
|
+
# Build SSL/SASL client config from env (for a TLS broker/proxy).
|
|
31
|
+
#
|
|
32
|
+
# Mirrors tina4_python KafkaConnector._security_config: each setting is
|
|
33
|
+
# read from the Tina4-namespaced env var first (TINA4_KAFKA_<NAME>) and
|
|
34
|
+
# falls back to the bare librdkafka-convention name (KAFKA_<NAME>) that
|
|
35
|
+
# many Kafka deployments already set. Honours security.protocol (e.g. SSL,
|
|
36
|
+
# SASL_SSL), ssl.ca.location, and optional SASL (mechanism / username /
|
|
37
|
+
# password). Unset keys are omitted, leaving librdkafka's PLAINTEXT default.
|
|
38
|
+
def self._security_config
|
|
39
|
+
# rdkafka key -> env suffix (read as TINA4_KAFKA_<suffix>, then KAFKA_<suffix>)
|
|
40
|
+
mapping = {
|
|
41
|
+
"security.protocol" => "SECURITY_PROTOCOL",
|
|
42
|
+
"ssl.ca.location" => "SSL_CA_LOCATION",
|
|
43
|
+
"sasl.mechanism" => "SASL_MECHANISM",
|
|
44
|
+
"sasl.username" => "SASL_USERNAME",
|
|
45
|
+
"sasl.password" => "SASL_PASSWORD"
|
|
46
|
+
}
|
|
47
|
+
config = {}
|
|
48
|
+
mapping.each do |rdk, suffix|
|
|
49
|
+
value = env_value("TINA4_KAFKA_#{suffix}") || env_value("KAFKA_#{suffix}")
|
|
50
|
+
config[rdk] = value if value
|
|
51
|
+
end
|
|
52
|
+
config
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Read an env var, treating empty/blank values as unset (parity with
|
|
56
|
+
# Python's `os.environ.get(...) or ...` truthiness).
|
|
57
|
+
def self.env_value(name)
|
|
58
|
+
value = ENV[name]
|
|
59
|
+
return nil if value.nil? || value.empty?
|
|
60
|
+
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
private_class_method :env_value
|
|
64
|
+
|
|
28
65
|
def enqueue(message)
|
|
29
66
|
@producer.produce(
|
|
30
67
|
topic: message.topic,
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -351,9 +351,12 @@ module Tina4
|
|
|
351
351
|
env["tina4.request"] = request # Store for session save after response
|
|
352
352
|
response = Tina4::Response.new
|
|
353
353
|
|
|
354
|
-
# Run global middleware (block-based + class-based before_* methods)
|
|
354
|
+
# Run global middleware (block-based + class-based before_* methods).
|
|
355
|
+
# M2 — AFTER-ON-4xx RULE: when a before_* short-circuits (4xx/skip) or
|
|
356
|
+
# throws (clean 500), the after-pass STILL runs so after_* can add
|
|
357
|
+
# headers/logging — consistent across all 4 frameworks.
|
|
355
358
|
unless Tina4::Middleware.run_before(Tina4::Middleware.global_middleware, request, response)
|
|
356
|
-
|
|
359
|
+
Tina4::Middleware.run_after(Tina4::Middleware.global_middleware, request, response)
|
|
357
360
|
return response.to_rack
|
|
358
361
|
end
|
|
359
362
|
|
|
@@ -885,18 +888,23 @@ module Tina4
|
|
|
885
888
|
# Wire the route handler into the WebSocket engine events
|
|
886
889
|
handler = ws_route.handler
|
|
887
890
|
|
|
888
|
-
# Create a dedicated WebSocket engine for this route so
|
|
891
|
+
# Create a dedicated WebSocket *event* engine for this route so each
|
|
892
|
+
# upgrade keeps its own isolated open/message/close handlers (the engine's
|
|
893
|
+
# on() appends handlers, so a single shared event engine would cross-wire
|
|
894
|
+
# routes).
|
|
889
895
|
ws = Tina4::WebSocket.new
|
|
890
896
|
|
|
891
|
-
# The
|
|
892
|
-
#
|
|
893
|
-
#
|
|
894
|
-
# Python's single
|
|
897
|
+
# The connection itself is OWNED by a process-wide shared manager so that
|
|
898
|
+
# broadcasts, rooms, the multi-instance backplane and the idle reaper span
|
|
899
|
+
# every route's connections — not just this one per-socket event engine.
|
|
900
|
+
# Mirrors Python's single WebSocketManager. The dev-reload channel uses its
|
|
901
|
+
# own shared manager (Tina4::DevReload) so POST /__dev/api/reload reaches
|
|
902
|
+
# every browser.
|
|
895
903
|
dev_reload = ws_route.path == "/__dev_reload"
|
|
904
|
+
manager = dev_reload ? Tina4::DevReload.manager : @websocket_engine
|
|
896
905
|
|
|
897
906
|
ws.on(:open) do |connection|
|
|
898
907
|
connection.params = ws_params
|
|
899
|
-
Tina4::DevReload.add(connection) if dev_reload
|
|
900
908
|
handler.call(connection, :open, nil)
|
|
901
909
|
end
|
|
902
910
|
|
|
@@ -905,7 +913,6 @@ module Tina4
|
|
|
905
913
|
end
|
|
906
914
|
|
|
907
915
|
ws.on(:close) do |connection|
|
|
908
|
-
Tina4::DevReload.remove(connection) if dev_reload
|
|
909
916
|
handler.call(connection, :close, nil)
|
|
910
917
|
end
|
|
911
918
|
|
|
@@ -913,7 +920,12 @@ module Tina4
|
|
|
913
920
|
Tina4::Log.error("WebSocket error on #{ws_route.path}: #{error.message}")
|
|
914
921
|
end
|
|
915
922
|
|
|
916
|
-
|
|
923
|
+
# Per-route auth on the upgrade: a secured WS route (auth_required) needs a
|
|
924
|
+
# valid JWT or the handshake is rejected (401, not accepted) by
|
|
925
|
+
# handle_upgrade — after the origin allow-list, before the handshake. The
|
|
926
|
+
# dev-reload channel is always public.
|
|
927
|
+
auth_required = !dev_reload && ws_route.respond_to?(:auth_required) && ws_route.auth_required
|
|
928
|
+
ws.handle_upgrade(env, socket, manager: manager, auth_required: auth_required)
|
|
917
929
|
|
|
918
930
|
# Return async response (-1 signals Rack the response is handled via hijack)
|
|
919
931
|
[-1, {}, []]
|
data/lib/tina4/response.rb
CHANGED
|
@@ -347,18 +347,38 @@ module Tina4
|
|
|
347
347
|
gen = @_stream_generator
|
|
348
348
|
blk = @_stream_block
|
|
349
349
|
body = Enumerator.new do |yielder|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
350
|
+
# SSE hardening: a streaming source that raises mid-stream (a
|
|
351
|
+
# generator/block error, or the client disconnecting and the server
|
|
352
|
+
# tearing the body down) must NEVER crash the worker. We catch the
|
|
353
|
+
# error, log it, and end the stream cleanly — the chunks emitted
|
|
354
|
+
# before the failure are still delivered.
|
|
355
|
+
#
|
|
356
|
+
# A client disconnect surfaces in a hijack/Puma streaming body as a
|
|
357
|
+
# write-side IOError/Errno on the socket; that is propagated up as a
|
|
358
|
+
# normal stop and re-raised so Rack/Puma can close the connection,
|
|
359
|
+
# while a *source* error is swallowed after logging.
|
|
360
|
+
begin
|
|
361
|
+
if gen
|
|
362
|
+
if gen.respond_to?(:each)
|
|
363
|
+
# Enumerator / array / any Enumerable of string chunks
|
|
364
|
+
gen.each { |chunk| yielder << chunk }
|
|
365
|
+
elsif gen.respond_to?(:call)
|
|
366
|
+
# Callable that receives the yielder, like the block form
|
|
367
|
+
gen.call(yielder)
|
|
368
|
+
else
|
|
369
|
+
yielder << gen.to_s
|
|
370
|
+
end
|
|
371
|
+
elsif blk
|
|
372
|
+
blk.call(yielder)
|
|
359
373
|
end
|
|
360
|
-
|
|
361
|
-
|
|
374
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET => e
|
|
375
|
+
# Client disconnected mid-stream — stop cleanly, do not crash, and
|
|
376
|
+
# do not log loudly (a normal browser closing an SSE stream).
|
|
377
|
+
Tina4::Log.debug("SSE/stream client disconnected: #{e.class}: #{e.message}") if defined?(Tina4::Log)
|
|
378
|
+
rescue StandardError => e
|
|
379
|
+
# The source (generator/block) itself raised mid-stream. Log it and
|
|
380
|
+
# end the stream cleanly rather than crashing the handler/worker.
|
|
381
|
+
Tina4::Log.error("SSE/stream source error: #{e.class}: #{e.message}") if defined?(Tina4::Log)
|
|
362
382
|
end
|
|
363
383
|
end
|
|
364
384
|
return [@status_code, final_headers, body]
|
data/lib/tina4/router.rb
CHANGED
|
@@ -214,15 +214,33 @@ module Tina4
|
|
|
214
214
|
# A registered WebSocket route with path pattern matching (reuses Route's compile logic)
|
|
215
215
|
class WebSocketRoute
|
|
216
216
|
attr_reader :path, :handler, :path_regex, :param_names
|
|
217
|
+
# PUBLIC by default (mirrors GET). Flip to true with #secure (or via
|
|
218
|
+
# Tina4.secure_websocket) to require a valid JWT on the upgrade. Mirrors the
|
|
219
|
+
# HTTP Route's auth_required so the upgrade path enforces it identically.
|
|
220
|
+
attr_accessor :auth_required
|
|
217
221
|
|
|
218
|
-
def initialize(path, handler)
|
|
222
|
+
def initialize(path, handler, auth_required: false)
|
|
219
223
|
@path = normalize_path(path).freeze
|
|
220
224
|
@handler = handler
|
|
225
|
+
@auth_required = auth_required
|
|
221
226
|
@param_names = []
|
|
222
227
|
@path_regex = compile_pattern(@path)
|
|
223
228
|
@param_names.freeze
|
|
224
229
|
end
|
|
225
230
|
|
|
231
|
+
# Mark this WebSocket route as requiring bearer-token auth on the upgrade.
|
|
232
|
+
# Returns self for chaining: Tina4::Router.websocket("/chat") { ... }.secure
|
|
233
|
+
def secure
|
|
234
|
+
@auth_required = true
|
|
235
|
+
self
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Opt back out (the default). Returns self for chaining.
|
|
239
|
+
def no_auth
|
|
240
|
+
@auth_required = false
|
|
241
|
+
self
|
|
242
|
+
end
|
|
243
|
+
|
|
226
244
|
# Returns params hash if matched, false otherwise
|
|
227
245
|
def match?(request_path)
|
|
228
246
|
match = @path_regex.match(request_path)
|
|
@@ -295,13 +313,25 @@ module Tina4
|
|
|
295
313
|
# connection — WebSocketConnection with #send, #broadcast, #close, #params
|
|
296
314
|
# event — :open, :message, or :close
|
|
297
315
|
# data — String payload for :message, nil for :open/:close
|
|
298
|
-
|
|
299
|
-
|
|
316
|
+
#
|
|
317
|
+
# PUBLIC by default (mirrors GET). Pass secure: true (the declarative way)
|
|
318
|
+
# OR chain .secure on the returned route (the imperative way) to require a
|
|
319
|
+
# valid JWT on the upgrade — both set the same auth_required flag, exactly
|
|
320
|
+
# like the HTTP routes support both a decorator/docblock and .secure.
|
|
321
|
+
def websocket(path, secure: false, &block)
|
|
322
|
+
ws_route = WebSocketRoute.new(path, block, auth_required: secure)
|
|
300
323
|
ws_routes << ws_route
|
|
301
|
-
Tina4::Log.debug("WebSocket route registered: #{path}")
|
|
324
|
+
Tina4::Log.debug("WebSocket route registered: #{path}#{secure ? ' (secured)' : ''}")
|
|
302
325
|
ws_route
|
|
303
326
|
end
|
|
304
327
|
|
|
328
|
+
# Register a SECURED WebSocket route (auth required on the upgrade). The
|
|
329
|
+
# declarative sibling of Tina4::Router.websocket(...).secure — mirrors the
|
|
330
|
+
# secure_get/secure_post pair for HTTP routes.
|
|
331
|
+
def secure_websocket(path, &block)
|
|
332
|
+
websocket(path, secure: true, &block)
|
|
333
|
+
end
|
|
334
|
+
|
|
305
335
|
# Find a matching WebSocket route for a given path.
|
|
306
336
|
# Returns [ws_route, params] or nil.
|
|
307
337
|
def find_ws_route(path)
|