tina4ruby 3.12.2 → 3.12.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a97e5249b5ffd22de2dac45ce08f27e217c9c1253192d41b036b0ef90e14dc11
4
- data.tar.gz: d2a67f3f7a4e11dab682f2333e4aed920ada9ba553737c5c77899ed3b7bf8cd2
3
+ metadata.gz: be4da6079e890067fdf914b4dc287a01f68eeee914a51cd97f13e1d768d3d334
4
+ data.tar.gz: 61df8b97ae56df7aba1d40feee95d5f0208b969c5166cce97d8e6b65289ed720
5
5
  SHA512:
6
- metadata.gz: beb243e57f2aa6df523fe64e63c2057a46058d711acda139342ca06144fd207949e24a3a35d25787d1f110bf7bead2cd85178c1cefb0c8158e46be89e7f31004
7
- data.tar.gz: dd2169f5c8837fe90185e9c81eb5bd6e5fa49819230cf6ec79207f32aa6288a6eaec80236d565d7a0b561d08a95f7c0949456d21abd8ac887425c54ac7fe1997
6
+ metadata.gz: c2b2f07c1a30c66c114b7c77845751d6b8125125392cf8adf49689b23378c73d61b79a8882013eeccc6b28ce86dd15121437ec51e228690345c08f32d10572cf
7
+ data.tar.gz: b63ed9e9ffedece9880e1e57c43e355ec6b8ed4c5418914557ebb163e020728c5b8c0b4cfa41c08f86aae70160b2862eebbc52f128a0116cb6bc700bf4b63105
data/lib/tina4/ai.rb CHANGED
@@ -212,11 +212,18 @@ module Tina4
212
212
  next unless system("which #{cmd} > /dev/null 2>&1")
213
213
 
214
214
  result = `#{cmd} install --upgrade tina4-ai 2>&1`
215
+ # Subprocess output is ASCII-8BIT — force UTF-8 with byte replacement
216
+ # so non-ASCII content (often emitted by pip on locale mismatch)
217
+ # doesn't crash String#strip with Encoding::CompatibilityError.
218
+ safe_result = result.dup.force_encoding("UTF-8")
219
+ unless safe_result.valid_encoding?
220
+ safe_result = safe_result.encode("UTF-8", "UTF-8", invalid: :replace, undef: :replace, replace: "?")
221
+ end
215
222
  if $?.success?
216
223
  puts " \e[32m✓\e[0m Installed tina4-ai (mdview)"
217
224
  return
218
225
  else
219
- puts " \e[33m!\e[0m #{cmd} failed: #{result.strip[0..100]}"
226
+ puts " \e[33m!\e[0m #{cmd} failed: #{safe_result.strip[0..100]}"
220
227
  end
221
228
  end
222
229
  puts " \e[33m!\e[0m Python/pip not available -- skip tina4-ai"
@@ -61,7 +61,7 @@ module Tina4
61
61
  end
62
62
 
63
63
  # Check if a service is registered.
64
- def has(name)
64
+ def has?(name)
65
65
  registry.key?(name.to_sym)
66
66
  end
67
67
 
@@ -83,7 +83,7 @@ module Tina4
83
83
  }.freeze
84
84
 
85
85
  # Static factory — cross-framework consistency: Database.create(url)
86
- def self.create(url, username: "", password: "", pool: 0)
86
+ def self.create(url, username: "", password: "", pool: nil)
87
87
  new(url, username: username.empty? ? nil : username,
88
88
  password: password.empty? ? nil : password,
89
89
  pool: pool)
@@ -91,7 +91,7 @@ module Tina4
91
91
 
92
92
  # Construct a Database from environment variables.
93
93
  # Returns nil if the named env var is not set.
94
- def self.from_env(env_key: "TINA4_DATABASE_URL", pool: 0)
94
+ def self.from_env(env_key: "TINA4_DATABASE_URL", pool: nil)
95
95
  url = ENV[env_key]
96
96
  return nil if url.nil? || url.strip.empty?
97
97
 
@@ -101,12 +101,18 @@ module Tina4
101
101
  pool: pool)
102
102
  end
103
103
 
104
- def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: 0)
104
+ def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: nil)
105
105
  @connection_string = connection_string || ENV["TINA4_DATABASE_URL"]
106
106
  @username = username || ENV["TINA4_DATABASE_USERNAME"]
107
107
  @password = password || ENV["TINA4_DATABASE_PASSWORD"]
108
108
  @driver_name = driver_name || detect_driver(@connection_string)
109
- @pool_size = pool # 0 = single connection, N>0 = N pooled connections
109
+ # TINA4_DB_POOL falls back when caller doesn't pass `pool:` explicitly.
110
+ # Default 0 = single connection, N>0 = N pooled connections (round-robin).
111
+ @pool_size = if pool.nil?
112
+ (ENV["TINA4_DB_POOL"] || "0").to_i
113
+ else
114
+ pool
115
+ end
110
116
  @connected = false
111
117
 
112
118
  # Per-instance thread-local key for the transaction adapter pin.
data/lib/tina4/env.rb CHANGED
@@ -62,7 +62,7 @@ module Tina4
62
62
  end
