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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -80
- data/LICENSE.txt +21 -21
- data/README.md +137 -137
- data/exe/tina4ruby +5 -5
- data/lib/tina4/ai.rb +696 -696
- data/lib/tina4/api.rb +189 -189
- data/lib/tina4/auth.rb +305 -305
- data/lib/tina4/auto_crud.rb +244 -244
- data/lib/tina4/cache.rb +154 -154
- data/lib/tina4/cli.rb +1449 -1449
- data/lib/tina4/constants.rb +46 -46
- data/lib/tina4/container.rb +74 -74
- data/lib/tina4/cors.rb +74 -74
- data/lib/tina4/crud.rb +692 -692
- data/lib/tina4/database/sqlite3_adapter.rb +165 -165
- data/lib/tina4/database.rb +625 -625
- data/lib/tina4/database_result.rb +208 -208
- data/lib/tina4/debug.rb +8 -8
- data/lib/tina4/dev.rb +14 -14
- data/lib/tina4/dev_admin.rb +935 -935
- data/lib/tina4/dev_mailbox.rb +191 -191
- data/lib/tina4/drivers/firebird_driver.rb +124 -110
- data/lib/tina4/drivers/mongodb_driver.rb +561 -561
- data/lib/tina4/drivers/mssql_driver.rb +112 -112
- data/lib/tina4/drivers/mysql_driver.rb +90 -90
- data/lib/tina4/drivers/odbc_driver.rb +191 -191
- data/lib/tina4/drivers/postgres_driver.rb +116 -106
- data/lib/tina4/drivers/sqlite_driver.rb +122 -122
- data/lib/tina4/env.rb +95 -95
- data/lib/tina4/error_overlay.rb +252 -252
- data/lib/tina4/events.rb +109 -109
- data/lib/tina4/field_types.rb +154 -154
- data/lib/tina4/frond.rb +2025 -2025
- data/lib/tina4/gallery/auth/meta.json +1 -1
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
- data/lib/tina4/gallery/database/meta.json +1 -1
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
- data/lib/tina4/gallery/error-overlay/meta.json +1 -1
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
- data/lib/tina4/gallery/orm/meta.json +1 -1
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
- data/lib/tina4/gallery/rest-api/meta.json +1 -1
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
- data/lib/tina4/gallery/templates/meta.json +1 -1
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
- data/lib/tina4/graphql.rb +966 -966
- data/lib/tina4/health.rb +39 -39
- data/lib/tina4/html_element.rb +170 -170
- data/lib/tina4/job.rb +80 -80
- data/lib/tina4/localization.rb +168 -168
- data/lib/tina4/log.rb +203 -203
- data/lib/tina4/mcp.rb +696 -696
- data/lib/tina4/messenger.rb +587 -587
- data/lib/tina4/metrics.rb +793 -793
- data/lib/tina4/middleware.rb +445 -445
- data/lib/tina4/migration.rb +451 -451
- data/lib/tina4/orm.rb +790 -790
- data/lib/tina4/public/css/tina4.css +2463 -2463
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/public/images/logo.svg +5 -5
- data/lib/tina4/public/js/frond.min.js +2 -2
- data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
- data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
- data/lib/tina4/public/js/tina4.min.js +92 -92
- data/lib/tina4/public/js/tina4js.min.js +48 -48
- data/lib/tina4/public/swagger/index.html +90 -90
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
- data/lib/tina4/query_builder.rb +380 -380
- data/lib/tina4/queue.rb +366 -366
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
- data/lib/tina4/queue_backends/lite_backend.rb +298 -298
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
- data/lib/tina4/rack_app.rb +817 -817
- data/lib/tina4/rate_limiter.rb +130 -130
- data/lib/tina4/request.rb +268 -255
- data/lib/tina4/response.rb +346 -346
- data/lib/tina4/response_cache.rb +551 -551
- data/lib/tina4/router.rb +406 -406
- data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
- data/lib/tina4/scss/tina4css/_badges.scss +22 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
- data/lib/tina4/scss/tina4css/_cards.scss +49 -49
- data/lib/tina4/scss/tina4css/_forms.scss +156 -156
- data/lib/tina4/scss/tina4css/_grid.scss +81 -81
- data/lib/tina4/scss/tina4css/_modals.scss +84 -84
- data/lib/tina4/scss/tina4css/_nav.scss +149 -149
- data/lib/tina4/scss/tina4css/_reset.scss +94 -94
- data/lib/tina4/scss/tina4css/_tables.scss +54 -54
- data/lib/tina4/scss/tina4css/_typography.scss +55 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
- data/lib/tina4/scss/tina4css/_variables.scss +117 -117
- data/lib/tina4/scss/tina4css/base.scss +1 -1
- data/lib/tina4/scss/tina4css/colors.scss +48 -48
- data/lib/tina4/scss/tina4css/tina4.scss +17 -17
- data/lib/tina4/scss_compiler.rb +178 -178
- data/lib/tina4/seeder.rb +567 -567
- data/lib/tina4/service_runner.rb +303 -303
- data/lib/tina4/session.rb +297 -297
- data/lib/tina4/session_handlers/database_handler.rb +72 -72
- data/lib/tina4/session_handlers/file_handler.rb +67 -67
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
- data/lib/tina4/session_handlers/redis_handler.rb +43 -43
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
- data/lib/tina4/shutdown.rb +84 -84
- data/lib/tina4/sql_translation.rb +158 -158
- data/lib/tina4/swagger.rb +124 -124
- data/lib/tina4/template.rb +894 -894
- data/lib/tina4/templates/base.twig +26 -26
- data/lib/tina4/templates/errors/302.twig +14 -14
- data/lib/tina4/templates/errors/401.twig +9 -9
- data/lib/tina4/templates/errors/403.twig +29 -29
- data/lib/tina4/templates/errors/404.twig +29 -29
- data/lib/tina4/templates/errors/500.twig +38 -38
- data/lib/tina4/templates/errors/502.twig +9 -9
- data/lib/tina4/templates/errors/503.twig +12 -12
- data/lib/tina4/templates/errors/base.twig +37 -37
- data/lib/tina4/test_client.rb +159 -159
- data/lib/tina4/testing.rb +340 -340
- data/lib/tina4/validator.rb +174 -174
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +312 -312
- data/lib/tina4/websocket.rb +343 -343
- data/lib/tina4/websocket_backplane.rb +190 -190
- data/lib/tina4/wsdl.rb +564 -564
- data/lib/tina4.rb +458 -458
- data/lib/tina4ruby.rb +4 -4
- metadata +3 -3
data/lib/tina4/response.rb
CHANGED
|
@@ -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
|