tina4ruby 3.11.13 → 3.11.15

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -3
@@ -1,346 +1,346 @@
1
- # frozen_string_literal: true
2
- require "json"
3
- require "uri"
4
-
5
- module Tina4
6
- # ---------------------------------------------------------------------------
7
- # Global Frond template engine registry
8
- # ---------------------------------------------------------------------------
9
- @_global_frond = nil
10
- @_framework_frond = nil
11
-
12
- # Return the global Frond engine, creating a default if needed.
13
- def self.get_frond
14
- @_global_frond ||= Tina4::Frond.new(template_dir: "src/templates")
15
- end
16
-
17
- # Return the singleton Frond engine for built-in framework templates.
18
- def self.get_framework_frond
19
- framework_dir = ::File.join(::File.dirname(__FILE__), "templates")
20
- if @_framework_frond.nil? && ::File.directory?(framework_dir)
21
- @_framework_frond = Tina4::Frond.new(template_dir: framework_dir)
22
- end
23
- # Sync custom filters/globals from the user engine
24
- if @_framework_frond
25
- user_engine = get_frond
26
- @_framework_frond.instance_variable_get(:@filters).merge!(user_engine.instance_variable_get(:@filters))
27
- @_framework_frond.instance_variable_get(:@globals).merge!(user_engine.instance_variable_get(:@globals))
28
- end
29
- @_framework_frond
30
- end
31
-
32
- # Register a pre-configured Frond engine for response.render().
33
- def self.set_frond(engine)
34
- @_global_frond = engine
35
- end
36
-
37
- class Response
38
- MIME_TYPES = {
39
- ".html" => "text/html", ".htm" => "text/html",
40
- ".css" => "text/css", ".js" => "application/javascript",
41
- ".json" => "application/json", ".xml" => "application/xml",
42
- ".txt" => "text/plain", ".csv" => "text/csv",
43
- ".png" => "image/png", ".jpg" => "image/jpeg",
44
- ".jpeg" => "image/jpeg", ".gif" => "image/gif",
45
- ".svg" => "image/svg+xml", ".ico" => "image/x-icon",
46
- ".webp" => "image/webp", ".pdf" => "application/pdf",
47
- ".zip" => "application/zip", ".woff" => "font/woff",
48
- ".woff2" => "font/woff2", ".ttf" => "font/ttf",
49
- ".eot" => "application/vnd.ms-fontobject",
50
- ".mp3" => "audio/mpeg", ".mp4" => "video/mp4",
51
- ".webm" => "video/webm"
52
- }.freeze
53
-
54
- # Pre-frozen header values
55
- JSON_CONTENT_TYPE = "application/json; charset=utf-8"
56
- HTML_CONTENT_TYPE = "text/html; charset=utf-8"
57
- TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
58
- XML_CONTENT_TYPE = "application/xml; charset=utf-8"
59
-
60
- attr_accessor :status_code, :headers, :body, :cookies
61
-
62
- def initialize
63
- @status_code = 200
64
- @headers = { "content-type" => HTML_CONTENT_TYPE }
65
- @body = ""
66
- @cookies = nil # Lazy -- most responses have no cookies
67
- end
68
-
69
- # Chainable status setter
70
- def status(code = nil)
71
- if code.nil?
72
- @status_code
73
- else
74
- @status_code = code
75
- self
76
- end
77
- end
78
-
79
- # Callable response — auto-detects content type from data.
80
- # Matches Python __call__ / PHP __invoke / Node response() pattern.
81
- def call(data = nil, status_code = 200, content_type = nil)
82
- @status_code = status_code
83
- if content_type
84
- @headers["content-type"] = content_type
85
- @body = data.to_s
86
- elsif data.is_a?(Hash) || data.is_a?(Array)
87
- @headers["content-type"] = JSON_CONTENT_TYPE
88
- @body = JSON.generate(data)
89
- else
90
- @headers["content-type"] = HTML_CONTENT_TYPE
91
- @body = data.to_s
92
- end
93
- self
94
- end
95
-
96
- def json(data, status_or_opts = nil, status: nil)
97
- @status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
98
- @headers["content-type"] = JSON_CONTENT_TYPE
99
- @body = data.is_a?(String) ? data : JSON.generate(data)
100
- self
101
- end
102
-
103
- def html(content, status_or_opts = nil, status: nil)
104
- @status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
105
- @headers["content-type"] = HTML_CONTENT_TYPE
106
- @body = content.to_s
107
- self
108
- end
109
-
110
- def text(content, status_or_opts = nil, status: nil)
111
- @status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
112
- @headers["content-type"] = TEXT_CONTENT_TYPE
113
- @body = content.to_s
114
- self
115
- end
116
-
117
- def xml(content, status: 200)
118
- @status_code = status
119
- @headers["content-type"] = XML_CONTENT_TYPE
120
- @body = content.to_s
121
- self
122
- end
123
-
124
- def csv(content, filename: "export.csv", status: 200)
125
- @status_code = status
126
- @headers["content-type"] = "text/csv"
127
- @headers["content-disposition"] = "attachment; filename=\"#{filename}\""
128
- @body = content.to_s
129
- self
130
- end
131
-
132
- def redirect(url, status_or_opts = nil, status: nil)
133
- @status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 302)
134
- @headers["location"] = url
135
- @body = ""
136
- self
137
- end
138
-
139
- def file(path, content_type: nil, download: false)
140
- unless ::File.exist?(path)
141
- @status_code = 404
142
- @body = "File not found"
143
- return self
144
- end
145
- ext = ::File.extname(path).downcase
146
- @headers["content-type"] = content_type || MIME_TYPES[ext] || "application/octet-stream"
147
- if download
148
- @headers["content-disposition"] = "attachment; filename=\"#{::File.basename(path)}\""
149
- end
150
- @body = ::File.binread(path)
151
- self
152
- end
153
-
154
- def render(template_path, data = {}, status: 200, template_dir: nil)
155
- @status_code = status
156
- @headers["content-type"] = HTML_CONTENT_TYPE
157
-
158
- engine = template_dir ? Tina4::Frond.new(template_dir: template_dir) : Tina4.get_frond
159
-
160
- # Try user templates first
161
- begin
162
- @body = engine.render(template_path, data)
163
- return self
164
- rescue Errno::ENOENT
165
- # Not found in user templates — try framework templates
166
- rescue => e
167
- @body = "<pre>Template error: #{e.message}</pre>"
168
- @status_code = 500
169
- return self
170
- end
171
-
172
- # Fallback: framework templates
173
- fw_engine = Tina4.get_framework_frond
174
- if fw_engine
175
- begin
176
- @body = fw_engine.render(template_path, data)
177
- return self
178
- rescue Errno::ENOENT
179
- # Not found in framework templates either
180
- rescue => e
181
- @body = "<pre>Template error: #{e.message}</pre>"
182
- @status_code = 500
183
- return self
184
- end
185
- end
186
-
187
- @body = "<pre>Template not found: #{template_path}</pre>"
188
- @status_code = 404
189
- self
190
- end
191
-
192
- # Standard error response envelope.
193
- #
194
- # Usage:
195
- # response.error("VALIDATION_FAILED", "Email is required", 400)
196
- #
197
- def error(code, message, status_code = 400)
198
- @status_code = status_code
199
- @headers["content-type"] = JSON_CONTENT_TYPE
200
- @body = JSON.generate({
201
- error: true,
202
- code: code,
203
- message: message,
204
- status: status_code
205
- })
206
- self
207
- end
208
-
209
- # Build a standard error envelope hash (class method).
210
- #
211
- # Usage:
212
- # response.json(Tina4::Response.error_response("NOT_FOUND", "Resource not found", 404), status: 404)
213
- #
214
- def self.error_response(code, message, status = 400)
215
- { error: true, code: code, message: message, status: status }
216
- end
217
-
218
- # Chainable header setter
219
- def header(name, value = nil)
220
- if value.nil?
221
- @headers[name]
222
- else
223
- @headers[name] = value
224
- self
225
- end
226
- end
227
-
228
- # Chainable cookie setter
229
- def cookie(name, value, opts = {})
230
- set_cookie(name, value, opts)
231
- end
232
-
233
- def set_cookie(name, value, opts = {})
234
- cookie_str = "#{name}=#{URI.encode_www_form_component(value)}"
235
- cookie_str += "; Path=#{opts[:path] || '/'}"
236
- cookie_str += "; HttpOnly" if opts.fetch(:http_only, true)
237
- cookie_str += "; Secure" if opts[:secure]
238
- cookie_str += "; SameSite=#{opts[:same_site] || 'Lax'}"
239
- cookie_str += "; Max-Age=#{opts[:max_age]}" if opts[:max_age]
240
- cookie_str += "; Expires=#{opts[:expires].httpdate}" if opts[:expires]
241
- @cookies ||= []
242
- @cookies << cookie_str
243
- self
244
- end
245
-
246
- def delete_cookie(name, path: "/")
247
- set_cookie(name, "", max_age: 0, path: path)
248
- end
249
-
250
- def add_header(key, value)
251
- @headers[key] = value
252
- self
253
- end
254
-
255
- def add_cors_headers(origin: "*", methods: "GET, POST, PUT, PATCH, DELETE, OPTIONS",
256
- headers_list: "Content-Type, Authorization, Accept", credentials: false)
257
- @headers["access-control-allow-origin"] = origin
258
- @headers["access-control-allow-methods"] = methods
259
- @headers["access-control-allow-headers"] = headers_list
260
- @headers["access-control-allow-credentials"] = "true" if credentials
261
- @headers["access-control-max-age"] = "86400"
262
- self
263
- end
264
-
265
- # Stream response from a block for Server-Sent Events (SSE).
266
- #
267
- # Usage:
268
- # Tina4::Router.get "/events" do |request, response|
269
- # response.stream do |out|
270
- # 10.times do |i|
271
- # out << "data: message #{i}\n\n"
272
- # sleep 1
273
- # end
274
- # end
275
- # end
276
- #
277
- # @param content_type [String] Content type (default: text/event-stream)
278
- # @yield [Enumerator::Yielder] Block receives a yielder to push chunks
279
- # @return [self]
280
- def stream(content_type: "text/event-stream", &block)
281
- @status_code = @status_code || 200
282
- @headers["content-type"] = content_type
283
- @headers["cache-control"] = "no-cache"
284
- @headers["connection"] = "keep-alive"
285
- @headers["x-accel-buffering"] = "no"
286
- @_streaming = true
287
- @_stream_block = block
288
- self
289
- end
290
-
291
- # Finalize and return the response — matches Python/Node API.
292
- def send(data = nil, status_code: nil, content_type: nil)
293
- if data
294
- if data.is_a?(Hash) || data.is_a?(Array)
295
- return json(data, status_code || 200)
296
- end
297
- @headers["content-type"] = content_type if content_type
298
- @body = data.to_s
299
- @status_code = status_code if status_code
300
- return self
301
- end
302
- to_rack
303
- end
304
-
305
- def to_rack
306
- final_headers = @headers.dup
307
- final_headers["set-cookie"] = @cookies.join("\n") if @cookies && !@cookies.empty?
308
-
309
- if @_streaming
310
- # Streaming mode — return an Enumerator as the body
311
- body = Enumerator.new do |yielder|
312
- @_stream_block.call(yielder)
313
- end
314
- return [@status_code, final_headers, body]
315
- end
316
-
317
- # Normal buffered response
318
- [@status_code, final_headers, [@body.to_s]]
319
- end
320
-
321
- def self.auto_detect(result, response)
322
- case result
323
- when Tina4::Response
324
- result
325
- when Hash, Array
326
- response.json(result)
327
- when String
328
- if result.start_with?("<")
329
- response.html(result)
330
- else
331
- response.text(result)
332
- end
333
- when Integer
334
- response.status_code = result
335
- response.body = ""
336
- response
337
- when NilClass
338
- response.status_code = 204
339
- response.body = ""
340
- response
341
- else
342
- response.json(result.respond_to?(:to_hash) ? result.to_hash : { data: result.to_s })
343
- end
344
- end
345
- end
346
- end
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "uri"
4
+
5
+ module Tina4
6
+ # ---------------------------------------------------------------------------
7
+ # Global Frond template engine registry
8
+ # ---------------------------------------------------------------------------
9
+ @_global_frond = nil
10
+ @_framework_frond = nil
11
+
12
+ # Return the global Frond engine, creating a default if needed.
13
+ def self.get_frond
14
+ @_global_frond ||= Tina4::Frond.new(template_dir: "src/templates")
15
+ end
16
+
17
+ # Return the singleton Frond engine for built-in framework templates.
18
+ def self.get_framework_frond
19
+ framework_dir = ::File.join(::File.dirname(__FILE__), "templates")
20
+ if @_framework_frond.nil? && ::File.directory?(framework_dir)
21
+ @_framework_frond = Tina4::Frond.new(template_dir: framework_dir)
22
+ end
23
+ # Sync custom filters/globals from the user engine
24
+ if @_framework_frond
25
+ user_engine = get_frond
26
+ @_framework_frond.instance_variable_get(:@filters).merge!(user_engine.instance_variable_get(:@filters))
27
+ @_framework_frond.instance_variable_get(:@globals).merge!(user_engine.instance_variable_get(:@globals))
28
+ end
29
+ @_framework_frond
30
+ end
31
+
32
+ # Register a pre-configured Frond engine for response.render().
33
+ def self.set_frond(engine)
34
+ @_global_frond = engine
35
+ end
36
+
37
+ class Response
38
+ MIME_TYPES = {
39
+ ".html" => "text/html", ".htm" => "text/html",
40
+ ".css" => "text/css", ".js" => "application/javascript",
41
+ ".json" => "application/json", ".xml" => "application/xml",
42
+ ".txt" => "text/plain", ".csv" => "text/csv",
43
+ ".png" => "image/png", ".jpg" => "image/jpeg",
44
+ ".jpeg" => "image/jpeg", ".gif" => "image/gif",
45
+ ".svg" => "image/svg+xml", ".ico" => "image/x-icon",
46
+ ".webp" => "image/webp", ".pdf" => "application/pdf",
47
+ ".zip" => "application/zip", ".woff" => "font/woff",
48
+ ".woff2" => "font/woff2", ".ttf" => "font/ttf",
49
+ ".eot" => "application/vnd.ms-fontobject",
50
+ ".mp3" => "audio/mpeg", ".mp4" => "video/mp4",
51
+ ".webm" => "video/webm"
52
+ }.freeze
53
+
54
+ # Pre-frozen header values
55
+ JSON_CONTENT_TYPE = "application/json; charset=utf-8"
56
+ HTML_CONTENT_TYPE = "text/html; charset=utf-8"
57
+ TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
58
+ XML_CONTENT_TYPE = "application/xml; charset=utf-8"
59
+
60
+ attr_accessor :status_code, :headers, :body, :cookies
61
+
62
+ def initialize
63
+ @status_code = 200
64
+ @headers = { "content-type" => HTML_CONTENT_TYPE }
65
+ @body = ""
66
+ @cookies = nil # Lazy -- most responses have no cookies
67
+ end
68
+
69
+ # Chainable status setter
70
+ def status(code = nil)
71
+ if code.nil?
72
+ @status_code
73
+ else
74
+ @status_code = code
75
+ self
76
+ end
77
+ end
78
+
79
+ # Callable response — auto-detects content type from data.
80
+ # Matches Python __call__ / PHP __invoke / Node response() pattern.
81
+ def call(data = nil, status_code = 200, content_type = nil)
82
+ @status_code = status_code
83
+ if content_type
84
+ @headers["content-type"] = content_type
85
+ @body = data.to_s
86
+ elsif data.is_a?(Hash) || data.is_a?(Array)
87
+ @headers["content-type"] = JSON_CONTENT_TYPE
88
+ @body = JSON.generate(data)
89
+ else
90
+ @headers["content-type"] = HTML_CONTENT_TYPE
91
+ @body = data.to_s
92
+ end
93
+ self
94
+ end
95
+
96
+ def json(data, status_or_opts = nil, status: nil)
97
+ @status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
98
+ @headers["content-type"] = JSON_CONTENT_TYPE
99
+ @body = data.is_a?(String) ? data : JSON.generate(data)
100
+ self
101
+ end
102
+
103
+ def html(content, status_or_opts = nil, status: nil)
104
+ @status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
105
+ @headers["content-type"] = HTML_CONTENT_TYPE
106
+ @body = content.to_s
107
+ self
108
+ end
109
+
110
+ def text(content, status_or_opts = nil, status: nil)
111
+ @status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 200)
112
+ @headers["content-type"] = TEXT_CONTENT_TYPE
113
+ @body = content.to_s
114
+ self
115
+ end
116
+
117
+ def xml(content, status: 200)
118
+ @status_code = status
119
+ @headers["content-type"] = XML_CONTENT_TYPE
120
+ @body = content.to_s
121
+ self
122
+ end
123
+
124
+ def csv(content, filename: "export.csv", status: 200)
125
+ @status_code = status
126
+ @headers["content-type"] = "text/csv"
127
+ @headers["content-disposition"] = "attachment; filename=\"#{filename}\""
128
+ @body = content.to_s
129
+ self
130
+ end
131
+
132
+ def redirect(url, status_or_opts = nil, status: nil)
133
+ @status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 302)
134
+ @headers["location"] = url
135
+ @body = ""
136
+ self
137
+ end
138
+
139
+ def file(path, content_type: nil, download: false)
140
+ unless ::File.exist?(path)
141
+ @status_code = 404
142
+ @body = "File not found"
143
+ return self
144
+ end
145
+ ext = ::File.extname(path).downcase
146
+ @headers["content-type"] = content_type || MIME_TYPES[ext] || "application/octet-stream"
147
+ if download
148
+ @headers["content-disposition"] = "attachment; filename=\"#{::File.basename(path)}\""
149
+ end
150
+ @body = ::File.binread(path)
151
+ self
152
+ end
153
+
154
+ def render(template_path, data = {}, status: 200, template_dir: nil)
155
+ @status_code = status
156
+ @headers["content-type"] = HTML_CONTENT_TYPE
157
+
158
+ engine = template_dir ? Tina4::Frond.new(template_dir: template_dir) : Tina4.get_frond
159
+
160
+ # Try user templates first
161
+ begin
162
+ @body = engine.render(template_path, data)
163
+ return self
164
+ rescue Errno::ENOENT
165
+ # Not found in user templates — try framework templates
166
+ rescue => e
167
+ @body = "<pre>Template error: #{e.message}</pre>"
168
+ @status_code = 500
169
+ return self
170
+ end
171
+
172
+ # Fallback: framework templates
173
+ fw_engine = Tina4.get_framework_frond
174
+ if fw_engine
175
+ begin
176
+ @body = fw_engine.render(template_path, data)
177
+ return self
178
+ rescue Errno::ENOENT
179
+ # Not found in framework templates either
180
+ rescue => e
181
+ @body = "<pre>Template error: #{e.message}</pre>"
182
+ @status_code = 500
183
+ return self
184
+ end
185
+ end
186
+
187
+ @body = "<pre>Template not found: #{template_path}</pre>"
188
+ @status_code = 404
189
+ self
190
+ end
191
+
192
+ # Standard error response envelope.
193
+ #
194
+ # Usage:
195
+ # response.error("VALIDATION_FAILED", "Email is required", 400)
196
+ #
197
+ def error(code, message, status_code = 400)
198
+ @status_code = status_code
199
+ @headers["content-type"] = JSON_CONTENT_TYPE
200
+ @body = JSON.generate({
201
+ error: true,
202
+ code: code,
203
+ message: message,
204
+ status: status_code
205
+ })
206
+ self
207
+ end
208
+
209
+ # Build a standard error envelope hash (class method).
210
+ #
211
+ # Usage:
212
+ # response.json(Tina4::Response.error_response("NOT_FOUND", "Resource not found", 404), status: 404)
213
+ #
214
+ def self.error_response(code, message, status = 400)
215
+ { error: true, code: code, message: message, status: status }
216
+ end
217
+
218
+ # Chainable header setter
219
+ def header(name, value = nil)
220
+ if value.nil?
221
+ @headers[name]
222
+ else
223
+ @headers[name] = value
224
+ self
225
+ end
226
+ end
227
+
228
+ # Chainable cookie setter
229
+ def cookie(name, value, opts = {})
230
+ set_cookie(name, value, opts)
231
+ end
232
+
233
+ def set_cookie(name, value, opts = {})
234
+ cookie_str = "#{name}=#{URI.encode_www_form_component(value)}"
235
+ cookie_str += "; Path=#{opts[:path] || '/'}"
236
+ cookie_str += "; HttpOnly" if opts.fetch(:http_only, true)
237
+ cookie_str += "; Secure" if opts[:secure]
238
+ cookie_str += "; SameSite=#{opts[:same_site] || 'Lax'}"
239
+ cookie_str += "; Max-Age=#{opts[:max_age]}" if opts[:max_age]
240
+ cookie_str += "; Expires=#{opts[:expires].httpdate}" if opts[:expires]
241
+ @cookies ||= []
242
+ @cookies << cookie_str
243
+ self
244
+ end
245
+
246
+ def delete_cookie(name, path: "/")
247
+ set_cookie(name, "", max_age: 0, path: path)
248
+ end
249
+
250
+ def add_header(key, value)
251
+ @headers[key] = value
252
+ self
253
+ end
254
+
255
+ def add_cors_headers(origin: "*", methods: "GET, POST, PUT, PATCH, DELETE, OPTIONS",
256
+ headers_list: "Content-Type, Authorization, Accept", credentials: false)
257
+ @headers["access-control-allow-origin"] = origin
258
+ @headers["access-control-allow-methods"] = methods
259
+ @headers["access-control-allow-headers"] = headers_list
260
+ @headers["access-control-allow-credentials"] = "true" if credentials
261
+ @headers["access-control-max-age"] = "86400"
262
+ self
263
+ end
264
+
265
+ # Stream response from a block for Server-Sent Events (SSE).
266
+ #
267
+ # Usage:
268
+ # Tina4::Router.get "/events" do |request, response|
269
+ # response.stream do |out|
270
+ # 10.times do |i|
271
+ # out << "data: message #{i}\n\n"
272
+ # sleep 1
273
+ # end
274
+ # end
275
+ # end
276
+ #
277
+ # @param content_type [String] Content type (default: text/event-stream)
278
+ # @yield [Enumerator::Yielder] Block receives a yielder to push chunks
279
+ # @return [self]
280
+ def stream(content_type: "text/event-stream", &block)
281
+ @status_code = @status_code || 200
282
+ @headers["content-type"] = content_type
283
+ @headers["cache-control"] = "no-cache"
284
+ @headers["connection"] = "keep-alive"
285
+ @headers["x-accel-buffering"] = "no"
286
+ @_streaming = true
287
+ @_stream_block = block
288
+ self
289
+ end
290
+
291
+ # Finalize and return the response — matches Python/Node API.
292
+ def send(data = nil, status_code: nil, content_type: nil)
293
+ if data
294
+ if data.is_a?(Hash) || data.is_a?(Array)
295
+ return json(data, status_code || 200)
296
+ end
297
+ @headers["content-type"] = content_type if content_type
298
+ @body = data.to_s
299
+ @status_code = status_code if status_code
300
+ return self
301
+ end
302
+ to_rack
303
+ end
304
+
305
+ def to_rack
306
+ final_headers = @headers.dup
307
+ final_headers["set-cookie"] = @cookies.join("\n") if @cookies && !@cookies.empty?
308
+
309
+ if @_streaming
310
+ # Streaming mode — return an Enumerator as the body
311
+ body = Enumerator.new do |yielder|
312
+ @_stream_block.call(yielder)
313
+ end
314
+ return [@status_code, final_headers, body]
315
+ end
316
+
317
+ # Normal buffered response
318
+ [@status_code, final_headers, [@body.to_s]]
319
+ end
320
+
321
+ def self.auto_detect(result, response)
322
+ case result
323
+ when Tina4::Response
324
+ result
325
+ when Hash, Array
326
+ response.json(result)
327
+ when String
328
+ if result.start_with?("<")
329
+ response.html(result)
330
+ else
331
+ response.text(result)
332
+ end
333
+ when Integer
334
+ response.status_code = result
335
+ response.body = ""
336
+ response
337
+ when NilClass
338
+ response.status_code = 204
339
+ response.body = ""
340
+ response
341
+ else
342
+ response.json(result.respond_to?(:to_hash) ? result.to_hash : { data: result.to_s })
343
+ end
344
+ end
345
+ end
346
+ end