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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +360 -559
  4. data/exe/{tina4 → tina4ruby} +1 -0
  5. data/lib/tina4/ai.rb +312 -0
  6. data/lib/tina4/auth.rb +44 -3
  7. data/lib/tina4/auto_crud.rb +163 -0
  8. data/lib/tina4/cli.rb +242 -77
  9. data/lib/tina4/constants.rb +46 -0
  10. data/lib/tina4/cors.rb +74 -0
  11. data/lib/tina4/database/sqlite3_adapter.rb +139 -0
  12. data/lib/tina4/database.rb +43 -7
  13. data/lib/tina4/debug.rb +4 -79
  14. data/lib/tina4/dev_admin.rb +1162 -0
  15. data/lib/tina4/dev_mailbox.rb +191 -0
  16. data/lib/tina4/dev_reload.rb +9 -9
  17. data/lib/tina4/drivers/firebird_driver.rb +19 -3
  18. data/lib/tina4/drivers/mssql_driver.rb +3 -3
  19. data/lib/tina4/drivers/mysql_driver.rb +4 -4
  20. data/lib/tina4/drivers/postgres_driver.rb +9 -2
  21. data/lib/tina4/drivers/sqlite_driver.rb +1 -1
  22. data/lib/tina4/env.rb +42 -2
  23. data/lib/tina4/error_overlay.rb +252 -0
  24. data/lib/tina4/events.rb +90 -0
  25. data/lib/tina4/field_types.rb +4 -0
  26. data/lib/tina4/frond.rb +1336 -0
  27. data/lib/tina4/gallery/auth/meta.json +1 -0
  28. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
  29. data/lib/tina4/gallery/database/meta.json +1 -0
  30. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
  31. data/lib/tina4/gallery/error-overlay/meta.json +1 -0
  32. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
  33. data/lib/tina4/gallery/orm/meta.json +1 -0
  34. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
  35. data/lib/tina4/gallery/queue/meta.json +1 -0
  36. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +27 -0
  37. data/lib/tina4/gallery/rest-api/meta.json +1 -0
  38. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
  39. data/lib/tina4/gallery/templates/meta.json +1 -0
  40. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
  41. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
  42. data/lib/tina4/health.rb +39 -0
  43. data/lib/tina4/html_element.rb +148 -0
  44. data/lib/tina4/localization.rb +2 -2
  45. data/lib/tina4/log.rb +203 -0
  46. data/lib/tina4/messenger.rb +484 -0
  47. data/lib/tina4/migration.rb +132 -29
  48. data/lib/tina4/orm.rb +337 -31
  49. data/lib/tina4/public/css/tina4.css +178 -1
  50. data/lib/tina4/public/css/tina4.min.css +1 -2
  51. data/lib/tina4/public/favicon.ico +0 -0
  52. data/lib/tina4/public/images/logo.svg +5 -0
  53. data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
  54. data/lib/tina4/public/js/frond.min.js +420 -0
  55. data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
  56. data/lib/tina4/public/js/tina4.min.js +93 -0
  57. data/lib/tina4/public/swagger/index.html +90 -0
  58. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
  59. data/lib/tina4/queue.rb +40 -4
  60. data/lib/tina4/queue_backends/lite_backend.rb +88 -0
  61. data/lib/tina4/rack_app.rb +314 -23
  62. data/lib/tina4/rate_limiter.rb +123 -0
  63. data/lib/tina4/request.rb +61 -15
  64. data/lib/tina4/response.rb +54 -24
  65. data/lib/tina4/response_cache.rb +134 -0
  66. data/lib/tina4/router.rb +90 -15
  67. data/lib/tina4/scss_compiler.rb +2 -2
  68. data/lib/tina4/seeder.rb +56 -61
  69. data/lib/tina4/service_runner.rb +303 -0
  70. data/lib/tina4/session.rb +85 -0
  71. data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
  72. data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
  73. data/lib/tina4/shutdown.rb +84 -0
  74. data/lib/tina4/sql_translation.rb +295 -0
  75. data/lib/tina4/template.rb +36 -6
  76. data/lib/tina4/templates/base.twig +2 -2
  77. data/lib/tina4/templates/errors/302.twig +14 -0
  78. data/lib/tina4/templates/errors/401.twig +9 -0
  79. data/lib/tina4/templates/errors/403.twig +22 -15
  80. data/lib/tina4/templates/errors/404.twig +22 -15
  81. data/lib/tina4/templates/errors/500.twig +31 -15
  82. data/lib/tina4/templates/errors/502.twig +9 -0
  83. data/lib/tina4/templates/errors/503.twig +12 -0
  84. data/lib/tina4/templates/errors/base.twig +37 -0
  85. data/lib/tina4/version.rb +1 -1
  86. data/lib/tina4/webserver.rb +28 -18
  87. data/lib/tina4.rb +57 -21
  88. metadata +51 -19
  89. data/lib/tina4/public/js/tina4.js +0 -134
  90. 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
- @body = nil
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 — only compute when needed
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
- @body ||= read_body
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..].downcase] = value
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
- parse_query_string(@query_string, p) unless @query_string.empty?
161
+ query.each { |k, v| p[k] = v }
113
162
 
114
163
  # Body params
115
- if @content_type.include?("application/json")
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 parse_query_string(qs, target = {})
127
- return target if qs.nil? || qs.empty?
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
- target[URI.decode_www_form_component(key.to_s)] = URI.decode_www_form_component(value.to_s)
176
+ result[URI.decode_www_form_component(key.to_s)] = URI.decode_www_form_component(value.to_s)
131
177
  end
132
- target
178
+ result
133
179
  end
134
180
 
135
181
  def extract_files
@@ -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 :status, :headers, :body, :cookies
29
+ attr_accessor :status_code, :headers, :body, :cookies
30
30
 
31
31
  def initialize
32
- @status = 200
32
+ @status_code = 200
33
33
  @headers = { "content-type" => HTML_CONTENT_TYPE }
34
34
  @body = ""
35
- @cookies = nil # Lazy most responses have no cookies
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
- @status = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
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
- @status = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
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
- @status = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
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
- @status = status
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
- @status = status
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: 302)
75
- @status = status
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
- @status = 404
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
- @status = status
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
- cookie = "#{name}=#{URI.encode_www_form_component(value)}"
105
- cookie += "; Path=#{opts[:path] || '/'}"
106
- cookie += "; HttpOnly" if opts.fetch(:http_only, true)
107
- cookie += "; Secure" if opts[:secure]
108
- cookie += "; SameSite=#{opts[:same_site] || 'Lax'}"
109
- cookie += "; Max-Age=#{opts[:max_age]}" if opts[:max_age]
110
- cookie += "; Expires=#{opts[:expires].httpdate}" if opts[:expires]
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 << cookie
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 [@status, @headers, [@body.to_s]]
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
- [@status, final_headers, [@body.to_s]]
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.status = result
190
+ response.status_code = result
161
191
  response.body = ""
162
192
  response
163
193
  when NilClass
164
- response.status = 204
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