tina4ruby 0.5.2 → 3.0.0
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/CHANGELOG.md +1 -1
- data/README.md +360 -559
- data/exe/{tina4 → tina4ruby} +1 -0
- data/lib/tina4/ai.rb +312 -0
- data/lib/tina4/auth.rb +44 -3
- data/lib/tina4/auto_crud.rb +163 -0
- data/lib/tina4/cli.rb +242 -77
- data/lib/tina4/constants.rb +46 -0
- data/lib/tina4/cors.rb +74 -0
- data/lib/tina4/database/sqlite3_adapter.rb +139 -0
- data/lib/tina4/database.rb +43 -7
- data/lib/tina4/debug.rb +4 -79
- data/lib/tina4/dev_admin.rb +1162 -0
- data/lib/tina4/dev_mailbox.rb +191 -0
- data/lib/tina4/dev_reload.rb +9 -9
- data/lib/tina4/drivers/firebird_driver.rb +19 -3
- data/lib/tina4/drivers/mssql_driver.rb +3 -3
- data/lib/tina4/drivers/mysql_driver.rb +4 -4
- data/lib/tina4/drivers/postgres_driver.rb +9 -2
- data/lib/tina4/drivers/sqlite_driver.rb +1 -1
- data/lib/tina4/env.rb +42 -2
- data/lib/tina4/error_overlay.rb +252 -0
- data/lib/tina4/events.rb +90 -0
- data/lib/tina4/field_types.rb +4 -0
- data/lib/tina4/frond.rb +1336 -0
- data/lib/tina4/gallery/auth/meta.json +1 -0
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
- data/lib/tina4/gallery/database/meta.json +1 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
- data/lib/tina4/gallery/error-overlay/meta.json +1 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
- data/lib/tina4/gallery/orm/meta.json +1 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
- data/lib/tina4/gallery/queue/meta.json +1 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +27 -0
- data/lib/tina4/gallery/rest-api/meta.json +1 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
- data/lib/tina4/gallery/templates/meta.json +1 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
- data/lib/tina4/health.rb +39 -0
- data/lib/tina4/html_element.rb +148 -0
- data/lib/tina4/localization.rb +2 -2
- data/lib/tina4/log.rb +203 -0
- data/lib/tina4/messenger.rb +484 -0
- data/lib/tina4/migration.rb +132 -29
- data/lib/tina4/orm.rb +337 -31
- data/lib/tina4/public/css/tina4.css +178 -1
- data/lib/tina4/public/css/tina4.min.css +1 -2
- data/lib/tina4/public/favicon.ico +0 -0
- data/lib/tina4/public/images/logo.svg +5 -0
- data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
- data/lib/tina4/public/js/frond.min.js +420 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
- data/lib/tina4/public/js/tina4.min.js +93 -0
- data/lib/tina4/public/swagger/index.html +90 -0
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
- data/lib/tina4/queue.rb +40 -4
- data/lib/tina4/queue_backends/lite_backend.rb +88 -0
- data/lib/tina4/rack_app.rb +314 -23
- data/lib/tina4/rate_limiter.rb +123 -0
- data/lib/tina4/request.rb +61 -15
- data/lib/tina4/response.rb +54 -24
- data/lib/tina4/response_cache.rb +134 -0
- data/lib/tina4/router.rb +90 -15
- data/lib/tina4/scss_compiler.rb +2 -2
- data/lib/tina4/seeder.rb +56 -61
- data/lib/tina4/service_runner.rb +303 -0
- data/lib/tina4/session.rb +85 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
- data/lib/tina4/shutdown.rb +84 -0
- data/lib/tina4/sql_translation.rb +295 -0
- data/lib/tina4/template.rb +36 -6
- data/lib/tina4/templates/base.twig +2 -2
- data/lib/tina4/templates/errors/302.twig +14 -0
- data/lib/tina4/templates/errors/401.twig +9 -0
- data/lib/tina4/templates/errors/403.twig +22 -15
- data/lib/tina4/templates/errors/404.twig +22 -15
- data/lib/tina4/templates/errors/500.twig +31 -15
- data/lib/tina4/templates/errors/502.twig +9 -0
- data/lib/tina4/templates/errors/503.twig +12 -0
- data/lib/tina4/templates/errors/base.twig +37 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -18
- data/lib/tina4.rb +57 -21
- metadata +51 -19
- data/lib/tina4/public/js/tina4.js +0 -134
- data/lib/tina4/public/js/tina4helper.js +0 -387
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
class RateLimiter
|
|
5
|
+
DEFAULT_LIMIT = 100
|
|
6
|
+
DEFAULT_WINDOW = 60 # seconds
|
|
7
|
+
|
|
8
|
+
attr_reader :limit, :window
|
|
9
|
+
|
|
10
|
+
def initialize(limit: nil, window: nil)
|
|
11
|
+
@limit = (limit || ENV["TINA4_RATE_LIMIT"] || DEFAULT_LIMIT).to_i
|
|
12
|
+
@window = (window || ENV["TINA4_RATE_WINDOW"] || DEFAULT_WINDOW).to_i
|
|
13
|
+
@store = {} # ip => [timestamps]
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@last_cleanup = Time.now
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Check if the given IP is rate limited.
|
|
19
|
+
# Returns a hash with rate limit info:
|
|
20
|
+
# { allowed: true/false, limit:, remaining:, reset:, retry_after: }
|
|
21
|
+
def check(ip)
|
|
22
|
+
now = Time.now
|
|
23
|
+
cleanup_if_needed(now)
|
|
24
|
+
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
@store[ip] ||= []
|
|
27
|
+
entries = @store[ip]
|
|
28
|
+
|
|
29
|
+
# Remove expired entries (sliding window)
|
|
30
|
+
cutoff = now - @window
|
|
31
|
+
entries.reject! { |t| t < cutoff }
|
|
32
|
+
|
|
33
|
+
if entries.length >= @limit
|
|
34
|
+
# Rate limited
|
|
35
|
+
oldest = entries.first
|
|
36
|
+
reset_at = (oldest + @window).to_i
|
|
37
|
+
retry_after = [(oldest + @window - now).ceil, 1].max
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
allowed: false,
|
|
41
|
+
limit: @limit,
|
|
42
|
+
remaining: 0,
|
|
43
|
+
reset: reset_at,
|
|
44
|
+
retry_after: retry_after
|
|
45
|
+
}
|
|
46
|
+
else
|
|
47
|
+
entries << now
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
allowed: true,
|
|
51
|
+
limit: @limit,
|
|
52
|
+
remaining: @limit - entries.length,
|
|
53
|
+
reset: (now + @window).to_i,
|
|
54
|
+
retry_after: nil
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Convenience predicate
|
|
61
|
+
def rate_limited?(ip)
|
|
62
|
+
!check(ip)[:allowed]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Apply rate limit headers to a response object and return 429 if exceeded.
|
|
66
|
+
# Returns [status, headers_hash] or nil if allowed.
|
|
67
|
+
def apply(ip, response)
|
|
68
|
+
result = check(ip)
|
|
69
|
+
|
|
70
|
+
# Always set rate limit headers
|
|
71
|
+
response.headers["X-RateLimit-Limit"] = result[:limit].to_s
|
|
72
|
+
response.headers["X-RateLimit-Remaining"] = result[:remaining].to_s
|
|
73
|
+
response.headers["X-RateLimit-Reset"] = result[:reset].to_s
|
|
74
|
+
|
|
75
|
+
unless result[:allowed]
|
|
76
|
+
response.headers["Retry-After"] = result[:retry_after].to_s
|
|
77
|
+
response.status_code = 429
|
|
78
|
+
response.headers["content-type"] = "application/json; charset=utf-8"
|
|
79
|
+
response.body = JSON.generate({
|
|
80
|
+
error: "Too Many Requests",
|
|
81
|
+
retry_after: result[:retry_after]
|
|
82
|
+
})
|
|
83
|
+
return false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Reset tracking for a specific IP (useful for testing)
|
|
90
|
+
def reset!(ip = nil)
|
|
91
|
+
@mutex.synchronize do
|
|
92
|
+
if ip
|
|
93
|
+
@store.delete(ip)
|
|
94
|
+
else
|
|
95
|
+
@store.clear
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Returns current entry count (for monitoring)
|
|
101
|
+
def entry_count
|
|
102
|
+
@mutex.synchronize { @store.length }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Clean up expired entries periodically (every window interval)
|
|
108
|
+
def cleanup_if_needed(now)
|
|
109
|
+
return if now - @last_cleanup < @window
|
|
110
|
+
|
|
111
|
+
@mutex.synchronize do
|
|
112
|
+
return if now - @last_cleanup < @window
|
|
113
|
+
|
|
114
|
+
cutoff = now - @window
|
|
115
|
+
@store.delete_if do |_ip, entries|
|
|
116
|
+
entries.reject! { |t| t < cutoff }
|
|
117
|
+
entries.empty?
|
|
118
|
+
end
|
|
119
|
+
@last_cleanup = now
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/tina4/request.rb
CHANGED
|
@@ -13,20 +13,36 @@ module Tina4
|
|
|
13
13
|
@path = env["PATH_INFO"] || "/"
|
|
14
14
|
@query_string = env["QUERY_STRING"] || ""
|
|
15
15
|
@content_type = env["CONTENT_TYPE"] || ""
|
|
16
|
-
@ip = env["REMOTE_ADDR"] || "127.0.0.1"
|
|
17
16
|
@path_params = path_params
|
|
18
17
|
|
|
18
|
+
# Client IP with X-Forwarded-For support
|
|
19
|
+
@ip = extract_client_ip
|
|
20
|
+
|
|
19
21
|
# Lazy-initialized fields (nil = not yet computed)
|
|
20
22
|
@headers = nil
|
|
21
23
|
@cookies = nil
|
|
22
24
|
@session = nil
|
|
23
|
-
@
|
|
25
|
+
@body_raw = nil
|
|
24
26
|
@params = nil
|
|
25
27
|
@files = nil
|
|
26
28
|
@json_body = nil
|
|
29
|
+
@query_hash = nil
|
|
30
|
+
@body_parsed = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Full URL reconstruction
|
|
34
|
+
def url
|
|
35
|
+
scheme = env["rack.url_scheme"] || "http"
|
|
36
|
+
host = env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
|
|
37
|
+
port = env["SERVER_PORT"]
|
|
38
|
+
url_str = "#{scheme}://#{host}"
|
|
39
|
+
url_str += ":#{port}" if port && port != "80" && port != "443"
|
|
40
|
+
url_str += @path
|
|
41
|
+
url_str += "?#{@query_string}" unless @query_string.empty?
|
|
42
|
+
url_str
|
|
27
43
|
end
|
|
28
44
|
|
|
29
|
-
# Lazy accessors
|
|
45
|
+
# Lazy accessors
|
|
30
46
|
def headers
|
|
31
47
|
@headers ||= extract_headers
|
|
32
48
|
end
|
|
@@ -39,14 +55,26 @@ module Tina4
|
|
|
39
55
|
@session ||= @env["tina4.session"] || {}
|
|
40
56
|
end
|
|
41
57
|
|
|
58
|
+
# Raw body string
|
|
42
59
|
def body
|
|
43
|
-
@
|
|
60
|
+
@body_raw ||= read_body
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Parsed body (JSON or form data)
|
|
64
|
+
def body_parsed
|
|
65
|
+
@body_parsed ||= parse_body
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Parsed query string as hash
|
|
69
|
+
def query
|
|
70
|
+
@query_hash ||= parse_query_to_hash(@query_string)
|
|
44
71
|
end
|
|
45
72
|
|
|
46
73
|
def files
|
|
47
74
|
@files ||= extract_files
|
|
48
75
|
end
|
|
49
76
|
|
|
77
|
+
# Merged params: query + body + path_params (path_params highest priority)
|
|
50
78
|
def params
|
|
51
79
|
@params ||= build_params
|
|
52
80
|
end
|
|
@@ -74,11 +102,22 @@ module Tina4
|
|
|
74
102
|
|
|
75
103
|
private
|
|
76
104
|
|
|
105
|
+
def extract_client_ip
|
|
106
|
+
# Check X-Forwarded-For first (proxy/load balancer)
|
|
107
|
+
forwarded = @env["HTTP_X_FORWARDED_FOR"]
|
|
108
|
+
if forwarded && !forwarded.empty?
|
|
109
|
+
# Take the first (original client) IP
|
|
110
|
+
forwarded.split(",").first.strip
|
|
111
|
+
else
|
|
112
|
+
@env["HTTP_X_REAL_IP"] || @env["REMOTE_ADDR"] || "127.0.0.1"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
77
116
|
def extract_headers
|
|
78
117
|
h = {}
|
|
79
118
|
@env.each do |key, value|
|
|
80
119
|
if key.start_with?("HTTP_")
|
|
81
|
-
h[key[5
|
|
120
|
+
h[key[5..-1].downcase] = value
|
|
82
121
|
end
|
|
83
122
|
end
|
|
84
123
|
h
|
|
@@ -105,31 +144,38 @@ module Tina4
|
|
|
105
144
|
data
|
|
106
145
|
end
|
|
107
146
|
|
|
147
|
+
def parse_body
|
|
148
|
+
if @content_type.include?("application/json")
|
|
149
|
+
json_body
|
|
150
|
+
elsif @content_type.include?("application/x-www-form-urlencoded")
|
|
151
|
+
parse_query_to_hash(body)
|
|
152
|
+
else
|
|
153
|
+
{}
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
108
157
|
def build_params
|
|
109
158
|
p = {}
|
|
110
159
|
|
|
111
160
|
# Query string params
|
|
112
|
-
|
|
161
|
+
query.each { |k, v| p[k] = v }
|
|
113
162
|
|
|
114
163
|
# Body params
|
|
115
|
-
|
|
116
|
-
json_body.each { |k, v| p[k.to_s] = v }
|
|
117
|
-
elsif @content_type.include?("application/x-www-form-urlencoded")
|
|
118
|
-
parse_query_string(body, p)
|
|
119
|
-
end
|
|
164
|
+
body_parsed.each { |k, v| p[k.to_s] = v }
|
|
120
165
|
|
|
121
166
|
# Path params (highest priority)
|
|
122
167
|
@path_params.each { |k, v| p[k.to_s] = v }
|
|
123
168
|
p
|
|
124
169
|
end
|
|
125
170
|
|
|
126
|
-
def
|
|
127
|
-
|
|
171
|
+
def parse_query_to_hash(qs)
|
|
172
|
+
result = {}
|
|
173
|
+
return result if qs.nil? || qs.empty?
|
|
128
174
|
qs.split("&").each do |pair|
|
|
129
175
|
key, value = pair.split("=", 2)
|
|
130
|
-
|
|
176
|
+
result[URI.decode_www_form_component(key.to_s)] = URI.decode_www_form_component(value.to_s)
|
|
131
177
|
end
|
|
132
|
-
|
|
178
|
+
result
|
|
133
179
|
end
|
|
134
180
|
|
|
135
181
|
def extract_files
|
data/lib/tina4/response.rb
CHANGED
|
@@ -26,53 +26,63 @@ module Tina4
|
|
|
26
26
|
TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
|
|
27
27
|
XML_CONTENT_TYPE = "application/xml; charset=utf-8"
|
|
28
28
|
|
|
29
|
-
attr_accessor :
|
|
29
|
+
attr_accessor :status_code, :headers, :body, :cookies
|
|
30
30
|
|
|
31
31
|
def initialize
|
|
32
|
-
@
|
|
32
|
+
@status_code = 200
|
|
33
33
|
@headers = { "content-type" => HTML_CONTENT_TYPE }
|
|
34
34
|
@body = ""
|
|
35
|
-
@cookies = nil # Lazy
|
|
35
|
+
@cookies = nil # Lazy -- most responses have no cookies
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Chainable status setter
|
|
39
|
+
def status(code = nil)
|
|
40
|
+
if code.nil?
|
|
41
|
+
@status_code
|
|
42
|
+
else
|
|
43
|
+
@status_code = code
|
|
44
|
+
self
|
|
45
|
+
end
|
|
36
46
|
end
|
|
37
47
|
|
|
38
48
|
def json(data, status_or_opts = nil, status: nil)
|
|
39
|
-
@
|
|
49
|
+
@status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
|
|
40
50
|
@headers["content-type"] = JSON_CONTENT_TYPE
|
|
41
51
|
@body = data.is_a?(String) ? data : JSON.generate(data)
|
|
42
52
|
self
|
|
43
53
|
end
|
|
44
54
|
|
|
45
55
|
def html(content, status_or_opts = nil, status: nil)
|
|
46
|
-
@
|
|
56
|
+
@status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
|
|
47
57
|
@headers["content-type"] = HTML_CONTENT_TYPE
|
|
48
58
|
@body = content.to_s
|
|
49
59
|
self
|
|
50
60
|
end
|
|
51
61
|
|
|
52
62
|
def text(content, status_or_opts = nil, status: nil)
|
|
53
|
-
@
|
|
63
|
+
@status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
|
|
54
64
|
@headers["content-type"] = TEXT_CONTENT_TYPE
|
|
55
65
|
@body = content.to_s
|
|
56
66
|
self
|
|
57
67
|
end
|
|
58
68
|
|
|
59
69
|
def xml(content, status: 200)
|
|
60
|
-
@
|
|
70
|
+
@status_code = status
|
|
61
71
|
@headers["content-type"] = XML_CONTENT_TYPE
|
|
62
72
|
@body = content.to_s
|
|
63
73
|
self
|
|
64
74
|
end
|
|
65
75
|
|
|
66
76
|
def csv(content, filename: "export.csv", status: 200)
|
|
67
|
-
@
|
|
77
|
+
@status_code = status
|
|
68
78
|
@headers["content-type"] = "text/csv"
|
|
69
79
|
@headers["content-disposition"] = "attachment; filename=\"#{filename}\""
|
|
70
80
|
@body = content.to_s
|
|
71
81
|
self
|
|
72
82
|
end
|
|
73
83
|
|
|
74
|
-
def redirect(url, status:
|
|
75
|
-
@
|
|
84
|
+
def redirect(url, status_or_opts = nil, status: nil)
|
|
85
|
+
@status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 302)
|
|
76
86
|
@headers["location"] = url
|
|
77
87
|
@body = ""
|
|
78
88
|
self
|
|
@@ -80,7 +90,7 @@ module Tina4
|
|
|
80
90
|
|
|
81
91
|
def file(path, content_type: nil, download: false)
|
|
82
92
|
unless ::File.exist?(path)
|
|
83
|
-
@
|
|
93
|
+
@status_code = 404
|
|
84
94
|
@body = "File not found"
|
|
85
95
|
return self
|
|
86
96
|
end
|
|
@@ -94,22 +104,37 @@ module Tina4
|
|
|
94
104
|
end
|
|
95
105
|
|
|
96
106
|
def render(template_path, data = {}, status: 200)
|
|
97
|
-
@
|
|
107
|
+
@status_code = status
|
|
98
108
|
@headers["content-type"] = HTML_CONTENT_TYPE
|
|
99
109
|
@body = Tina4::Template.render(template_path, data)
|
|
100
110
|
self
|
|
101
111
|
end
|
|
102
112
|
|
|
113
|
+
# Chainable header setter
|
|
114
|
+
def header(name, value = nil)
|
|
115
|
+
if value.nil?
|
|
116
|
+
@headers[name]
|
|
117
|
+
else
|
|
118
|
+
@headers[name] = value
|
|
119
|
+
self
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Chainable cookie setter
|
|
124
|
+
def cookie(name, value, opts = {})
|
|
125
|
+
set_cookie(name, value, opts)
|
|
126
|
+
end
|
|
127
|
+
|
|
103
128
|
def set_cookie(name, value, opts = {})
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
129
|
+
cookie_str = "#{name}=#{URI.encode_www_form_component(value)}"
|
|
130
|
+
cookie_str += "; Path=#{opts[:path] || '/'}"
|
|
131
|
+
cookie_str += "; HttpOnly" if opts.fetch(:http_only, true)
|
|
132
|
+
cookie_str += "; Secure" if opts[:secure]
|
|
133
|
+
cookie_str += "; SameSite=#{opts[:same_site] || 'Lax'}"
|
|
134
|
+
cookie_str += "; Max-Age=#{opts[:max_age]}" if opts[:max_age]
|
|
135
|
+
cookie_str += "; Expires=#{opts[:expires].httpdate}" if opts[:expires]
|
|
111
136
|
@cookies ||= []
|
|
112
|
-
@cookies <<
|
|
137
|
+
@cookies << cookie_str
|
|
113
138
|
self
|
|
114
139
|
end
|
|
115
140
|
|
|
@@ -132,16 +157,21 @@ module Tina4
|
|
|
132
157
|
self
|
|
133
158
|
end
|
|
134
159
|
|
|
160
|
+
# Flush / finalize -- alias for to_rack for semantic clarity
|
|
161
|
+
def send
|
|
162
|
+
to_rack
|
|
163
|
+
end
|
|
164
|
+
|
|
135
165
|
def to_rack
|
|
136
166
|
# Fast path: no cookies (99% of API responses)
|
|
137
167
|
if @cookies.nil? || @cookies.empty?
|
|
138
|
-
return [@
|
|
168
|
+
return [@status_code, @headers, [@body.to_s]]
|
|
139
169
|
end
|
|
140
170
|
|
|
141
171
|
# Cookie path
|
|
142
172
|
final_headers = @headers.dup
|
|
143
173
|
final_headers["set-cookie"] = @cookies.join("\n")
|
|
144
|
-
[@
|
|
174
|
+
[@status_code, final_headers, [@body.to_s]]
|
|
145
175
|
end
|
|
146
176
|
|
|
147
177
|
def self.auto_detect(result, response)
|
|
@@ -157,11 +187,11 @@ module Tina4
|
|
|
157
187
|
response.text(result)
|
|
158
188
|
end
|
|
159
189
|
when Integer
|
|
160
|
-
response.
|
|
190
|
+
response.status_code = result
|
|
161
191
|
response.body = ""
|
|
162
192
|
response
|
|
163
193
|
when NilClass
|
|
164
|
-
response.
|
|
194
|
+
response.status_code = 204
|
|
165
195
|
response.body = ""
|
|
166
196
|
response
|
|
167
197
|
else
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
# In-memory response cache for GET requests.
|
|
5
|
+
#
|
|
6
|
+
# Caches serialized responses by method + URL.
|
|
7
|
+
# Designed to be used as Rack middleware or integrated into the Tina4 middleware chain.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# cache = Tina4::ResponseCache.new(ttl: 60, max_entries: 1000)
|
|
11
|
+
# cache.cache_response("GET", "/api/users", 200, "application/json", '{"users":[]}')
|
|
12
|
+
# hit = cache.get("GET", "/api/users")
|
|
13
|
+
#
|
|
14
|
+
# Environment:
|
|
15
|
+
# TINA4_CACHE_TTL -- default TTL in seconds (default: 0 = disabled)
|
|
16
|
+
#
|
|
17
|
+
class ResponseCache
|
|
18
|
+
CacheEntry = Struct.new(:body, :content_type, :status_code, :expires_at)
|
|
19
|
+
|
|
20
|
+
# @param ttl [Integer] default TTL in seconds (0 = disabled)
|
|
21
|
+
# @param max_entries [Integer] maximum cache entries
|
|
22
|
+
# @param status_codes [Array<Integer>] only cache these status codes
|
|
23
|
+
def initialize(ttl: nil, max_entries: 1000, status_codes: [200])
|
|
24
|
+
@ttl = ttl || (ENV["TINA4_CACHE_TTL"] ? ENV["TINA4_CACHE_TTL"].to_i : 0)
|
|
25
|
+
@max_entries = max_entries
|
|
26
|
+
@status_codes = status_codes
|
|
27
|
+
@store = {}
|
|
28
|
+
@mutex = Mutex.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Check if caching is enabled.
|
|
32
|
+
#
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def enabled?
|
|
35
|
+
@ttl > 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Build a cache key from method and URL.
|
|
39
|
+
#
|
|
40
|
+
# @param method [String]
|
|
41
|
+
# @param url [String]
|
|
42
|
+
# @return [String]
|
|
43
|
+
def cache_key(method, url)
|
|
44
|
+
"#{method}:#{url}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Retrieve a cached response. Returns nil on miss or expired entry.
|
|
48
|
+
#
|
|
49
|
+
# @param method [String]
|
|
50
|
+
# @param url [String]
|
|
51
|
+
# @return [CacheEntry, nil]
|
|
52
|
+
def get(method, url)
|
|
53
|
+
return nil unless enabled?
|
|
54
|
+
return nil unless method == "GET"
|
|
55
|
+
|
|
56
|
+
key = cache_key(method, url)
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
entry = @store[key]
|
|
59
|
+
return nil unless entry
|
|
60
|
+
|
|
61
|
+
if Time.now.to_f > entry.expires_at
|
|
62
|
+
@store.delete(key)
|
|
63
|
+
return nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
entry
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Store a response in the cache.
|
|
71
|
+
#
|
|
72
|
+
# @param method [String]
|
|
73
|
+
# @param url [String]
|
|
74
|
+
# @param status_code [Integer]
|
|
75
|
+
# @param content_type [String]
|
|
76
|
+
# @param body [String]
|
|
77
|
+
# @param ttl [Integer, nil] override default TTL
|
|
78
|
+
def cache_response(method, url, status_code, content_type, body, ttl: nil)
|
|
79
|
+
return unless enabled?
|
|
80
|
+
return unless method == "GET"
|
|
81
|
+
return unless @status_codes.include?(status_code)
|
|
82
|
+
|
|
83
|
+
effective_ttl = ttl || @ttl
|
|
84
|
+
key = cache_key(method, url)
|
|
85
|
+
expires_at = Time.now.to_f + effective_ttl
|
|
86
|
+
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
# Evict oldest if at capacity
|
|
89
|
+
if @store.size >= @max_entries && !@store.key?(key)
|
|
90
|
+
oldest_key = @store.keys.first
|
|
91
|
+
@store.delete(oldest_key)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
@store[key] = CacheEntry.new(body, content_type, status_code, expires_at)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get cache statistics.
|
|
99
|
+
#
|
|
100
|
+
# @return [Hash] with :size and :keys
|
|
101
|
+
def cache_stats
|
|
102
|
+
@mutex.synchronize do
|
|
103
|
+
{ size: @store.size, keys: @store.keys.dup }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Clear all cached responses.
|
|
108
|
+
def clear_cache
|
|
109
|
+
@mutex.synchronize { @store.clear }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Remove expired entries.
|
|
113
|
+
#
|
|
114
|
+
# @return [Integer] number of entries removed
|
|
115
|
+
def sweep
|
|
116
|
+
@mutex.synchronize do
|
|
117
|
+
now = Time.now.to_f
|
|
118
|
+
keys_to_remove = @store.select { |_k, v| now > v.expires_at }.keys
|
|
119
|
+
keys_to_remove.each { |k| @store.delete(k) }
|
|
120
|
+
keys_to_remove.size
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Current TTL setting.
|
|
125
|
+
#
|
|
126
|
+
# @return [Integer]
|
|
127
|
+
attr_reader :ttl
|
|
128
|
+
|
|
129
|
+
# Maximum entries setting.
|
|
130
|
+
#
|
|
131
|
+
# @return [Integer]
|
|
132
|
+
attr_reader :max_entries
|
|
133
|
+
end
|
|
134
|
+
end
|