tina4ruby 3.2.1 → 3.9.2

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.
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ # Request body validator with chainable rules.
5
+ #
6
+ # Usage:
7
+ # validator = Tina4::Validator.new(request.body)
8
+ # validator.required("name", "email")
9
+ # .email("email")
10
+ # .min_length("name", 2)
11
+ # .max_length("name", 100)
12
+ # .integer("age")
13
+ # .min("age", 0)
14
+ # .max("age", 150)
15
+ # .in_list("role", ["admin", "user", "guest"])
16
+ # .regex("phone", /^\+?[\d\s\-]+$/)
17
+ #
18
+ # unless validator.is_valid?
19
+ # return response.error("VALIDATION_FAILED", validator.errors.first[:message], 400)
20
+ # end
21
+ #
22
+ class Validator
23
+ attr_reader :validation_errors
24
+
25
+ EMAIL_REGEX = /\A[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\z/
26
+
27
+ def initialize(data = {})
28
+ @data = data.is_a?(Hash) ? data : {}
29
+ # Normalise keys to strings for consistent lookup
30
+ @data = @data.transform_keys(&:to_s) unless @data.empty?
31
+ @validation_errors = []
32
+ end
33
+
34
+ # Check that one or more fields are present and non-empty.
35
+ def required(*fields)
36
+ fields.each do |field|
37
+ key = field.to_s
38
+ value = @data[key]
39
+ if value.nil? || (value.is_a?(String) && value.strip.empty?)
40
+ @validation_errors << { field: key, message: "#{key} is required" }
41
+ end
42
+ end
43
+ self
44
+ end
45
+
46
+ # Check that a field contains a valid email address.
47
+ def email(field)
48
+ key = field.to_s
49
+ value = @data[key]
50
+ return self if value.nil?
51
+
52
+ unless value.is_a?(String) && value.match?(EMAIL_REGEX)
53
+ @validation_errors << { field: key, message: "#{key} must be a valid email address" }
54
+ end
55
+ self
56
+ end
57
+
58
+ # Check that a string field has at least +length+ characters.
59
+ def min_length(field, length)
60
+ key = field.to_s
61
+ value = @data[key]
62
+ return self if value.nil?
63
+
64
+ unless value.is_a?(String) && value.length >= length
65
+ @validation_errors << { field: key, message: "#{key} must be at least #{length} characters" }
66
+ end
67
+ self
68
+ end
69
+
70
+ # Check that a string field has at most +length+ characters.
71
+ def max_length(field, length)
72
+ key = field.to_s
73
+ value = @data[key]
74
+ return self if value.nil?
75
+
76
+ unless value.is_a?(String) && value.length <= length
77
+ @validation_errors << { field: key, message: "#{key} must be at most #{length} characters" }
78
+ end
79
+ self
80
+ end
81
+
82
+ # Check that a field is an integer (or can be parsed as one).
83
+ def integer(field)
84
+ key = field.to_s
85
+ value = @data[key]
86
+ return self if value.nil?
87
+
88
+ if value.is_a?(Integer)
89
+ return self
90
+ end
91
+
92
+ begin
93
+ Integer(value)
94
+ rescue ArgumentError, TypeError
95
+ @validation_errors << { field: key, message: "#{key} must be an integer" }
96
+ end
97
+ self
98
+ end
99
+
100
+ # Check that a numeric field is >= +minimum+.
101
+ def min(field, minimum)
102
+ key = field.to_s
103
+ value = @data[key]
104
+ return self if value.nil?
105
+
106
+ begin
107
+ num = Float(value)
108
+ rescue ArgumentError, TypeError
109
+ return self
110
+ end
111
+
112
+ if num < minimum
113
+ @validation_errors << { field: key, message: "#{key} must be at least #{minimum}" }
114
+ end
115
+ self
116
+ end
117
+
118
+ # Check that a numeric field is <= +maximum+.
119
+ def max(field, maximum)
120
+ key = field.to_s
121
+ value = @data[key]
122
+ return self if value.nil?
123
+
124
+ begin
125
+ num = Float(value)
126
+ rescue ArgumentError, TypeError
127
+ return self
128
+ end
129
+
130
+ if num > maximum
131
+ @validation_errors << { field: key, message: "#{key} must be at most #{maximum}" }
132
+ end
133
+ self
134
+ end
135
+
136
+ # Check that a field's value is one of the allowed values.
137
+ def in_list(field, allowed)
138
+ key = field.to_s
139
+ value = @data[key]
140
+ return self if value.nil?
141
+
142
+ unless allowed.include?(value)
143
+ @validation_errors << { field: key, message: "#{key} must be one of #{allowed}" }
144
+ end
145
+ self
146
+ end
147
+
148
+ # Check that a field matches a regular expression.
149
+ def regex(field, pattern)
150
+ key = field.to_s
151
+ value = @data[key]
152
+ return self if value.nil?
153
+
154
+ regexp = pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern)
155
+ unless value.is_a?(String) && value.match?(regexp)
156
+ @validation_errors << { field: key, message: "#{key} does not match the required format" }
157
+ end
158
+ self
159
+ end
160
+
161
+ # Return the list of validation errors (empty if valid).
162
+ def errors
163
+ @validation_errors.dup
164
+ end
165
+
166
+ # Return true if no validation errors have been recorded.
167
+ def is_valid?
168
+ @validation_errors.empty?
169
+ end
170
+
171
+ # Alias for is_valid? (Ruby convention)
172
+ alias_method :valid?, :is_valid?
173
+ end
174
+ end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.2.1"
4
+ VERSION = "3.9.2"
5
5
  end