63
63
  lines.concat([
64
64
  "",
65
- "Run `tina4 env-migrate` to rewrite your .env automatically,",
65
+ "Run `tina4 env --migrate` to rewrite your .env automatically,",
66
66
  "or rename manually. See https://tina4.com/release/3.12.0",
67
67
  "Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
68
68
  sep, ""
@@ -132,6 +132,12 @@ module Tina4
132
132
  private
133
133
 
134
134
  def resolve_env_file(root_dir)
135
+ # TINA4_ENV_FILE wins — explicit path or filename (resolved against root_dir).
136
+ explicit = ENV["TINA4_ENV_FILE"]
137
+ if explicit && !explicit.empty?
138
+ return File.absolute_path?(explicit) ? explicit : File.join(root_dir, explicit)
139
+ end
140
+
135
141
  environment = ENV["ENVIRONMENT"]
136
142
  if environment && !environment.empty?
137
143
  candidate = File.join(root_dir, ".env.#{environment}")
data/lib/tina4/frond.rb CHANGED
@@ -190,6 +190,11 @@ module Tina4
190
190
  end
191
191
 
192
192
  # Render a template file with data. Uses token caching for performance.
193
+ #
194
+ # Caching strategy:
195
+ # * TINA4_DEBUG=true — never cache (always re-read + re-tokenize).
196
+ # * TINA4_TEMPLATE_CACHE_TTL > 0 — cache entries expire after N seconds.
197
+ # * TINA4_TEMPLATE_CACHE_TTL == 0 (default in production) — permanent cache.
193
198
  def render(template, data = {})
194
199
  context = @globals.merge(stringify_keys(data))
195
200
 
@@ -197,11 +202,16 @@ module Tina4
197
202
  raise "Template not found: #{path}" unless File.exist?(path)
198
203
 
199
204
  debug_mode = ENV.fetch("TINA4_DEBUG", "").downcase == "true"
205
+ ttl = (ENV["TINA4_TEMPLATE_CACHE_TTL"] || "0").to_i
200
206
 
201
207
  unless debug_mode
202
- # Production: use permanent cache (no filesystem checks)
203
208
  cached = @compiled[template]
204
- return execute_cached(cached[0], context) if cached
209
+ if cached
210
+ # cached layout: [tokens, mtime, cached_at]
211
+ tokens, _mtime, cached_at = cached
212
+ fresh = ttl <= 0 || (Time.now.to_i - cached_at.to_i) < ttl
213
+ return execute_cached(tokens, context) if fresh
214
+ end
205
215
  end
206
216
  # Dev mode: skip cache entirely — always re-read and re-tokenize
207
217
  # so edits to partials and extended base templates are detected
@@ -210,7 +220,7 @@ module Tina4
210
220
  source = File.read(path, encoding: "utf-8")
211
221
  mtime = File.mtime(path)
212
222
  tokens = tokenize(source)
213
- @compiled[template] = [tokens, mtime]
223
+ @compiled[template] = [tokens, mtime, Time.now.to_i]
214
224
  execute_with_tokens(source, tokens, context)
215
225
  end
216
226
 
data/lib/tina4/graphql.rb CHANGED
@@ -845,6 +845,14 @@ module Tina4
845
845
  class GraphQL
846
846
  attr_reader :schema
847
847
 
848
+ # Class-level toggle for ORM auto-schema generation. Defaults to true,
849
+ # can be disabled via TINA4_GRAPHQL_AUTO_SCHEMA=false. Initializers and
850
+ # user app code can branch on this before calling `schema.from_orm(...)`.
851
+ def self.auto_schema_enabled?
852
+ val = ENV.fetch("TINA4_GRAPHQL_AUTO_SCHEMA", "true").to_s.strip.downcase
853
+ !%w[false 0 no off].include?(val)
854
+ end
855
+
848
856
  def initialize(schema = nil)
849
857
  @schema = schema || GraphQLSchema.new
850
858
  @executor = GraphQLExecutor.new(@schema)
@@ -916,7 +924,12 @@ module Tina4
916
924
  # gql.register_route # POST /graphql
917
925
  # gql.register_route("/api/graphql") # custom path
918
926
  #
919
- def register_route(path = "/graphql")
927
+ def register_route(path = nil)
928
+ # TINA4_GRAPHQL_ENDPOINT — defaults to /graphql when caller doesn't override.
929
+ path ||= ENV.fetch("TINA4_GRAPHQL_ENDPOINT", "/graphql")
930
+ path = path.to_s
931
+ path = "/#{path}" unless path.start_with?("/")
932
+
920
933
  graphql = self
921
934
  Tina4.post path, auth: false do |request, response|
922
935
  body = request.body
data/lib/tina4/health.rb CHANGED
@@ -7,8 +7,19 @@ module Tina4
7
7
  START_TIME = Process.clock_gettime(Process::CLOCK_MONOTONIC)
8
8
 
9
9
  class << self
10
+ # Return the configured health endpoint path.
11
+ # TINA4_HEALTH_PATH overrides the default "/__health" — kept consistent across all 4 frameworks.
12
+ def path
13
+ configured = ENV["TINA4_HEALTH_PATH"]
14
+ return "/__health" if configured.nil? || configured.empty?
15
+ configured.start_with?("/") ? configured : "/#{configured}"
16
+ end
17
+
10
18
  def register!
11
- Tina4::Router.add("GET", "/health", method(:handle))
19
+ # Register at the configured path. The legacy "/health" path stays
20
+ # registered for backward-compat.
21
+ Tina4::Router.add("GET", path, method(:handle))
22
+ Tina4::Router.add("GET", "/health", method(:handle)) unless path == "/health"
12
23
  end
13
24
 
14
25
  def handle(_request, response)
data/lib/tina4/log.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "json"
5
+ require "logger"
5
6
 
6
7
  module Tina4
7
8
  module Log
@@ -27,23 +28,74 @@ module Tina4
27
28
  # ANSI escape code regex for stripping from file output
28
29
  ANSI_RE = /\033\[[0-9;]*m/
29
30
 
31
+ # Defaults used when env vars are unset.
32
+ DEFAULT_ROTATE_SIZE = 10 * 1024 * 1024 # 10MB
33
+ DEFAULT_ROTATE_KEEP = 5
34
+
30
35
  class << self
31
- attr_reader :log_dir
36
+ attr_reader :log_dir, :log_file_path
32
37
 
33
38
  def configure(root_dir = Dir.pwd)
34
- @log_dir = File.join(root_dir, "logs")
39
+ # TINA4_LOG_DIR — relative or absolute. Default "logs".
40
+ log_dir_env = ENV["TINA4_LOG_DIR"]
41
+ log_dir_env = "logs" if log_dir_env.nil? || log_dir_env.empty?
42
+ @log_dir = if File.absolute_path?(log_dir_env)
43
+ log_dir_env
44
+ else
45
+ File.join(root_dir, log_dir_env)
46
+ end
35
47
  FileUtils.mkdir_p(@log_dir)
36
48
 
37
- @max_size_mb = (ENV["TINA4_LOG_MAX_SIZE"] || "10").to_i
38
- @max_size = @max_size_mb * 1024 * 1024
39
- @keep = (ENV["TINA4_LOG_KEEP"] || "5").to_i
40
- @json_mode = production?
41
- @log_file = File.join(@log_dir, "tina4.log")
49
+ # TINA4_LOG_FILE — explicit log file path (absolute or relative to log_dir).
50
+ # Default: <log_dir>/tina4.log.
51
+ log_file_env = ENV["TINA4_LOG_FILE"]
52
+ @log_file_path = if log_file_env && !log_file_env.empty?
53
+ File.absolute_path?(log_file_env) ? log_file_env : File.join(@log_dir, log_file_env)
54
+ else
55
+ File.join(@log_dir, "tina4.log")
56
+ end
57
+
58
+ # TINA4_LOG_ROTATE_SIZE — bytes per file before rotation. 0 = no rotation.
59
+ @rotate_size = (ENV["TINA4_LOG_ROTATE_SIZE"] || DEFAULT_ROTATE_SIZE).to_i
60
+ # TINA4_LOG_ROTATE_KEEP — number of rotated backups to keep.
61
+ @rotate_keep = (ENV["TINA4_LOG_ROTATE_KEEP"] || DEFAULT_ROTATE_KEEP).to_i
62
+
63
+ # TINA4_LOG_FORMAT — "text" or "json". Defaults to "json" in production, else "text".
64
+ format_env = ENV["TINA4_LOG_FORMAT"]
65
+ @format = format_env && !format_env.empty? ? format_env.downcase : (production? ? "json" : "text")
66
+ @json_mode = @format == "json"
67
+
68
+ # TINA4_LOG_OUTPUT — "stdout", "file", or "both". Defaults to "both".
69
+ output_env = ENV["TINA4_LOG_OUTPUT"]
70
+ @output = output_env && !output_env.empty? ? output_env.downcase : "both"
71
+ unless %w[stdout file both].include?(@output)
72
+ @output = "both"
73
+ end
74
+
75
+ # TINA4_LOG_CRITICAL — when true, raise on log write failures instead of swallowing.
76
+ @critical = truthy?(ENV["TINA4_LOG_CRITICAL"])
42
77
 
43
78
  @console_level = resolve_level
44
79
  @request_id = nil
45
80
  @current_context = {}
46
81
  @mutex = Mutex.new
82
+
83
+ # Build the file logger via stdlib Logger which handles rotation natively.
84
+ # Logger.new(path, shift_age, shift_size):
85
+ # shift_age = number of files to keep
86
+ # shift_size = bytes before rotation
87
+ # When @rotate_size is 0, omit rotation args.
88
+ close_file_logger
89
+ if @output != "stdout"
90
+ @file_logger = if @rotate_size > 0
91
+ ::Logger.new(@log_file_path, @rotate_keep, @rotate_size)
92
+ else
93
+ ::Logger.new(@log_file_path)
94
+ end
95
+ # We do our own formatting — strip Logger's default formatter.
96
+ @file_logger.formatter = proc { |_sev, _t, _p, msg| msg.to_s.end_with?("\n") ? msg : "#{msg}\n" }
97
+ end
98
+
47
99
  @initialized = true
48
100
  end
49
101
 
@@ -79,8 +131,19 @@ module Tina4
79
131
  log(:error, message, context)
80
132
  end
81
133
 
134
+ # Test/teardown helper — closes the underlying Logger so the file
135
+ # handle is released (Windows / tmpdir cleanup).
136
+ def close_file_logger
137
+ @file_logger&.close rescue nil
138
+ @file_logger = nil
139
+ end
140
+
82
141
  private
83
142
 
143
+ def truthy?(val)
144
+ %w[true 1 yes on].include?(val.to_s.strip.downcase)
145
+ end
146
+
84
147
  def production?
85
148
  env = ENV["TINA4_ENV"] || ENV["RACK_ENV"] || ENV["RUBY_ENV"] || "development"
86
149
  env.downcase == "production"
@@ -92,9 +155,9 @@ module Tina4
92
155
 
93
156
  formatted = format_line(level, message)
94
157
 
95
- # Console output respects TINA4_LOG_LEVEL
158
+ # Console output respects TINA4_LOG_LEVEL and TINA4_LOG_OUTPUT
96
159
  severity = SEVERITY_MAP[level] || 0
97
- if severity >= @console_level
160
+ if severity >= @console_level && @output != "file"
98
161
  if @json_mode
99
162
  $stdout.puts json_line(level, message)
100
163
  else
@@ -102,8 +165,11 @@ module Tina4
102
165
  end
103
166
  end
104
167
 
105
- # File always gets ALL levels, plain text (no ANSI)
106
- write_to_file(strip_ansi(formatted))
168
+ # File output — always full level (consumer parses themselves) unless disabled.
169
+ if @output != "stdout" && @file_logger
170
+ payload = @json_mode ? json_line(level, message) : strip_ansi(formatted)
171
+ write_to_file(payload)
172
+ end
107
173
 
108
174
  @current_context = {}
109
175
  end
@@ -166,37 +232,12 @@ module Tina4
166
232
  end
167
233
 
168
234
  def write_to_file(line)
169
- rotate_if_needed
170
- begin
171
- File.open(@log_file, "a") { |f| f.puts(line) }
172
- rescue IOError, SystemCallError
173
- # Don't crash on log write failure
174
- end
175
- end
176
-
177
- # Numbered rotation: tina4.log → tina4.log.1 → tina4.log.2 → ... → tina4.log.{keep}
178
- def rotate_if_needed
179
- return unless File.exist?(@log_file)
180
-
181
- begin
182
- return if File.size(@log_file) < @max_size
183
- rescue SystemCallError
184
- return
185
- end
186
-
187
- # Delete the oldest rotated file if it exists
188
- oldest = "#{@log_file}.#{@keep}"
189
- File.delete(oldest) if File.exist?(oldest)
190
-
191
- # Shift existing rotated files: .{n} → .{n+1}
192
- (@keep - 1).downto(1) do |n|
193
- src = "#{@log_file}.#{n}"
194
- dst = "#{@log_file}.#{n + 1}"
195
- File.rename(src, dst) if File.exist?(src)
196
- end
197
-
198
- # Rename current log to .1
199
- File.rename(@log_file, "#{@log_file}.1") rescue nil
235
+ return unless @file_logger
236
+ # Use << to bypass Logger's severity filtering — we already filtered above.
237
+ @file_logger << "#{line}\n"
238
+ rescue IOError, SystemCallError => e
239
+ raise if @critical
240
+ # Don't crash on log write failure
200
241
  end
201
242
  end
202
243
  end
data/lib/tina4/mcp.rb CHANGED
@@ -136,6 +136,30 @@ module Tina4
136
136
  ["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].include?(host)
137
137
  end
138
138
 
139
+ # Resolve whether the built-in MCP dev server should be active.
140
+ #
141
+ # Precedence:
142
+ # * TINA4_MCP set explicitly → use that (truthy/falsey).
143
+ # * Otherwise: enabled only when TINA4_DEBUG=true.
144
+ def self.mcp_enabled?
145
+ explicit = ENV["TINA4_MCP"]
146
+ if explicit && !explicit.empty?
147
+ return %w[true 1 yes on].include?(explicit.to_s.strip.downcase)
148
+ end
149
+ %w[true 1 yes on].include?(ENV.fetch("TINA4_DEBUG", "").to_s.strip.downcase)
150
+ end
151
+
152
+ # Resolve the dedicated MCP port. Defaults to (server port + 2000) — keeps
153
+ # MCP tooling reachable on a stable, predictable channel separate from the
154
+ # main HTTP port and the AI test port (port + 1000).
155
+ def self.mcp_port
156
+ explicit = ENV["TINA4_MCP_PORT"]
157
+ return explicit.to_i if explicit && !explicit.empty? && explicit.to_i > 0
158
+
159
+ base_port = (ENV["TINA4_PORT"] || ENV["PORT"] || "7147").to_i
160
+ base_port + 2000
161
+ end
162
+
139
163
  # ── McpServer ─────────────────────────────────────────────────────
140
164
  class McpServer
141
165
  attr_reader :path, :name, :version
@@ -36,13 +36,14 @@ module Tina4
36
36
  #
37
37
  class Messenger
38
38
  attr_reader :host, :port, :username, :from_address, :from_name,
39
- :imap_host, :imap_port, :use_tls, :encryption
39
+ :imap_host, :imap_port, :use_tls, :encryption,
40
+ :imap_encryption, :imap_use_tls
40
41
 
41
42
  # Initialize with SMTP config.
42
43
  # Priority: constructor params > ENV (TINA4_MAIL_*) > sensible defaults
43
44
  def initialize(host: nil, port: nil, username: nil, password: nil,
44
45
  from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
45
- imap_host: nil, imap_port: nil)
46
+ imap_host: nil, imap_port: nil, imap_encryption: nil)
46
47
  @host = host || ENV["TINA4_MAIL_HOST"] || "localhost"
47
48
  @port = (port || ENV["TINA4_MAIL_PORT"] || 587).to_i
48
49
  @username = username || ENV["TINA4_MAIL_USERNAME"]
@@ -53,7 +54,7 @@ module Tina4
53
54
 
54
55
  @from_name = from_name || ENV["TINA4_MAIL_FROM_NAME"] || ""
55
56
 
56
- # Encryption: constructor > .env > backward-compat use_tls > default "tls"
57
+ # SMTP encryption: constructor > .env > backward-compat use_tls > default "tls"
57
58
  env_encryption = encryption || ENV["TINA4_MAIL_ENCRYPTION"]
58
59
  if env_encryption
59
60
  @encryption = env_encryption.downcase
@@ -66,6 +67,12 @@ module Tina4
66
67
 
67
68
  @imap_host = imap_host || ENV["TINA4_MAIL_IMAP_HOST"] || @host
68
69
  @imap_port = (imap_port || ENV["TINA4_MAIL_IMAP_PORT"] || 993).to_i
70
+
71
+ # IMAP encryption: dedicated env var TINA4_MAIL_IMAP_ENCRYPTION (tls/starttls/none).
72
+ # Defaults to "tls" — IMAPS over implicit TLS on port 993 is the safe industry norm.
73
+ env_imap_enc = imap_encryption || ENV["TINA4_MAIL_IMAP_ENCRYPTION"]
74
+ @imap_encryption = (env_imap_enc && !env_imap_enc.to_s.empty?) ? env_imap_enc.to_s.downcase : "tls"
75
+ @imap_use_tls = %w[tls starttls ssl].include?(@imap_encryption)
69
76
  end
70
77
 
71
78
  # Send email using Ruby's Net::SMTP
@@ -387,7 +394,7 @@ module Tina4
387
394
  # ── IMAP helpers ─────────────────────────────────────────────────────
388
395
 
389
396
  def imap_connect(&block)
390
- imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @use_tls)
397
+ imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
391
398
  imap.login(@username, @password)
392
399
  result = block.call(imap)
393
400
  imap.logout
@@ -3,6 +3,17 @@
3
3
  module Tina4
4
4
  # Multi-backend response cache for GET requests.
5
5
  #
6
+ # Public surface (parity with Python tina4_python.cache):
7
+ # - Tina4::ResponseCache — middleware class
8
+ # - Tina4.cache_stats — module function returning cache stats
9
+ # - Tina4.clear_cache — module function flushing all cached entries
10
+ # - Tina4.cache_get / cache_set / cache_delete — module-level KV API
11
+ #
12
+ # The internal lookup/store of GET responses is performed by the middleware
13
+ # hooks (before_cache, after_cache) and is NOT exposed publicly. Use the
14
+ # middleware by attaching ResponseCache to your route, not by calling
15
+ # the (private) internal_lookup / internal_store directly.
16
+ #
6
17
  # Backends are selected via the TINA4_CACHE_BACKEND env var:
7
18
  # memory — in-process LRU cache (default, zero deps)
8
19
  # redis — Redis / Valkey (uses `redis` gem or raw RESP over TCP)
@@ -14,16 +25,6 @@ module Tina4
14
25
  # TINA4_CACHE_TTL — default TTL in seconds (default: 0 = disabled)
15
26
  # TINA4_CACHE_MAX_ENTRIES — maximum cache entries (default: 1000)
16
27
  #
17
- # Usage:
18
- # cache = Tina4::ResponseCache.new(ttl: 60, max_entries: 1000)
19
- # cache.cache_response("GET", "/api/users", 200, "application/json", '{"users":[]}')
20
- # hit = cache.get("GET", "/api/users")
21
- #
22
- # # Direct API (same across all 4 languages)
23
- # cache.cache_set("key", {"data" => "value"}, ttl: 120)
24
- # value = cache.cache_get("key")
25
- # cache.cache_delete("key")
26
- #
27
28
  class ResponseCache
28
29
  CacheEntry = Struct.new(:body, :content_type, :status_code, :expires_at)
29
30
 
@@ -57,94 +58,75 @@ module Tina4
57
58
  @ttl > 0
58
59
  end
59
60
 
60
- # Build a cache key from method and URL.
61
- #
62
- # @param method [String]
63
- # @param url [String]
64
- # @return [String]
65
- def cache_key(method, url)
66
- "#{method}:#{url}"
67
- end
68
-
69
- # Retrieve a cached response. Returns nil on miss or expired entry.
70
- #
71
- # @param method [String]
72
- # @param url [String]
73
- # @return [CacheEntry, nil]
74
- def get(method, url)
75
- return nil unless enabled?
76
- return nil unless method == "GET"
61
+ # ── Middleware hooks ────────────────────────────────────────────
77
62
 
78
- key = cache_key(method, url)
79
- entry = backend_get(key)
63
+ # Middleware hook — checks for a cached entry before the route handler runs.
64
+ # If a cached entry exists for this GET request, short-circuits by replacing
65
+ # the response. Otherwise tags the request so after_cache can capture the
66
+ # response.
67
+ def before_cache(request, response)
68
+ return [request, response] unless enabled?
80
69
 
81
- if entry.nil?
82
- @mutex.synchronize { @misses += 1 }
83
- return nil
84
- end
70
+ method = (request.respond_to?(:method) ? request.method : "GET").to_s.upcase
71
+ return [request, response] unless method == "GET"
85
72
 
86
- # For memory backend, entry is a CacheEntry; for others, reconstruct
87
- if entry.is_a?(CacheEntry)
88
- if Time.now.to_f > entry.expires_at
89
- backend_delete(key)
90
- @mutex.synchronize { @misses += 1 }
91
- return nil
73
+ url = request.respond_to?(:url) ? request.url : (request.respond_to?(:path) ? request.path : "/")
74
+ hit = internal_lookup(method, url)
75
+ if hit
76
+ if response.respond_to?(:call)
77
+ new_response = response.call(hit.body, hit.status_code, hit.content_type)
78
+ return [request, new_response]
92
79
  end
93
- @mutex.synchronize { @hits += 1 }
94
- entry
95
- elsif entry.is_a?(Hash)
96
- expires_at = entry["expires_at"] || entry[:expires_at] || 0
97
- if Time.now.to_f > expires_at
98
- backend_delete(key)
99
- @mutex.synchronize { @misses += 1 }
100
- return nil
101
- end
102
- @mutex.synchronize { @hits += 1 }
103
- CacheEntry.new(
104
- entry["body"] || entry[:body],
105
- entry["content_type"] || entry[:content_type],
106
- entry["status_code"] || entry[:status_code],
107
- expires_at
108
- )
109
80
  end
110
- end
111
-
112
- # Store a response in the cache.
113
- #
114
- # @param method [String]
115
- # @param url [String]
116
- # @param status_code [Integer]
117
- # @param content_type [String]
118
- # @param body [String]
119
- # @param ttl [Integer, nil] override default TTL
120
- def cache_response(method, url, status_code, content_type, body, ttl: nil)
121
- return unless enabled?
122
- return unless method == "GET"
123
- return unless @status_codes.include?(status_code)
124
-
125
- effective_ttl = ttl || @ttl
126
- key = cache_key(method, url)
127
- expires_at = Time.now.to_f + effective_ttl
128
-
129
- entry_data = {
130
- "body" => body,
131
- "content_type" => content_type,
132
- "status_code" => status_code,
133
- "expires_at" => expires_at
134
- }
135
81
 
136
- case @backend_name
137
- when "memory"
138
- @mutex.synchronize do
139
- if @store.size >= @max_entries && !@store.key?(key)
140
- oldest_key = @store.keys.first
141
- @store.delete(oldest_key)
142
- end
143
- @store[key] = CacheEntry.new(body, content_type, status_code, expires_at)
144
- end
82
+ # Tag for after_cache
83
+ if request.respond_to?(:[]=)
84
+ request[:_cache_method] = method
85
+ request[:_cache_url] = url
145
86
  else
146
- backend_set(key, entry_data, effective_ttl)
87
+ request.instance_variable_set(:@_cache_method, method)
88
+ request.instance_variable_set(:@_cache_url, url)
147
89
  end
90
+ [request, response]
91
+ end
92
+
93
+ # Middleware hook — captures the response body and stores it after the
94
+ # route handler runs.
95
+ def after_cache(request, response)
96
+ return [request, response] unless enabled?
97
+
98
+ method = if request.respond_to?(:[])
99
+ request[:_cache_method]
100
+ else
101
+ request.instance_variable_get(:@_cache_method)
102
+ end
103
+ url = if request.respond_to?(:[])
104
+ request[:_cache_url]
105
+ else
106
+ request.instance_variable_get(:@_cache_url)
107
+ end
108
+ return [request, response] if method.nil? || url.nil?
109
+
110
+ status = if response.respond_to?(:status_code)
111
+ response.status_code
112
+ elsif response.respond_to?(:status)
113
+ response.status
114
+ else
115
+ 200
116
+ end
117
+ content_type = if response.respond_to?(:content_type)
118
+ response.content_type
119
+ else
120
+ "application/json"
121
+ end
122
+ body = if response.respond_to?(:body)
123
+ response.body.to_s
124
+ else
125
+ response.to_s
126
+ end
127
+
128
+ internal_store(method, url, status.to_i, content_type.to_s, body)
129
+ [request, response]
148
130
  end
149
131
 
150
132
  # ── Direct Cache API (same across all 4 languages) ──────────
@@ -297,8 +279,95 @@ module Tina4
297
279
  @backend_name
298
280
  end
299
281
 
282
+ # @internal Test seam — exercises the same path the middleware uses.
283
+ # Public for parity tests only; do not use in application code.
284
+ def _internal_lookup(method, url)
285
+ internal_lookup(method, url)
286
+ end
287
+
288
+ # @internal Test seam — exercises the same path the middleware uses.
289
+ # Public for parity tests only; do not use in application code.
290
+ def _internal_store(method, url, status_code, content_type, body, ttl: nil)
291
+ internal_store(method, url, status_code, content_type, body, ttl: ttl)
292
+ end
293
+
300
294
  private
301
295
 
296
+ # Build a cache key from method and URL.
297
+ def cache_key(method, url)
298
+ "#{method}:#{url}"
299
+ end
300
+
301
+ # Internal: retrieve a cached response. Used by middleware hooks only.
302
+ def internal_lookup(method, url)
303
+ return nil unless enabled?
304
+ return nil unless method == "GET"
305
+
306
+ key = cache_key(method, url)
307
+ entry = backend_get(key)
308
+
309
+ if entry.nil?
310
+ @mutex.synchronize { @misses += 1 }
311
+ return nil
312
+ end
313
+
314
+ # For memory backend, entry is a CacheEntry; for others, reconstruct
315
+ if entry.is_a?(CacheEntry)
316
+ if Time.now.to_f > entry.expires_at
317
+ backend_delete(key)
318
+ @mutex.synchronize { @misses += 1 }
319
+ return nil
320
+ end
321
+ @mutex.synchronize { @hits += 1 }
322
+ entry
323
+ elsif entry.is_a?(Hash)
324
+ expires_at = entry["expires_at"] || entry[:expires_at] || 0
325
+ if Time.now.to_f > expires_at
326
+ backend_delete(key)
327
+ @mutex.synchronize { @misses += 1 }
328
+ return nil
329
+ end
330
+ @mutex.synchronize { @hits += 1 }
331
+ CacheEntry.new(
332
+ entry["body"] || entry[:body],
333
+ entry["content_type"] || entry[:content_type],
334
+ entry["status_code"] || entry[:status_code],
335
+ expires_at
336
+ )
337
+ end
338
+ end
339
+
340
+ # Internal: store a response in the cache. Used by middleware hooks only.
341
+ def internal_store(method, url, status_code, content_type, body, ttl: nil)
342
+ return unless enabled?
343
+ return unless method == "GET"
344
+ return unless @status_codes.include?(status_code)
345
+
346
+ effective_ttl = ttl || @ttl
347
+ key = cache_key(method, url)
348
+ expires_at = Time.now.to_f + effective_ttl
349
+
350
+ entry_data = {
351
+ "body" => body,
352
+ "content_type" => content_type,
353
+ "status_code" => status_code,
354
+ "expires_at" => expires_at
355
+ }
356
+
357
+ case @backend_name
358
+ when "memory"
359
+ @mutex.synchronize do
360
+ if @store.size >= @max_entries && !@store.key?(key)
361
+ oldest_key = @store.keys.first
362
+ @store.delete(oldest_key)
363
+ end
364
+ @store[key] = CacheEntry.new(body, content_type, status_code, expires_at)
365
+ end
366
+ else
367
+ backend_set(key, entry_data, effective_ttl)
368
+ end
369
+ end
370
+
302
371
  # ── Backend initialization ─────────────────────────────────
303
372
 
304
373
  def init_backend
@@ -519,15 +588,17 @@ module Tina4
519
588
  end
520
589
  end
521
590
 
522
- # ── Module-level convenience (singleton) ───────────────────────
591
+ # ── Module-level convenience (singleton, parity with Python) ───
523
592
 
524
593
  @default_cache = nil
525
594
 
526
595
  class << self
596
+ # Lazy module-level singleton for cache_stats / clear_cache.
527
597
  def cache_instance
528
598
  @default_cache ||= ResponseCache.new(ttl: ENV["TINA4_CACHE_TTL"] ? ENV["TINA4_CACHE_TTL"].to_i : 60)
529
599
  end
530
600
 
601
+ # Module-level KV API (parity with Python tina4_python.cache).
531
602
  def cache_get(key)
532
603
  cache_instance.cache_get(key)
533
604
  end
@@ -540,12 +611,19 @@ module Tina4
540
611
  cache_instance.cache_delete(key)
541
612
  end
542
613
 
543
- def cache_clear
614
+ # Module-level cache stats (parity with Python tina4_python.cache.cache_stats()).
615
+ def cache_stats
616
+ cache_instance.cache_stats
617
+ end
618
+
619
+ # Module-level cache clear (parity with Python tina4_python.cache.clear_cache()).
620
+ def clear_cache
544
621
  cache_instance.clear_cache
545
622
  end
546
623
 
547
- def cache_stats
548
- cache_instance.cache_stats
624
+ # Backward-compat alias for cache_clear (deprecated — use clear_cache).
625
+ def cache_clear
626
+ cache_instance.clear_cache
549
627
  end
550
628
  end
551
629
  end
data/lib/tina4/router.rb CHANGED
@@ -328,6 +328,14 @@ module Tina4
328
328
  nil
329
329
  end
330
330
 
331
+ # When TINA4_TRAILING_SLASH_REDIRECT is truthy, the rack app uses this
332
+ # to detect whether the *original* (un-stripped) path differed from the
333
+ # canonical form so it can issue a 301 redirect. Default false — silent
334
+ # match keeps backward compatibility.
335
+ def trailing_slash_redirect?
336
+ %w[true 1 yes on].include?(ENV.fetch("TINA4_TRAILING_SLASH_REDIRECT", "").to_s.strip.downcase)
337
+ end
338
+
331
339
  # Find a route matching method + path. Returns [route, params] or nil.
332
340
  # match(method, path) — consistent with Python, PHP, and Node.
333
341
  def match(method, path)
data/lib/tina4/session.rb CHANGED
@@ -16,6 +16,11 @@ module Tina4
16
16
 
17
17
  def initialize(env, options = {})
18
18
  @options = DEFAULT_OPTIONS.merge(options)
19
+ # TINA4_SESSION_NAME — overrides cookie_name unless caller explicitly passed one.
20
+ env_name = ENV["TINA4_SESSION_NAME"]
21
+ if !options.key?(:cookie_name) && env_name && !env_name.empty?
22
+ @options[:cookie_name] = env_name
23
+ end
19
24
  @options[:secret] ||= ENV["TINA4_SECRET"] || "tina4-default-secret"
20
25
  @handler = create_handler
21
26
  @id = extract_session_id(env) || SecureRandom.hex(32)
@@ -151,7 +156,17 @@ module Tina4
151
156
  def cookie_header(cookie_name = nil)
152
157
  name = cookie_name || @options[:cookie_name]
153
158
  samesite = ENV["TINA4_SESSION_SAMESITE"] || "Lax"
154
- "#{name}=#{@id}; Path=/; HttpOnly; SameSite=#{samesite}; Max-Age=#{@options[:max_age]}"
159
+ # HttpOnly defaults to true (existing behaviour); flip off only when explicitly false.
160
+ httponly = !%w[false 0 no off].include?((ENV["TINA4_SESSION_HTTPONLY"] || "true").to_s.strip.downcase)
161
+ # Secure defaults to false; flip on with truthy env var.
162
+ secure = %w[true 1 yes on].include?((ENV["TINA4_SESSION_SECURE"] || "false").to_s.strip.downcase)
163
+
164
+ parts = ["#{name}=#{@id}", "Path=/"]
165
+ parts << "HttpOnly" if httponly
166
+ parts << "Secure" if secure
167
+ parts << "SameSite=#{samesite}"
168
+ parts << "Max-Age=#{@options[:max_age]}"
169
+ parts.join("; ")
155
170
  end
156
171
 
157
172
  private
data/lib/tina4/swagger.rb CHANGED
@@ -13,16 +13,44 @@ module Tina4
13
13
  spec
14
14
  end
15
15
 
16
+ # TINA4_SWAGGER_ENABLED — defaults to TINA4_DEBUG. When false, callers
17
+ # can choose to skip mounting /swagger entirely in production.
18
+ def enabled?
19
+ explicit = ENV["TINA4_SWAGGER_ENABLED"]
20
+ if explicit && !explicit.empty?
21
+ return %w[true 1 yes on].include?(explicit.to_s.strip.downcase)
22
+ end
23
+ %w[true 1 yes on].include?(ENV.fetch("TINA4_DEBUG", "").to_s.strip.downcase)
24
+ end
25
+
16
26
  private
17
27
 
18
28
  def base_spec
29
+ info = {
30
+ "title" => ENV["TINA4_SWAGGER_TITLE"] || ENV["PROJECT_NAME"] || "Tina4 API",
31
+ "version" => ENV["TINA4_SWAGGER_VERSION"] || Tina4::VERSION,
32
+ "description" => ENV["TINA4_SWAGGER_DESCRIPTION"] || "Auto-generated API documentation"
33
+ }
34
+
35
+ # Optional contact block — only emitted when at least one field is set.
36
+ contact_email = ENV["TINA4_SWAGGER_CONTACT_EMAIL"]
37
+ contact_team = ENV["TINA4_SWAGGER_CONTACT_TEAM"] || ENV["SWAGGER_CONTACT_TEAM"]
38
+ contact_url = ENV["TINA4_SWAGGER_CONTACT_URL"] || ENV["SWAGGER_CONTACT_URL"]
39
+ contact = {}
40
+ contact["email"] = contact_email if contact_email && !contact_email.empty?
41
+ contact["name"] = contact_team if contact_team && !contact_team.empty?
42
+ contact["url"] = contact_url if contact_url && !contact_url.empty?
43
+ info["contact"] = contact unless contact.empty?
44
+
45
+ # Optional license block — TINA4_SWAGGER_LICENSE is the SPDX name (e.g. "MIT").
46
+ license_name = ENV["TINA4_SWAGGER_LICENSE"]
47
+ if license_name && !license_name.empty?
48
+ info["license"] = { "name" => license_name }
49
+ end
50
+
19
51
  {
20
52
  "openapi" => "3.0.3",
21
- "info" => {
22
- "title" => ENV["TINA4_SWAGGER_TITLE"] || ENV["PROJECT_NAME"] || "Tina4 API",
23
- "version" => ENV["TINA4_SWAGGER_VERSION"] || Tina4::VERSION,
24
- "description" => ENV["TINA4_SWAGGER_DESCRIPTION"] || "Auto-generated API documentation"
25
- },
53
+ "info" => info,
26
54
  "servers" => [
27
55
  { "url" => "/" }
28
56
  ],
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.12.2"
4
+ VERSION = "3.12.4"
5
5
  end
@@ -2,10 +2,16 @@
2
2
 
3
3
  module Tina4
4
4
  class WebServer
5
- def initialize(app, host: "0.0.0.0", port: 7147)
5
+ DEFAULT_HOST = "0.0.0.0"
6
+ DEFAULT_PORT = 7147
7
+
8
+ # TINA4_HOST overrides the bind address when caller doesn't pass `host:`.
9
+ def initialize(app, host: nil, port: nil)
6
10
  @app = app
7
- @host = host
8
- @port = port
11
+ env_host = ENV["TINA4_HOST"]
12
+ @host = host || (env_host && !env_host.empty? ? env_host : DEFAULT_HOST)
13
+ env_port = ENV["TINA4_PORT"] || ENV["PORT"]
14
+ @port = port || (env_port && !env_port.empty? ? env_port.to_i : DEFAULT_PORT)
9
15
  end
10
16
 
11
17
  # Kill whatever process is listening on *port*.
@@ -105,12 +105,16 @@ module Tina4
105
105
 
106
106
  # ── Rooms ──────────────────────────────────────────────────
107
107
 
108
- def join_room_for(conn_id, room_name)
108
+ # Internal: add connection ID to a room (called by WebSocketConnection#join_room).
109
+ # Mirrors Python's WebSocketManager._join_room — not part of the public API.
110
+ def _join_room(conn_id, room_name)
109
111
  @rooms[room_name] ||= Set.new
110
112
  @rooms[room_name].add(conn_id)
111
113
  end
112
114
 
113
- def leave_room_for(conn_id, room_name)
115
+ # Internal: remove connection ID from a room (called by WebSocketConnection#leave_room).
116
+ # Mirrors Python's WebSocketManager._leave_room — not part of the public API.
117
+ def _leave_room(conn_id, room_name)
114
118
  @rooms[room_name]&.delete(conn_id)
115
119
  end
116
120
 
@@ -251,12 +255,12 @@ module Tina4
251
255
 
252
256
  def join_room(room_name)
253
257
  @rooms.add(room_name)
254
- @ws_server&.join_room_for(@id, room_name)
258
+ @ws_server&._join_room(@id, room_name)
255
259
  end
256
260
 
257
261
  def leave_room(room_name)
258
262
  @rooms.delete(room_name)
259
- @ws_server&.leave_room_for(@id, room_name)
263
+ @ws_server&._leave_room(@id, room_name)
260
264
  end
261
265
 
262
266
  def broadcast_to_room(room_name, message, exclude_self: false)
data/lib/tina4.rb CHANGED
@@ -119,6 +119,9 @@ module Tina4
119
119
  attr_accessor :root_dir, :database
120
120
 
121
121
  def print_banner(host: "0.0.0.0", port: 7147, server_name: nil)
122
+ # TINA4_SUPPRESS — short-circuit ALL banner output for headless / CI runs.
123
+ return if Tina4::Env.is_truthy(ENV["TINA4_SUPPRESS"])
124
+
122
125
  is_tty = $stdout.respond_to?(:isatty) && $stdout.isatty
123
126
  color = is_tty ? "\e[31m" : ""
124
127
  reset = is_tty ? "\e[0m" : ""
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.12.2
4
+ version: 3.12.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-05 00:00:00.000000000 Z
11
+ date: 2026-05-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack