tina4ruby 3.0.0 → 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.
- checksums.yaml +4 -4
- data/README.md +120 -32
- data/lib/tina4/auth.rb +137 -27
- data/lib/tina4/auto_crud.rb +55 -3
- data/lib/tina4/cli.rb +228 -28
- data/lib/tina4/cors.rb +1 -1
- data/lib/tina4/database.rb +230 -26
- data/lib/tina4/database_result.rb +122 -8
- data/lib/tina4/dev_mailbox.rb +1 -1
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/frond.rb +314 -7
- 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/localization.rb +1 -1
- data/lib/tina4/messenger.rb +111 -33
- data/lib/tina4/middleware.rb +349 -1
- data/lib/tina4/migration.rb +132 -11
- data/lib/tina4/orm.rb +149 -18
- data/lib/tina4/public/js/tina4-dev-admin.min.js +1 -1
- data/lib/tina4/public/js/tina4js.min.js +47 -0
- data/lib/tina4/query_builder.rb +374 -0
- data/lib/tina4/queue.rb +219 -61
- data/lib/tina4/queue_backends/lite_backend.rb +42 -7
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -0
- data/lib/tina4/rack_app.rb +200 -11
- data/lib/tina4/request.rb +14 -1
- data/lib/tina4/response.rb +26 -0
- data/lib/tina4/response_cache.rb +446 -29
- data/lib/tina4/router.rb +127 -0
- data/lib/tina4/service_runner.rb +1 -1
- data/lib/tina4/session.rb +6 -1
- data/lib/tina4/session_handlers/database_handler.rb +66 -0
- data/lib/tina4/swagger.rb +1 -1
- data/lib/tina4/templates/errors/404.twig +2 -2
- data/lib/tina4/templates/errors/500.twig +1 -1
- data/lib/tina4/validator.rb +174 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +23 -4
- data/lib/tina4/websocket_backplane.rb +118 -0
- data/lib/tina4.rb +126 -5
- metadata +40 -3
data/lib/tina4/response_cache.rb
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Tina4
|
|
4
|
-
#
|
|
4
|
+
# Multi-backend response cache for GET requests.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
6
|
+
# Backends are selected via the TINA4_CACHE_BACKEND env var:
|
|
7
|
+
# memory — in-process LRU cache (default, zero deps)
|
|
8
|
+
# redis — Redis / Valkey (uses `redis` gem or raw RESP over TCP)
|
|
9
|
+
# file — JSON files in data/cache/
|
|
10
|
+
#
|
|
11
|
+
# Environment:
|
|
12
|
+
# TINA4_CACHE_BACKEND — memory | redis | file (default: memory)
|
|
13
|
+
# TINA4_CACHE_URL — redis://localhost:6379 (redis only)
|
|
14
|
+
# TINA4_CACHE_TTL — default TTL in seconds (default: 0 = disabled)
|
|
15
|
+
# TINA4_CACHE_MAX_ENTRIES — maximum cache entries (default: 1000)
|
|
8
16
|
#
|
|
9
17
|
# Usage:
|
|
10
18
|
# cache = Tina4::ResponseCache.new(ttl: 60, max_entries: 1000)
|
|
11
19
|
# cache.cache_response("GET", "/api/users", 200, "application/json", '{"users":[]}')
|
|
12
20
|
# hit = cache.get("GET", "/api/users")
|
|
13
21
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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")
|
|
16
26
|
#
|
|
17
27
|
class ResponseCache
|
|
18
28
|
CacheEntry = Struct.new(:body, :content_type, :status_code, :expires_at)
|
|
@@ -20,12 +30,24 @@ module Tina4
|
|
|
20
30
|
# @param ttl [Integer] default TTL in seconds (0 = disabled)
|
|
21
31
|
# @param max_entries [Integer] maximum cache entries
|
|
22
32
|
# @param status_codes [Array<Integer>] only cache these status codes
|
|
23
|
-
|
|
33
|
+
# @param backend [String, nil] cache backend: memory|redis|file
|
|
34
|
+
# @param cache_url [String, nil] Redis URL
|
|
35
|
+
# @param cache_dir [String, nil] File cache directory
|
|
36
|
+
def initialize(ttl: nil, max_entries: nil, status_codes: [200],
|
|
37
|
+
backend: nil, cache_url: nil, cache_dir: nil)
|
|
24
38
|
@ttl = ttl || (ENV["TINA4_CACHE_TTL"] ? ENV["TINA4_CACHE_TTL"].to_i : 0)
|
|
25
|
-
@max_entries = max_entries
|
|
39
|
+
@max_entries = max_entries || (ENV["TINA4_CACHE_MAX_ENTRIES"] ? ENV["TINA4_CACHE_MAX_ENTRIES"].to_i : 1000)
|
|
26
40
|
@status_codes = status_codes
|
|
41
|
+
@backend_name = backend || ENV.fetch("TINA4_CACHE_BACKEND", "memory").downcase.strip
|
|
42
|
+
@cache_url = cache_url || ENV.fetch("TINA4_CACHE_URL", "redis://localhost:6379")
|
|
43
|
+
@cache_dir = cache_dir || ENV.fetch("TINA4_CACHE_DIR", "data/cache")
|
|
27
44
|
@store = {}
|
|
28
45
|
@mutex = Mutex.new
|
|
46
|
+
@hits = 0
|
|
47
|
+
@misses = 0
|
|
48
|
+
|
|
49
|
+
# Initialize backend
|
|
50
|
+
init_backend
|
|
29
51
|
end
|
|
30
52
|
|
|
31
53
|
# Check if caching is enabled.
|
|
@@ -54,16 +76,36 @@ module Tina4
|
|
|
54
76
|
return nil unless method == "GET"
|
|
55
77
|
|
|
56
78
|
key = cache_key(method, url)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
79
|
+
entry = backend_get(key)
|
|
80
|
+
|
|
81
|
+
if entry.nil?
|
|
82
|
+
@mutex.synchronize { @misses += 1 }
|
|
83
|
+
return nil
|
|
84
|
+
end
|
|
60
85
|
|
|
86
|
+
# For memory backend, entry is a CacheEntry; for others, reconstruct
|
|
87
|
+
if entry.is_a?(CacheEntry)
|
|
61
88
|
if Time.now.to_f > entry.expires_at
|
|
62
|
-
|
|
89
|
+
backend_delete(key)
|
|
90
|
+
@mutex.synchronize { @misses += 1 }
|
|
63
91
|
return nil
|
|
64
92
|
end
|
|
65
|
-
|
|
93
|
+
@mutex.synchronize { @hits += 1 }
|
|
66
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
|
+
)
|
|
67
109
|
end
|
|
68
110
|
end
|
|
69
111
|
|
|
@@ -84,51 +126,426 @@ module Tina4
|
|
|
84
126
|
key = cache_key(method, url)
|
|
85
127
|
expires_at = Time.now.to_f + effective_ttl
|
|
86
128
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
129
|
+
entry_data = {
|
|
130
|
+
"body" => body,
|
|
131
|
+
"content_type" => content_type,
|
|
132
|
+
"status_code" => status_code,
|
|
133
|
+
"expires_at" => expires_at
|
|
134
|
+
}
|
|
135
|
+
|
|
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)
|
|
92
144
|
end
|
|
145
|
+
else
|
|
146
|
+
backend_set(key, entry_data, effective_ttl)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# ── Direct Cache API (same across all 4 languages) ──────────
|
|
93
151
|
|
|
94
|
-
|
|
152
|
+
# Get a value from the cache by key.
|
|
153
|
+
#
|
|
154
|
+
# @param key [String]
|
|
155
|
+
# @return [Object, nil]
|
|
156
|
+
def cache_get(key)
|
|
157
|
+
full_key = "direct:#{key}"
|
|
158
|
+
raw = backend_get(full_key)
|
|
159
|
+
|
|
160
|
+
if raw.nil?
|
|
161
|
+
@mutex.synchronize { @misses += 1 }
|
|
162
|
+
return nil
|
|
95
163
|
end
|
|
164
|
+
|
|
165
|
+
if raw.is_a?(Hash)
|
|
166
|
+
expires_at = raw["expires_at"] || raw[:expires_at] || 0
|
|
167
|
+
if expires_at > 0 && Time.now.to_f > expires_at
|
|
168
|
+
backend_delete(full_key)
|
|
169
|
+
@mutex.synchronize { @misses += 1 }
|
|
170
|
+
return nil
|
|
171
|
+
end
|
|
172
|
+
@mutex.synchronize { @hits += 1 }
|
|
173
|
+
raw["value"] || raw[:value]
|
|
174
|
+
else
|
|
175
|
+
@mutex.synchronize { @hits += 1 }
|
|
176
|
+
raw
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Store a value in the cache with optional TTL.
|
|
181
|
+
#
|
|
182
|
+
# @param key [String]
|
|
183
|
+
# @param value [Object]
|
|
184
|
+
# @param ttl [Integer] TTL in seconds (0 uses default)
|
|
185
|
+
def cache_set(key, value, ttl: 0)
|
|
186
|
+
effective_ttl = ttl > 0 ? ttl : @ttl
|
|
187
|
+
effective_ttl = 60 if effective_ttl <= 0 # fallback for direct API
|
|
188
|
+
full_key = "direct:#{key}"
|
|
189
|
+
entry = {
|
|
190
|
+
"value" => value,
|
|
191
|
+
"expires_at" => Time.now.to_f + effective_ttl
|
|
192
|
+
}
|
|
193
|
+
backend_set(full_key, entry, effective_ttl)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Delete a key from the cache.
|
|
197
|
+
#
|
|
198
|
+
# @param key [String]
|
|
199
|
+
# @return [Boolean]
|
|
200
|
+
def cache_delete(key)
|
|
201
|
+
backend_delete("direct:#{key}")
|
|
96
202
|
end
|
|
97
203
|
|
|
98
204
|
# Get cache statistics.
|
|
99
205
|
#
|
|
100
|
-
# @return [Hash] with :size
|
|
206
|
+
# @return [Hash] with :hits, :misses, :size, :backend, :keys
|
|
101
207
|
def cache_stats
|
|
102
208
|
@mutex.synchronize do
|
|
103
|
-
|
|
209
|
+
case @backend_name
|
|
210
|
+
when "memory"
|
|
211
|
+
now = Time.now.to_f
|
|
212
|
+
@store.reject! { |_k, v| v.is_a?(CacheEntry) && now > v.expires_at }
|
|
213
|
+
{ hits: @hits, misses: @misses, size: @store.size, backend: @backend_name, keys: @store.keys.dup }
|
|
214
|
+
when "file"
|
|
215
|
+
sweep
|
|
216
|
+
files = Dir.glob(File.join(@cache_dir, "*.json"))
|
|
217
|
+
{ hits: @hits, misses: @misses, size: files.size, backend: @backend_name, keys: [] }
|
|
218
|
+
when "redis"
|
|
219
|
+
size = 0
|
|
220
|
+
if @redis_client
|
|
221
|
+
begin
|
|
222
|
+
keys = @redis_client.keys("tina4:cache:*")
|
|
223
|
+
size = keys.size
|
|
224
|
+
rescue StandardError
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
{ hits: @hits, misses: @misses, size: size, backend: @backend_name, keys: [] }
|
|
228
|
+
else
|
|
229
|
+
{ hits: @hits, misses: @misses, size: @store.size, backend: @backend_name, keys: @store.keys.dup }
|
|
230
|
+
end
|
|
104
231
|
end
|
|
105
232
|
end
|
|
106
233
|
|
|
107
234
|
# Clear all cached responses.
|
|
108
235
|
def clear_cache
|
|
109
|
-
@mutex.synchronize
|
|
236
|
+
@mutex.synchronize do
|
|
237
|
+
@hits = 0
|
|
238
|
+
@misses = 0
|
|
239
|
+
|
|
240
|
+
case @backend_name
|
|
241
|
+
when "memory"
|
|
242
|
+
@store.clear
|
|
243
|
+
when "file"
|
|
244
|
+
Dir.glob(File.join(@cache_dir, "*.json")).each { |f| File.delete(f) rescue nil }
|
|
245
|
+
when "redis"
|
|
246
|
+
if @redis_client
|
|
247
|
+
begin
|
|
248
|
+
keys = @redis_client.keys("tina4:cache:*")
|
|
249
|
+
@redis_client.del(*keys) unless keys.empty?
|
|
250
|
+
rescue StandardError
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
110
255
|
end
|
|
111
256
|
|
|
112
257
|
# Remove expired entries.
|
|
113
258
|
#
|
|
114
259
|
# @return [Integer] number of entries removed
|
|
115
260
|
def sweep
|
|
116
|
-
@
|
|
261
|
+
case @backend_name
|
|
262
|
+
when "memory"
|
|
263
|
+
@mutex.synchronize do
|
|
264
|
+
now = Time.now.to_f
|
|
265
|
+
keys_to_remove = @store.select { |_k, v| v.is_a?(CacheEntry) && now > v.expires_at }.keys
|
|
266
|
+
keys_to_remove += @store.select { |_k, v| v.is_a?(Hash) && (v["expires_at"] || 0) > 0 && now > (v["expires_at"] || 0) }.keys
|
|
267
|
+
keys_to_remove.each { |k| @store.delete(k) }
|
|
268
|
+
keys_to_remove.size
|
|
269
|
+
end
|
|
270
|
+
when "file"
|
|
271
|
+
removed = 0
|
|
117
272
|
now = Time.now.to_f
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
273
|
+
Dir.glob(File.join(@cache_dir, "*.json")).each do |f|
|
|
274
|
+
begin
|
|
275
|
+
data = JSON.parse(File.read(f))
|
|
276
|
+
if data["expires_at"] && now > data["expires_at"]
|
|
277
|
+
File.delete(f)
|
|
278
|
+
removed += 1
|
|
279
|
+
end
|
|
280
|
+
rescue StandardError
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
removed
|
|
284
|
+
else
|
|
285
|
+
0
|
|
121
286
|
end
|
|
122
287
|
end
|
|
123
288
|
|
|
124
289
|
# Current TTL setting.
|
|
125
|
-
#
|
|
126
|
-
# @return [Integer]
|
|
127
290
|
attr_reader :ttl
|
|
128
291
|
|
|
129
292
|
# Maximum entries setting.
|
|
130
|
-
#
|
|
131
|
-
# @return [Integer]
|
|
132
293
|
attr_reader :max_entries
|
|
294
|
+
|
|
295
|
+
# Active backend name.
|
|
296
|
+
def backend_name
|
|
297
|
+
@backend_name
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
private
|
|
301
|
+
|
|
302
|
+
# ── Backend initialization ─────────────────────────────────
|
|
303
|
+
|
|
304
|
+
def init_backend
|
|
305
|
+
case @backend_name
|
|
306
|
+
when "redis"
|
|
307
|
+
init_redis
|
|
308
|
+
when "file"
|
|
309
|
+
init_file_dir
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def init_redis
|
|
314
|
+
@redis_client = nil
|
|
315
|
+
begin
|
|
316
|
+
require "redis"
|
|
317
|
+
parsed = parse_redis_url(@cache_url)
|
|
318
|
+
@redis_client = Redis.new(host: parsed[:host], port: parsed[:port], db: parsed[:db], timeout: 5)
|
|
319
|
+
@redis_client.ping
|
|
320
|
+
rescue LoadError, StandardError
|
|
321
|
+
@redis_client = nil
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def parse_redis_url(url)
|
|
326
|
+
cleaned = url.sub(%r{^redis://}, "")
|
|
327
|
+
parts = cleaned.split(":")
|
|
328
|
+
host = parts[0].empty? ? "localhost" : parts[0]
|
|
329
|
+
port_and_db = parts[1] ? parts[1].split("/") : ["6379"]
|
|
330
|
+
port = port_and_db[0].to_i
|
|
331
|
+
port = 6379 if port == 0
|
|
332
|
+
db = port_and_db[1] ? port_and_db[1].to_i : 0
|
|
333
|
+
{ host: host, port: port, db: db }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def init_file_dir
|
|
337
|
+
require "json"
|
|
338
|
+
require "fileutils"
|
|
339
|
+
FileUtils.mkdir_p(@cache_dir)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# ── Backend operations ─────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
def backend_get(key)
|
|
345
|
+
case @backend_name
|
|
346
|
+
when "memory"
|
|
347
|
+
@mutex.synchronize { @store[key] }
|
|
348
|
+
when "redis"
|
|
349
|
+
redis_get(key)
|
|
350
|
+
when "file"
|
|
351
|
+
file_get(key)
|
|
352
|
+
else
|
|
353
|
+
@mutex.synchronize { @store[key] }
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def backend_set(key, entry, ttl)
|
|
358
|
+
case @backend_name
|
|
359
|
+
when "memory"
|
|
360
|
+
@mutex.synchronize do
|
|
361
|
+
if @store.size >= @max_entries && !@store.key?(key)
|
|
362
|
+
oldest_key = @store.keys.first
|
|
363
|
+
@store.delete(oldest_key)
|
|
364
|
+
end
|
|
365
|
+
@store[key] = entry
|
|
366
|
+
end
|
|
367
|
+
when "redis"
|
|
368
|
+
redis_set(key, entry, ttl)
|
|
369
|
+
when "file"
|
|
370
|
+
file_set(key, entry)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def backend_delete(key)
|
|
375
|
+
case @backend_name
|
|
376
|
+
when "memory"
|
|
377
|
+
@mutex.synchronize do
|
|
378
|
+
!@store.delete(key).nil?
|
|
379
|
+
end
|
|
380
|
+
when "redis"
|
|
381
|
+
redis_delete(key)
|
|
382
|
+
when "file"
|
|
383
|
+
file_delete(key)
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# ── Redis operations ───────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
def redis_get(key)
|
|
390
|
+
full_key = "tina4:cache:#{key}"
|
|
391
|
+
if @redis_client
|
|
392
|
+
begin
|
|
393
|
+
raw = @redis_client.get(full_key)
|
|
394
|
+
return nil if raw.nil?
|
|
395
|
+
JSON.parse(raw)
|
|
396
|
+
rescue StandardError
|
|
397
|
+
nil
|
|
398
|
+
end
|
|
399
|
+
else
|
|
400
|
+
resp_get(full_key)
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def redis_set(key, entry, ttl)
|
|
405
|
+
full_key = "tina4:cache:#{key}"
|
|
406
|
+
serialized = JSON.generate(entry)
|
|
407
|
+
if @redis_client
|
|
408
|
+
begin
|
|
409
|
+
if ttl > 0
|
|
410
|
+
@redis_client.setex(full_key, ttl, serialized)
|
|
411
|
+
else
|
|
412
|
+
@redis_client.set(full_key, serialized)
|
|
413
|
+
end
|
|
414
|
+
rescue StandardError
|
|
415
|
+
end
|
|
416
|
+
else
|
|
417
|
+
if ttl > 0
|
|
418
|
+
resp_command("SETEX", full_key, ttl.to_s, serialized)
|
|
419
|
+
else
|
|
420
|
+
resp_command("SET", full_key, serialized)
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def redis_delete(key)
|
|
426
|
+
full_key = "tina4:cache:#{key}"
|
|
427
|
+
if @redis_client
|
|
428
|
+
begin
|
|
429
|
+
@redis_client.del(full_key) > 0
|
|
430
|
+
rescue StandardError
|
|
431
|
+
false
|
|
432
|
+
end
|
|
433
|
+
else
|
|
434
|
+
result = resp_command("DEL", full_key)
|
|
435
|
+
result == "1"
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def resp_get(key)
|
|
440
|
+
result = resp_command("GET", key)
|
|
441
|
+
return nil if result.nil?
|
|
442
|
+
JSON.parse(result) rescue nil
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def resp_command(*args)
|
|
446
|
+
parsed = parse_redis_url(@cache_url)
|
|
447
|
+
cmd = "*#{args.size}\r\n"
|
|
448
|
+
args.each { |arg| s = arg.to_s; cmd += "$#{s.bytesize}\r\n#{s}\r\n" }
|
|
449
|
+
|
|
450
|
+
sock = TCPSocket.new(parsed[:host], parsed[:port])
|
|
451
|
+
sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [5, 0].pack("l_2"))
|
|
452
|
+
if parsed[:db] > 0
|
|
453
|
+
select_cmd = "*2\r\n$6\r\nSELECT\r\n$#{parsed[:db].to_s.bytesize}\r\n#{parsed[:db]}\r\n"
|
|
454
|
+
sock.write(select_cmd)
|
|
455
|
+
sock.recv(1024)
|
|
456
|
+
end
|
|
457
|
+
sock.write(cmd)
|
|
458
|
+
response = sock.recv(65536)
|
|
459
|
+
sock.close
|
|
460
|
+
|
|
461
|
+
if response.start_with?("+")
|
|
462
|
+
response[1..].strip
|
|
463
|
+
elsif response.start_with?("$-1")
|
|
464
|
+
nil
|
|
465
|
+
elsif response.start_with?("$")
|
|
466
|
+
lines = response.split("\r\n")
|
|
467
|
+
lines[1]
|
|
468
|
+
elsif response.start_with?(":")
|
|
469
|
+
response[1..].strip
|
|
470
|
+
else
|
|
471
|
+
nil
|
|
472
|
+
end
|
|
473
|
+
rescue StandardError
|
|
474
|
+
nil
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# ── File operations ────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
def file_key_path(key)
|
|
480
|
+
require "digest"
|
|
481
|
+
safe = Digest::SHA256.hexdigest(key)
|
|
482
|
+
File.join(@cache_dir, "#{safe}.json")
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def file_get(key)
|
|
486
|
+
path = file_key_path(key)
|
|
487
|
+
return nil unless File.exist?(path)
|
|
488
|
+
begin
|
|
489
|
+
data = JSON.parse(File.read(path))
|
|
490
|
+
if data["expires_at"] && Time.now.to_f > data["expires_at"]
|
|
491
|
+
File.delete(path) rescue nil
|
|
492
|
+
return nil
|
|
493
|
+
end
|
|
494
|
+
data
|
|
495
|
+
rescue StandardError
|
|
496
|
+
nil
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def file_set(key, entry)
|
|
501
|
+
init_file_dir
|
|
502
|
+
files = Dir.glob(File.join(@cache_dir, "*.json")).sort_by { |f| File.mtime(f) }
|
|
503
|
+
while files.size >= @max_entries
|
|
504
|
+
File.delete(files.shift) rescue nil
|
|
505
|
+
end
|
|
506
|
+
path = file_key_path(key)
|
|
507
|
+
File.write(path, JSON.generate(entry))
|
|
508
|
+
rescue StandardError
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def file_delete(key)
|
|
512
|
+
path = file_key_path(key)
|
|
513
|
+
if File.exist?(path)
|
|
514
|
+
File.delete(path) rescue nil
|
|
515
|
+
true
|
|
516
|
+
else
|
|
517
|
+
false
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# ── Module-level convenience (singleton) ───────────────────────
|
|
523
|
+
|
|
524
|
+
@default_cache = nil
|
|
525
|
+
|
|
526
|
+
class << self
|
|
527
|
+
def cache_instance
|
|
528
|
+
@default_cache ||= ResponseCache.new(ttl: ENV["TINA4_CACHE_TTL"] ? ENV["TINA4_CACHE_TTL"].to_i : 60)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def cache_get(key)
|
|
532
|
+
cache_instance.cache_get(key)
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def cache_set(key, value, ttl: 0)
|
|
536
|
+
cache_instance.cache_set(key, value, ttl: ttl)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def cache_delete(key)
|
|
540
|
+
cache_instance.cache_delete(key)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def cache_clear
|
|
544
|
+
cache_instance.clear_cache
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def cache_stats
|
|
548
|
+
cache_instance.cache_stats
|
|
549
|
+
end
|
|
133
550
|
end
|
|
134
551
|
end
|
data/lib/tina4/router.rb
CHANGED
|
@@ -4,6 +4,7 @@ module Tina4
|
|
|
4
4
|
class Route
|
|
5
5
|
attr_reader :method, :path, :handler, :auth_handler, :swagger_meta,
|
|
6
6
|
:path_regex, :param_names, :middleware, :template
|
|
7
|
+
attr_accessor :auth_required, :cached
|
|
7
8
|
|
|
8
9
|
def initialize(method, path, handler, auth_handler: nil, swagger_meta: {}, middleware: [], template: nil)
|
|
9
10
|
@method = method.to_s.upcase.freeze
|
|
@@ -13,11 +14,34 @@ module Tina4
|
|
|
13
14
|
@swagger_meta = swagger_meta
|
|
14
15
|
@middleware = middleware.freeze
|
|
15
16
|
@template = template&.freeze
|
|
17
|
+
@auth_required = %w[POST PUT PATCH DELETE].include?(@method)
|
|
18
|
+
@cached = false
|
|
16
19
|
@param_names = []
|
|
17
20
|
@path_regex = compile_pattern(@path)
|
|
18
21
|
@param_names.freeze
|
|
19
22
|
end
|
|
20
23
|
|
|
24
|
+
# Mark this route as requiring bearer-token authentication.
|
|
25
|
+
# Returns self for chaining: Router.get("/path") { ... }.secure
|
|
26
|
+
def secure
|
|
27
|
+
@auth_required = true
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Opt out of the secure-by-default auth on write routes.
|
|
32
|
+
# Returns self for chaining: Router.post("/login") { ... }.no_auth
|
|
33
|
+
def no_auth
|
|
34
|
+
@auth_required = false
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Mark this route as cacheable.
|
|
39
|
+
# Returns self for chaining: Router.get("/path") { ... }.cache
|
|
40
|
+
def cache
|
|
41
|
+
@cached = true
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
21
45
|
# Returns params hash if matched, false otherwise
|
|
22
46
|
def match?(request_path, request_method = nil)
|
|
23
47
|
return false if request_method && @method != "ANY" && @method != request_method.to_s.upcase
|
|
@@ -105,12 +129,98 @@ module Tina4
|
|
|
105
129
|
end
|
|
106
130
|
end
|
|
107
131
|
|
|
132
|
+
# A registered WebSocket route with path pattern matching (reuses Route's compile logic)
|
|
133
|
+
class WebSocketRoute
|
|
134
|
+
attr_reader :path, :handler, :path_regex, :param_names
|
|
135
|
+
|
|
136
|
+
def initialize(path, handler)
|
|
137
|
+
@path = normalize_path(path).freeze
|
|
138
|
+
@handler = handler
|
|
139
|
+
@param_names = []
|
|
140
|
+
@path_regex = compile_pattern(@path)
|
|
141
|
+
@param_names.freeze
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Returns params hash if matched, false otherwise
|
|
145
|
+
def match?(request_path)
|
|
146
|
+
match = @path_regex.match(request_path)
|
|
147
|
+
return false unless match
|
|
148
|
+
|
|
149
|
+
if @param_names.empty?
|
|
150
|
+
{}
|
|
151
|
+
else
|
|
152
|
+
params = {}
|
|
153
|
+
@param_names.each_with_index do |param_def, i|
|
|
154
|
+
raw_value = match[i + 1]
|
|
155
|
+
params[param_def[:name]] = raw_value
|
|
156
|
+
end
|
|
157
|
+
params
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def normalize_path(path)
|
|
164
|
+
p = path.to_s.gsub("\\", "/")
|
|
165
|
+
p = "/#{p}" unless p.start_with?("/")
|
|
166
|
+
p = p.chomp("/") unless p == "/"
|
|
167
|
+
p
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def compile_pattern(path)
|
|
171
|
+
return Regexp.new("\\A/\\z") if path == "/"
|
|
172
|
+
|
|
173
|
+
parts = path.split("/").reject(&:empty?)
|
|
174
|
+
regex_parts = parts.map do |part|
|
|
175
|
+
if part =~ /\A\{(\w+)\}\z/
|
|
176
|
+
name = Regexp.last_match(1)
|
|
177
|
+
@param_names << { name: name.to_sym }
|
|
178
|
+
'([^/]+)'
|
|
179
|
+
else
|
|
180
|
+
Regexp.escape(part)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
Regexp.new("\\A/#{regex_parts.join("/")}\\z")
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
108
187
|
module Router
|
|
109
188
|
class << self
|
|
110
189
|
def routes
|
|
111
190
|
@routes ||= []
|
|
112
191
|
end
|
|
113
192
|
|
|
193
|
+
# Registered WebSocket routes
|
|
194
|
+
def ws_routes
|
|
195
|
+
@ws_routes ||= []
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Register a WebSocket route.
|
|
199
|
+
# The handler block receives (connection, event, data) where:
|
|
200
|
+
# connection — WebSocketConnection with #send, #broadcast, #close, #params
|
|
201
|
+
# event — :open, :message, or :close
|
|
202
|
+
# data — String payload for :message, nil for :open/:close
|
|
203
|
+
def websocket(path, &block)
|
|
204
|
+
ws_route = WebSocketRoute.new(path, block)
|
|
205
|
+
ws_routes << ws_route
|
|
206
|
+
Tina4::Log.debug("WebSocket route registered: #{path}")
|
|
207
|
+
ws_route
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Find a matching WebSocket route for a given path.
|
|
211
|
+
# Returns [ws_route, params] or nil.
|
|
212
|
+
def find_ws_route(path)
|
|
213
|
+
normalized = path.gsub("\\", "/")
|
|
214
|
+
normalized = "/#{normalized}" unless normalized.start_with?("/")
|
|
215
|
+
normalized = normalized.chomp("/") unless normalized == "/"
|
|
216
|
+
|
|
217
|
+
ws_routes.each do |ws_route|
|
|
218
|
+
params = ws_route.match?(normalized)
|
|
219
|
+
return [ws_route, params] if params
|
|
220
|
+
end
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
114
224
|
# Routes indexed by HTTP method for O(1) method lookup
|
|
115
225
|
def method_index
|
|
116
226
|
@method_index ||= Hash.new { |h, k| h[k] = [] }
|
|
@@ -169,9 +279,26 @@ module Tina4
|
|
|
169
279
|
nil
|
|
170
280
|
end
|
|
171
281
|
|
|
282
|
+
# Register a class-based middleware globally.
|
|
283
|
+
# The class should define static before_* and/or after_* methods.
|
|
284
|
+
# Example:
|
|
285
|
+
# class AuthMiddleware
|
|
286
|
+
# def self.before_auth(request, response)
|
|
287
|
+
# unless request.headers["authorization"]
|
|
288
|
+
# return [request, response.json({ error: "Unauthorized" }, 401)]
|
|
289
|
+
# end
|
|
290
|
+
# [request, response]
|
|
291
|
+
# end
|
|
292
|
+
# end
|
|
293
|
+
# Tina4::Router.use(AuthMiddleware)
|
|
294
|
+
def use(klass)
|
|
295
|
+
Tina4::Middleware.use(klass)
|
|
296
|
+
end
|
|
297
|
+
|
|
172
298
|
def clear!
|
|
173
299
|
@routes = []
|
|
174
300
|
@method_index = Hash.new { |h, k| h[k] = [] }
|
|
301
|
+
@ws_routes = []
|
|
175
302
|
end
|
|
176
303
|
|
|
177
304
|
def group(prefix, auth_handler: nil, middleware: [], &block)
|