@@ -44,7 +44,8 @@ module Tina4
44
44
  socket.write(response)
45
45
 
46
46
  conn_id = SecureRandom.hex(16)
47
- connection = WebSocketConnection.new(conn_id, socket)
47
+ ws_path = env["REQUEST_PATH"] || env["PATH_INFO"] || "/"
48
+ connection = WebSocketConnection.new(conn_id, socket, ws_server: self, path: ws_path)
48
49
  @connections[conn_id] = connection
49
50
 
50
51
  emit(:open, connection)
@@ -74,9 +75,10 @@ module Tina4
74
75
  end
75
76
  end
76
77
 
77
- def broadcast(message, exclude: nil)
78
+ def broadcast(message, exclude: nil, path: nil)
78
79
  @connections.each do |id, conn|
79
80
  next if exclude && id == exclude
81
+ next if path && conn.path != path
80
82
  conn.send_text(message)
81
83
  end
82
84
  end
@@ -90,13 +92,28 @@ module Tina4
90
92
 
91
93
  class WebSocketConnection
92
94
  attr_reader :id
95
+ attr_accessor :params, :path
93
96
 
94
- def initialize(id, socket)
97
+ def initialize(id, socket, ws_server: nil, path: "/")
95
98
  @id = id
96
99
  @socket = socket
100
+ @params = {}
101
+ @ws_server = ws_server
102
+ @path = path
97
103
  end
98
104
 
99
- def send_text(message)
105
+ # Broadcast a message to all other connections on the same path
106
+ def broadcast(message, include_self: false)
107
+ return unless @ws_server
108
+
109
+ @ws_server.connections.each do |cid, conn|
110
+ next if !include_self && cid == @id
111
+ next if conn.path != @path
112
+ conn.send_text(message)
113
+ end
114
+ end
115
+
116
+ def send(message)
100
117
  data = message.encode("UTF-8")
101
118
  frame = build_frame(0x1, data)
102
119
  @socket.write(frame)
@@ -104,6 +121,8 @@ module Tina4
104
121
  # Connection closed
105
122
  end
106
123
 
124
+ alias_method :send_text, :send
125
+
107
126
  def send_pong(data)
108
127
  frame = build_frame(0xA, data || "")
109
128
  @socket.write(frame)
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ # WebSocket Backplane Abstraction for Tina4 Ruby.
4
+ #
5
+ # Enables broadcasting WebSocket messages across multiple server instances
6
+ # using a shared pub/sub channel (e.g. Redis). Without a backplane configured,
7
+ # broadcast() only reaches connections on the local process.
8
+ #
9
+ # Configuration via environment variables:
10
+ # TINA4_WS_BACKPLANE — Backend type: "redis", "nats", or "" (default: none)
11
+ # TINA4_WS_BACKPLANE_URL — Connection string (default: redis://localhost:6379)
12
+ #
13
+ # Usage:
14
+ # backplane = Tina4::WebSocketBackplane.create
15
+ # if backplane
16
+ # backplane.subscribe("chat") { |msg| relay_to_local(msg) }
17
+ # backplane.publish("chat", '{"user":"A","text":"hello"}')
18
+ # end
19
+
20
+ module Tina4
21
+ # Base backplane interface for scaling WebSocket broadcast across instances.
22
+ #
23
+ # Subclasses implement publish/subscribe over a shared message bus so that
24
+ # every server instance receives every broadcast, not just the originator.
25
+ class WebSocketBackplane
26
+ # Publish a message to all instances listening on +channel+.
27
+ def publish(channel, message)
28
+ raise NotImplementedError, "#{self.class}#publish not implemented"
29
+ end
30
+
31
+ # Subscribe to +channel+. The block is called with each incoming message.
32
+ # Runs in a background thread.
33
+ def subscribe(channel, &block)
34
+ raise NotImplementedError, "#{self.class}#subscribe not implemented"
35
+ end
36
+
37
+ # Stop listening on +channel+.
38
+ def unsubscribe(channel)
39
+ raise NotImplementedError, "#{self.class}#unsubscribe not implemented"
40
+ end
41
+
42
+ # Tear down connections and background threads.
43
+ def close
44
+ raise NotImplementedError, "#{self.class}#close not implemented"
45
+ end
46
+
47
+ # Factory that reads TINA4_WS_BACKPLANE and returns the appropriate
48
+ # backplane instance, or +nil+ if no backplane is configured.
49
+ #
50
+ # This keeps backplane usage entirely optional — callers simply check
51
+ # +if backplane+ before publishing.
52
+ def self.create(url: nil)
53
+ backend = ENV.fetch("TINA4_WS_BACKPLANE", "").strip.downcase
54
+
55
+ case backend
56
+ when "redis"
57
+ RedisBackplane.new(url: url)
58
+ when "nats"
59
+ raise NotImplementedError, "NATS backplane is on the roadmap but not yet implemented."
60
+ when ""
61
+ nil
62
+ else
63
+ raise ArgumentError, "Unknown TINA4_WS_BACKPLANE value: '#{backend}'"
64
+ end
65
+ end
66
+ end
67
+
68
+ # Redis pub/sub backplane.
69
+ #
70
+ # Requires the +redis+ gem (+gem install redis+). The require is deferred
71
+ # so the rest of Tina4 works fine without it installed — an error is raised
72
+ # only when this class is actually instantiated.
73
+ class RedisBackplane < WebSocketBackplane
74
+ def initialize(url: nil)
75
+ begin
76
+ require "redis"
77
+ rescue LoadError
78
+ raise LoadError,
79
+ "The 'redis' gem is required for RedisBackplane. " \
80
+ "Install it with: gem install redis"
81
+ end
82
+
83
+ @url = url || ENV.fetch("TINA4_WS_BACKPLANE_URL", "redis://localhost:6379")
84
+ @redis = Redis.new(url: @url)
85
+ @subscriber = Redis.new(url: @url)
86
+ @threads = {}
87
+ @running = true
88
+ end
89
+
90
+ def publish(channel, message)
91
+ @redis.publish(channel, message)
92
+ end
93
+
94
+ def subscribe(channel, &block)
95
+ @threads[channel] = Thread.new do
96
+ @subscriber.subscribe(channel) do |on|
97
+ on.message do |_chan, msg|
98
+ block.call(msg) if @running
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ def unsubscribe(channel)
105
+ @subscriber.unsubscribe(channel)
106
+ thread = @threads.delete(channel)
107
+ thread&.join(1)
108
+ end
109
+
110
+ def close
111
+ @running = false
112
+ @threads.each_value { |t| t.kill }
113
+ @threads.clear
114
+ @subscriber.close
115
+ @redis.close
116
+ end
117
+ end
118
+ end
data/lib/tina4.rb CHANGED
@@ -22,6 +22,7 @@ require_relative "tina4/database"
22
22
  require_relative "tina4/database_result"
23
23
  require_relative "tina4/field_types"
24
24
  require_relative "tina4/orm"
25
+ require_relative "tina4/query_builder"
25
26
  require_relative "tina4/migration"
26
27
  require_relative "tina4/auto_crud"
27
28
  require_relative "tina4/database/sqlite3_adapter"
@@ -63,7 +64,8 @@ module Tina4
63
64
  autoload :FileHandler, File.expand_path("tina4/session_handlers/file_handler", __dir__)
64
65
  autoload :RedisHandler, File.expand_path("tina4/session_handlers/redis_handler", __dir__)
65
66
  autoload :MongoHandler, File.expand_path("tina4/session_handlers/mongo_handler", __dir__)
66
- autoload :ValkeyHandler, File.expand_path("tina4/session_handlers/valkey_handler", __dir__)
67
+ autoload :ValkeyHandler, File.expand_path("tina4/session_handlers/valkey_handler", __dir__)
68
+ autoload :DatabaseHandler, File.expand_path("tina4/session_handlers/database_handler", __dir__)
67
69
  end
68
70
 
69
71
  # ── Lazy-loaded: queue backends ───────────────────────────────────────
@@ -71,6 +73,7 @@ module Tina4
71
73
  autoload :LiteBackend, File.expand_path("tina4/queue_backends/lite_backend", __dir__)
72
74
  autoload :RabbitmqBackend, File.expand_path("tina4/queue_backends/rabbitmq_backend", __dir__)
73
75
  autoload :KafkaBackend, File.expand_path("tina4/queue_backends/kafka_backend", __dir__)
76
+ autoload :MongoBackend, File.expand_path("tina4/queue_backends/mongo_backend", __dir__)
74
77
  end
75
78
 
76
79
  # ── Lazy-loaded: web server ───────────────────────────────────────────
@@ -174,12 +177,61 @@ module Tina4
174
177
  # Tina4.initialize!(__dir__)
175
178
  # Tina4.run!
176
179
  # Or combined: Tina4.run!(__dir__)
177
- def run!(root_dir = Dir.pwd)
180
+ def find_available_port(start, max_tries = 10)
181
+ require "socket"
182
+ max_tries.times do |offset|
183
+ port = start + offset
184
+ begin
185
+ server = TCPServer.new("127.0.0.1", port)
186
+ server.close
187
+ return port
188
+ rescue Errno::EADDRINUSE, Errno::EACCES
189
+ next
190
+ end
191
+ end
192
+ start
193
+ end
194
+
195
+ def open_browser(url)
196
+ require "rbconfig"
197
+ Thread.new do
198
+ sleep 2
199
+ case RbConfig::CONFIG["host_os"]
200
+ when /darwin/i then system("open", url)
201
+ when /mswin|mingw/i then system("start", url)
202
+ else system("xdg-open", url)
203
+ end
204
+ end
205
+ end
206
+
207
+ def run!(root_dir = nil, port: nil, host: nil, debug: nil)
208
+ # Handle legacy call: run!(port: 7147) where root_dir receives the hash
209
+ if root_dir.is_a?(Hash)
210
+ port ||= root_dir[:port]
211
+ host ||= root_dir[:host]
212
+ debug = root_dir[:debug] if debug.nil? && root_dir.key?(:debug)
213
+ root_dir = nil
214
+ end
215
+ root_dir ||= Dir.pwd
216
+
217
+ ENV["PORT"] = port.to_s if port
218
+ ENV["HOST"] = host.to_s if host
219
+ ENV["TINA4_DEBUG"] = debug.to_s unless debug.nil?
220
+
178
221
  initialize!(root_dir) unless @root_dir
179
222
 
180
223
  host = ENV.fetch("HOST", ENV.fetch("TINA4_HOST", "0.0.0.0"))
181
224
  port = ENV.fetch("PORT", ENV.fetch("TINA4_PORT", "7147")).to_i
182
225
 
226
+ actual_port = find_available_port(port)
227
+ if actual_port != port
228
+ Tina4::Log.info("Port #{port} in use, using #{actual_port}")
229
+ port = actual_port
230
+ end
231
+
232
+ display_host = (host == "0.0.0.0" || host == "::") ? "localhost" : host
233
+ url = "http://#{display_host}:#{port}"
234
+
183
235
  app = Tina4::RackApp.new(root_dir: root_dir)
184
236
  is_debug = Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
185
237
 
@@ -203,6 +255,7 @@ module Tina4
203
255
  Tina4::Log.info("Production server: puma")
204
256
  Tina4::Shutdown.setup
205
257
 
258
+ open_browser(url)
206
259
  launcher = Puma::Launcher.new(config)
207
260
  launcher.run
208
261
  return
@@ -212,6 +265,7 @@ module Tina4
212
265
  end
213
266
 
214
267
  Tina4::Log.info("Development server: WEBrick")
268
+ open_browser(url)
215
269
  server = Tina4::WebServer.new(app, host: host, port: port)
216
270
  server.start
217
271
  end
@@ -286,6 +340,11 @@ module Tina4
286
340
  Tina4::Router.group(prefix, auth_handler: auth, &block)
287
341
  end
288
342
 
343
+ # WebSocket route registration
344
+ def websocket(path, &block)
345
+ Tina4::Router.websocket(path, &block)
346
+ end
347
+
289
348
  # Middleware hooks
290
349
  def before(pattern = nil, &block)
291
350
  Tina4::Middleware.before(pattern, &block)
@@ -355,8 +414,9 @@ module Tina4
355
414
  end
356
415
 
357
416
  def auto_discover(root_dir)
358
- route_dirs = %w[routes src/routes src/api api]
359
- route_dirs.each do |dir|
417
+ # src/ prefixed directories take priority over root-level ones
418
+ discover_dirs = %w[src/routes routes src/api api src/orm orm]
419
+ discover_dirs.each do |dir|
360
420
  full_dir = File.join(root_dir, dir)
361
421
  next unless Dir.exist?(full_dir)
362
422
 
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.1
4
+ version: 3.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-03-27 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rack
@@ -319,11 +320,14 @@ files:
319
320
  - lib/tina4/public/js/frond.min.js
320
321
  - lib/tina4/public/js/tina4-dev-admin.min.js
321
322
  - lib/tina4/public/js/tina4.min.js
323
+ - lib/tina4/public/js/tina4js.min.js
322
324
  - lib/tina4/public/swagger/index.html
323
325
  - lib/tina4/public/swagger/oauth2-redirect.html
326
+ - lib/tina4/query_builder.rb
324
327
  - lib/tina4/queue.rb
325
328
  - lib/tina4/queue_backends/kafka_backend.rb
326
329
  - lib/tina4/queue_backends/lite_backend.rb
330
+ - lib/tina4/queue_backends/mongo_backend.rb
327
331
  - lib/tina4/queue_backends/rabbitmq_backend.rb
328
332
  - lib/tina4/rack_app.rb
329
333
  - lib/tina4/rate_limiter.rb
@@ -351,6 +355,7 @@ files:
351
355
  - lib/tina4/seeder.rb
352
356
  - lib/tina4/service_runner.rb
353
357
  - lib/tina4/session.rb
358
+ - lib/tina4/session_handlers/database_handler.rb
354
359
  - lib/tina4/session_handlers/file_handler.rb
355
360
  - lib/tina4/session_handlers/mongo_handler.rb
356
361
  - lib/tina4/session_handlers/redis_handler.rb
@@ -369,9 +374,11 @@ files:
369
374
  - lib/tina4/templates/errors/503.twig
370
375
  - lib/tina4/templates/errors/base.twig
371
376
  - lib/tina4/testing.rb
377
+ - lib/tina4/validator.rb
372
378
  - lib/tina4/version.rb
373
379
  - lib/tina4/webserver.rb
374
380
  - lib/tina4/websocket.rb
381
+ - lib/tina4/websocket_backplane.rb
375
382
  - lib/tina4/wsdl.rb
376
383
  - lib/tina4ruby.rb
377
384
  homepage: https://tina4.com
@@ -379,6 +386,7 @@ licenses:
379
386
  - MIT
380
387
  metadata:
381
388
  homepage_uri: https://tina4.com
389
+ post_install_message:
382
390
  rdoc_options: []
383
391
  require_paths:
384
392
  - lib
@@ -393,7 +401,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
393
401
  - !ruby/object:Gem::Version
394
402
  version: '0'
395
403
  requirements: []
396
- rubygems_version: 4.0.3
404
+ rubygems_version: 3.4.19
405
+ signing_key:
397
406
  specification_version: 4
398
407
  summary: Simple. Fast. Human. This is not a framework.
399
408
  test_files: []