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
data/lib/tina4/router.rb CHANGED
@@ -1,406 +1,406 @@
1
- # frozen_string_literal: true
2
-
3
- module Tina4
4
- class Route
5
- attr_reader :method, :path, :handler, :auth_handler, :swagger_meta,
6
- :path_regex, :param_names, :template
7
- attr_accessor :auth_required, :cached
8
-
9
- def initialize(method, path, handler, auth_handler: nil, swagger_meta: {}, middleware: [], template: nil)
10
- @method = method.to_s.upcase.freeze
11
- @path = normalize_path(path).freeze
12
- @handler = handler
13
- @auth_handler = auth_handler
14
- @swagger_meta = swagger_meta
15
- @middleware = middleware.freeze
16
- @template = template&.freeze
17
- # Write routes are secure by default, unless custom middleware is registered
18
- # (developer handles auth themselves via middleware)
19
- @auth_required = %w[POST PUT PATCH DELETE].include?(@method) && middleware.empty?
20
- @cached = false
21
- @param_names = []
22
- @path_regex = compile_pattern(@path)
23
- @param_names.freeze
24
- end
25
-
26
- # Mark this route as requiring bearer-token authentication.
27
- # Returns self for chaining: Router.get("/path") { ... }.secure
28
- def secure
29
- @auth_required = true
30
- self
31
- end
32
-
33
- # Opt out of the secure-by-default auth on write routes.
34
- # Returns self for chaining: Router.post("/login") { ... }.no_auth
35
- def no_auth
36
- @auth_required = false
37
- self
38
- end
39
-
40
- # Mark this route as cacheable.
41
- # Returns self for chaining: Router.get("/path") { ... }.cache
42
- def cache
43
- @cached = true
44
- self
45
- end
46
-
47
- # Dual-mode: getter (no args) returns the middleware array;
48
- # setter (with args) appends middleware and returns self for chaining.
49
- # Router.post("/api") { ... }.middleware(AuthMiddleware)
50
- def middleware(*middleware_classes)
51
- return @middleware if middleware_classes.empty?
52
-
53
- @middleware = @middleware.dup + middleware_classes
54
- # Custom middleware means developer handles auth — disable built-in gate
55
- # unless .secure was explicitly called.
56
- @auth_required = false unless @auth_required
57
- self
58
- end
59
-
60
- # Returns params hash if matched, false otherwise
61
- def match?(request_path, request_method = nil)
62
- return false if request_method && @method != "ANY" && @method != request_method.to_s.upcase
63
- match_path(request_path)
64
- end
65
-
66
- # Returns params hash if matched, false otherwise
67
- def match_path(request_path)
68
- match = @path_regex.match(request_path)
69
- return false unless match
70
-
71
- if @param_names.empty?
72
- {}
73
- else
74
- params = {}
75
- @param_names.each_with_index do |param_def, i|
76
- raw_value = match[i + 1]
77
- params[param_def[:name]] = cast_param(raw_value, param_def[:type])
78
- end
79
- params
80
- end
81
- end
82
-
83
- # Run per-route middleware chain; returns true if all pass
84
- def run_middleware(request, response)
85
- @middleware.each do |mw|
86
- result = mw.call(request, response)
87
- return false if result == false
88
- end
89
- true
90
- end
91
-
92
- private
93
-
94
- def normalize_path(path)
95
- p = path.to_s.gsub("\\", "/")
96
- p = "/#{p}" unless p.start_with?("/")
97
- p = p.chomp("/") unless p == "/"
98
- p
99
- end
100
-
101
- # Supported typed-parameter constraints. Mirrored verbatim in
102
- # tina4-python / tina4-php / tina4-nodejs for cross-framework parity.
103
- #
104
- # Any type name not in this table raises ``ArgumentError`` at route
105
- # registration — we never silently fall through to the default matcher,
106
- # because a typo like ``{id:inetger}`` would otherwise match anything
107
- # and create a security footgun (see tina4-book#125).
108
- PARAM_TYPE_PATTERNS = {
109
- "string" => "[^/]+", # default, any non-slash segment
110
- "int" => '\d+',
111
- "integer" => '\d+',
112
- "float" => '[\d.]+',
113
- "number" => '[\d.]+',
114
- "alpha" => "[A-Za-z]+", # letters only
115
- "alnum" => "[A-Za-z0-9]+", # letters + digits
116
- "slug" => "[a-z0-9-]+", # URL slug
117
- "uuid" => "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
118
- "path" => ".+", # greedy
119
- ".*" => ".+",
120
- }.freeze
121
-
122
- def compile_pattern(path)
123
- return Regexp.new("\\A/\\z") if path == "/"
124
-
125
- parts = path.split("/").reject(&:empty?)
126
- regex_parts = parts.map do |part|
127
- if part =~ /\A\*(\w+)\z/
128
- # Named catch-all splat parameter: *path captures everything after
129
- name = Regexp.last_match(1)
130
- @param_names << { name: name.to_sym, type: "path" }
131
- '(.+)'
132
- elsif part == "*"
133
- # Bare catch-all wildcard: captures everything after under the "*" key
134
- # to match Python/PHP/Node parity (docs say `request.params["*"]`).
135
- @param_names << { name: :"*", type: "path" }
136
- '(.+)'
137
- elsif part =~ /\A\{(\w+)(?::([\w.*]+))?\}\z/
138
- # Tina4/Python-style brace params: {id} or {id:int}
139
- # This is the ONLY supported param syntax, matching Python exactly.
140
- # Do NOT add :id (colon) style params.
141
- name = Regexp.last_match(1)
142
- type = Regexp.last_match(2) || "string"
143
- unless PARAM_TYPE_PATTERNS.key?(type)
144
- valid = PARAM_TYPE_PATTERNS.keys.reject { |k| k == ".*" }.sort.join(", ")
145
- raise ArgumentError,
146
- "Unknown param type '#{type}' in route '#{path}'. Valid types: #{valid}."
147
- end
148
- @param_names << { name: name.to_sym, type: type }
149
- "(#{PARAM_TYPE_PATTERNS[type]})"
150
- else
151
- Regexp.escape(part)
152
- end
153
- end
154
- Regexp.new("\\A/#{regex_parts.join("/")}\\z")
155
- end
156
-
157
- def cast_param(value, type)
158
- case type
159
- when "int", "integer"
160
- value.to_i
161
- when "float", "number"
162
- value.to_f
163
- else
164
- value
165
- end
166
- end
167
- end
168
-
169
- # A registered WebSocket route with path pattern matching (reuses Route's compile logic)
170
- class WebSocketRoute
171
- attr_reader :path, :handler, :path_regex, :param_names
172
-
173
- def initialize(path, handler)
174
- @path = normalize_path(path).freeze
175
- @handler = handler
176
- @param_names = []
177
- @path_regex = compile_pattern(@path)
178
- @param_names.freeze
179
- end
180
-
181
- # Returns params hash if matched, false otherwise
182
- def match?(request_path)
183
- match = @path_regex.match(request_path)
184
- return false unless match
185
-
186
- if @param_names.empty?
187
- {}
188
- else
189
- params = {}
190
- @param_names.each_with_index do |param_def, i|
191
- raw_value = match[i + 1]
192
- params[param_def[:name]] = raw_value
193
- end
194
- params
195
- end
196
- end
197
-
198
- private
199
-
200
- def normalize_path(path)
201
- p = path.to_s.gsub("\\", "/")
202
- p = "/#{p}" unless p.start_with?("/")
203
- p = p.chomp("/") unless p == "/"
204
- p
205
- end
206
-
207
- def compile_pattern(path)
208
- return Regexp.new("\\A/\\z") if path == "/"
209
-
210
- parts = path.split("/").reject(&:empty?)
211
- regex_parts = parts.map do |part|
212
- if part =~ /\A\{(\w+)\}\z/
213
- name = Regexp.last_match(1)
214
- @param_names << { name: name.to_sym }
215
- '([^/]+)'
216
- else
217
- Regexp.escape(part)
218
- end
219
- end
220
- Regexp.new("\\A/#{regex_parts.join("/")}\\z")
221
- end
222
- end
223
-
224
- module Router
225
- class << self
226
- def routes
227
- @routes ||= []
228
- end
229
-
230
- def get_routes
231
- routes
232
- end
233
-
234
- def list_routes
235
- routes
236
- end
237
-
238
- # Registered WebSocket routes
239
- def ws_routes
240
- @ws_routes ||= []
241
- end
242
-
243
- # Parity alias — returns all registered WebSocket routes.
244
- def get_web_socket_routes
245
- ws_routes
246
- end
247
-
248
- # Register a WebSocket route.
249
- # The handler block receives (connection, event, data) where:
250
- # connection — WebSocketConnection with #send, #broadcast, #close, #params
251
- # event — :open, :message, or :close
252
- # data — String payload for :message, nil for :open/:close
253
- def websocket(path, &block)
254
- ws_route = WebSocketRoute.new(path, block)
255
- ws_routes << ws_route
256
- Tina4::Log.debug("WebSocket route registered: #{path}")
257
- ws_route
258
- end
259
-
260
- # Find a matching WebSocket route for a given path.
261
- # Returns [ws_route, params] or nil.
262
- def find_ws_route(path)
263
- normalized = path.gsub("\\", "/")
264
- normalized = "/#{normalized}" unless normalized.start_with?("/")
265
- normalized = normalized.chomp("/") unless normalized == "/"
266
-
267
- ws_routes.each do |ws_route|
268
- params = ws_route.match?(normalized)
269
- return [ws_route, params] if params
270
- end
271
- nil
272
- end
273
-
274
- # Routes indexed by HTTP method for O(1) method lookup
275
- def method_index
276
- @method_index ||= Hash.new { |h, k| h[k] = [] }
277
- end
278
-
279
- def add(method, path, handler, auth_handler: nil, swagger_meta: {}, middleware: [], template: nil)
280
- route = Route.new(method, path, handler,
281
- auth_handler: auth_handler,
282
- swagger_meta: swagger_meta,
283
- middleware: middleware,
284
- template: template)
285
- routes << route
286
- method_index[route.method] << route
287
- Tina4::Log.debug("Route registered: #{method.upcase} #{path}")
288
- route
289
- end
290
- # Convenience registration methods
291
- def get(path, middleware: [], swagger_meta: {}, template: nil, &block)
292
- add("GET", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
293
- end
294
-
295
- def post(path, middleware: [], swagger_meta: {}, template: nil, &block)
296
- add("POST", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
297
- end
298
-
299
- def put(path, middleware: [], swagger_meta: {}, template: nil, &block)
300
- add("PUT", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
301
- end
302
-
303
- def patch(path, middleware: [], swagger_meta: {}, template: nil, &block)
304
- add("PATCH", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
305
- end
306
-
307
- def delete(path, middleware: [], swagger_meta: {}, template: nil, &block)
308
- add("DELETE", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
309
- end
310
-
311
- def any(path, middleware: [], swagger_meta: {}, template: nil, &block)
312
- add("ANY", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
313
- end
314
-
315
- def find_route(method, path)
316
- normalized_method = method.upcase
317
- # Normalize path once (not per-route)
318
- normalized_path = path.gsub("\\", "/")
319
- normalized_path = "/#{normalized_path}" unless normalized_path.start_with?("/")
320
- normalized_path = normalized_path.chomp("/") unless normalized_path == "/"
321
-
322
- # Check ANY routes first, then method-specific routes
323
- candidates = (method_index["ANY"] || []) + (method_index[normalized_method] || [])
324
- candidates.each do |route|
325
- params = route.match_path(normalized_path)
326
- return [route, params] if params
327
- end
328
- nil
329
- end
330
-
331
- # Find a route matching method + path. Returns [route, params] or nil.
332
- # match(method, path) — consistent with Python, PHP, and Node.
333
- def match(method, path)
334
- find_route(method, path)
335
- end
336
-
337
- # Register a class-based middleware globally.
338
- # The class should define static before_* and/or after_* methods.
339
- # Example:
340
- # class AuthMiddleware
341
- # def self.before_auth(request, response)
342
- # unless request.headers["authorization"]
343
- # return [request, response.json({ error: "Unauthorized" }, 401)]
344
- # end
345
- # [request, response]
346
- # end
347
- # end
348
- # Tina4::Router.use(AuthMiddleware)
349
- def use(klass)
350
- Tina4::Middleware.use(klass)
351
- end
352
-
353
- def clear!
354
- @routes = []
355
- @method_index = Hash.new { |h, k| h[k] = [] }
356
- @ws_routes = []
357
- end
358
- alias clear clear!
359
-
360
- def group(prefix, auth_handler: nil, middleware: [], &block)
361
- GroupContext.new(prefix, auth_handler, middleware).instance_eval(&block)
362
- end
363
-
364
- # Load route files from a directory (file-based route discovery)
365
- def load_routes(directory)
366
- return unless Dir.exist?(directory)
367
- Dir.glob(File.join(directory, "**/*.rb")).sort.each do |file|
368
- begin
369
- load file
370
- Tina4::Log.debug("Route loaded: #{file}")
371
- rescue => e
372
- Tina4::Log.error("Failed to load route #{file}: #{e.message}")
373
- end
374
- end
375
- end
376
- end
377
-
378
- class GroupContext
379
- def initialize(prefix, auth_handler = nil, middleware = [])
380
- @prefix = prefix.chomp("/")
381
- @auth_handler = auth_handler
382
- @middleware = middleware
383
- end
384
-
385
- %w[get post put patch delete any].each do |m|
386
- define_method(m) do |path, middleware: [], swagger_meta: {}, template: nil, &handler|
387
- full_path = "#{@prefix}#{path}"
388
- combined_middleware = @middleware + middleware
389
- Tina4::Router.add(m, full_path, handler,
390
- auth_handler: @auth_handler,
391
- swagger_meta: swagger_meta,
392
- middleware: combined_middleware,
393
- template: template)
394
- end
395
- end
396
-
397
- # Nested groups
398
- def group(prefix, auth_handler: nil, middleware: [], &block)
399
- full_prefix = "#{@prefix}#{prefix}"
400
- combined_middleware = @middleware + middleware
401
- nested_auth = auth_handler || @auth_handler
402
- GroupContext.new(full_prefix, nested_auth, combined_middleware).instance_eval(&block)
403
- end
404
- end
405
- end
406
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ class Route
5
+ attr_reader :method, :path, :handler, :auth_handler, :swagger_meta,
6
+ :path_regex, :param_names, :template
7
+ attr_accessor :auth_required, :cached
8
+
9
+ def initialize(method, path, handler, auth_handler: nil, swagger_meta: {}, middleware: [], template: nil)
10
+ @method = method.to_s.upcase.freeze
11
+ @path = normalize_path(path).freeze
12
+ @handler = handler
13
+ @auth_handler = auth_handler
14
+ @swagger_meta = swagger_meta
15
+ @middleware = middleware.freeze
16
+ @template = template&.freeze
17
+ # Write routes are secure by default, unless custom middleware is registered
18
+ # (developer handles auth themselves via middleware)
19
+ @auth_required = %w[POST PUT PATCH DELETE].include?(@method) && middleware.empty?
20
+ @cached = false
21
+ @param_names = []
22
+ @path_regex = compile_pattern(@path)
23
+ @param_names.freeze
24
+ end
25
+
26
+ # Mark this route as requiring bearer-token authentication.
27
+ # Returns self for chaining: Router.get("/path") { ... }.secure
28
+ def secure
29
+ @auth_required = true
30
+ self
31
+ end
32
+
33
+ # Opt out of the secure-by-default auth on write routes.
34
+ # Returns self for chaining: Router.post("/login") { ... }.no_auth
35
+ def no_auth
36
+ @auth_required = false
37
+ self
38
+ end
39
+
40
+ # Mark this route as cacheable.
41
+ # Returns self for chaining: Router.get("/path") { ... }.cache
42
+ def cache
43
+ @cached = true
44
+ self
45
+ end
46
+
47
+ # Dual-mode: getter (no args) returns the middleware array;
48
+ # setter (with args) appends middleware and returns self for chaining.
49
+ # Router.post("/api") { ... }.middleware(AuthMiddleware)
50
+ def middleware(*middleware_classes)
51
+ return @middleware if middleware_classes.empty?
52
+
53
+ @middleware = @middleware.dup + middleware_classes
54
+ # Custom middleware means developer handles auth — disable built-in gate
55
+ # unless .secure was explicitly called.
56
+ @auth_required = false unless @auth_required
57
+ self
58
+ end
59
+
60
+ # Returns params hash if matched, false otherwise
61
+ def match?(request_path, request_method = nil)
62
+ return false if request_method && @method != "ANY" && @method != request_method.to_s.upcase
63
+ match_path(request_path)
64
+ end
65
+
66
+ # Returns params hash if matched, false otherwise
67
+ def match_path(request_path)
68
+ match = @path_regex.match(request_path)
69
+ return false unless match
70
+
71
+ if @param_names.empty?
72
+ {}
73
+ else
74
+ params = {}
75
+ @param_names.each_with_index do |param_def, i|
76
+ raw_value = match[i + 1]
77
+ params[param_def[:name]] = cast_param(raw_value, param_def[:type])
78
+ end
79
+ params
80
+ end
81
+ end
82
+
83
+ # Run per-route middleware chain; returns true if all pass
84
+ def run_middleware(request, response)
85
+ @middleware.each do |mw|
86
+ result = mw.call(request, response)
87
+ return false if result == false
88
+ end
89
+ true
90
+ end
91
+
92
+ private
93
+
94
+ def normalize_path(path)
95
+ p = path.to_s.gsub("\\", "/")
96
+ p = "/#{p}" unless p.start_with?("/")
97
+ p = p.chomp("/") unless p == "/"
98
+ p
99
+ end
100
+
101
+ # Supported typed-parameter constraints. Mirrored verbatim in
102
+ # tina4-python / tina4-php / tina4-nodejs for cross-framework parity.
103
+ #
104
+ # Any type name not in this table raises ``ArgumentError`` at route
105
+ # registration — we never silently fall through to the default matcher,
106
+ # because a typo like ``{id:inetger}`` would otherwise match anything
107
+ # and create a security footgun (see tina4-book#125).
108
+ PARAM_TYPE_PATTERNS = {
109
+ "string" => "[^/]+", # default, any non-slash segment
110
+ "int" => '\d+',
111
+ "integer" => '\d+',
112
+ "float" => '[\d.]+',
113
+ "number" => '[\d.]+',
114
+ "alpha" => "[A-Za-z]+", # letters only
115
+ "alnum" => "[A-Za-z0-9]+", # letters + digits
116
+ "slug" => "[a-z0-9-]+", # URL slug
117
+ "uuid" => "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
118
+ "path" => ".+", # greedy
119
+ ".*" => ".+",
120
+ }.freeze
121
+
122
+ def compile_pattern(path)
123
+ return Regexp.new("\\A/\\z") if path == "/"
124
+
125
+ parts = path.split("/").reject(&:empty?)
126
+ regex_parts = parts.map do |part|
127
+ if part =~ /\A\*(\w+)\z/
128
+ # Named catch-all splat parameter: *path captures everything after
129
+ name = Regexp.last_match(1)
130
+ @param_names << { name: name.to_sym, type: "path" }
131
+ '(.+)'
132
+ elsif part == "*"
133
+ # Bare catch-all wildcard: captures everything after under the "*" key
134
+ # to match Python/PHP/Node parity (docs say `request.params["*"]`).
135
+ @param_names << { name: :"*", type: "path" }
136
+ '(.+)'
137
+ elsif part =~ /\A\{(\w+)(?::([\w.*]+))?\}\z/
138
+ # Tina4/Python-style brace params: {id} or {id:int}
139
+ # This is the ONLY supported param syntax, matching Python exactly.
140
+ # Do NOT add :id (colon) style params.
141
+ name = Regexp.last_match(1)
142
+ type = Regexp.last_match(2) || "string"
143
+ unless PARAM_TYPE_PATTERNS.key?(type)
144
+ valid = PARAM_TYPE_PATTERNS.keys.reject { |k| k == ".*" }.sort.join(", ")
145
+ raise ArgumentError,
146
+ "Unknown param type '#{type}' in route '#{path}'. Valid types: #{valid}."
147
+ end
148
+ @param_names << { name: name.to_sym, type: type }
149
+ "(#{PARAM_TYPE_PATTERNS[type]})"
150
+ else
151
+ Regexp.escape(part)
152
+ end
153
+ end
154
+ Regexp.new("\\A/#{regex_parts.join("/")}\\z")
155
+ end
156
+
157
+ def cast_param(value, type)
158
+ case type
159
+ when "int", "integer"
160
+ value.to_i
161
+ when "float", "number"
162
+ value.to_f
163
+ else
164
+ value
165
+ end
166
+ end
167
+ end
168
+
169
+ # A registered WebSocket route with path pattern matching (reuses Route's compile logic)
170
+ class WebSocketRoute
171
+ attr_reader :path, :handler, :path_regex, :param_names
172
+
173
+ def initialize(path, handler)
174
+ @path = normalize_path(path).freeze
175
+ @handler = handler
176
+ @param_names = []
177
+ @path_regex = compile_pattern(@path)
178
+ @param_names.freeze
179
+ end
180
+
181
+ # Returns params hash if matched, false otherwise
182
+ def match?(request_path)
183
+ match = @path_regex.match(request_path)
184
+ return false unless match
185
+
186
+ if @param_names.empty?
187
+ {}
188
+ else
189
+ params = {}
190
+ @param_names.each_with_index do |param_def, i|
191
+ raw_value = match[i + 1]
192
+ params[param_def[:name]] = raw_value
193
+ end
194
+ params
195
+ end
196
+ end
197
+
198
+ private
199
+
200
+ def normalize_path(path)
201
+ p = path.to_s.gsub("\\", "/")
202
+ p = "/#{p}" unless p.start_with?("/")
203
+ p = p.chomp("/") unless p == "/"
204
+ p
205
+ end
206
+
207
+ def compile_pattern(path)
208
+ return Regexp.new("\\A/\\z") if path == "/"
209
+
210
+ parts = path.split("/").reject(&:empty?)
211
+ regex_parts = parts.map do |part|
212
+ if part =~ /\A\{(\w+)\}\z/
213
+ name = Regexp.last_match(1)
214
+ @param_names << { name: name.to_sym }
215
+ '([^/]+)'
216
+ else
217
+ Regexp.escape(part)
218
+ end
219
+ end
220
+ Regexp.new("\\A/#{regex_parts.join("/")}\\z")
221
+ end
222
+ end
223
+
224
+ module Router
225
+ class << self
226
+ def routes
227
+ @routes ||= []
228
+ end
229
+
230
+ def get_routes
231
+ routes
232
+ end
233
+
234
+ def list_routes
235
+ routes
236
+ end
237
+
238
+ # Registered WebSocket routes
239
+ def ws_routes
240
+ @ws_routes ||= []
241
+ end
242
+
243
+ # Parity alias — returns all registered WebSocket routes.
244
+ def get_web_socket_routes
245
+ ws_routes
246
+ end
247
+
248
+ # Register a WebSocket route.
249
+ # The handler block receives (connection, event, data) where:
250
+ # connection — WebSocketConnection with #send, #broadcast, #close, #params
251
+ # event — :open, :message, or :close
252
+ # data — String payload for :message, nil for :open/:close
253
+ def websocket(path, &block)
254
+ ws_route = WebSocketRoute.new(path, block)
255
+ ws_routes << ws_route
256
+ Tina4::Log.debug("WebSocket route registered: #{path}")
257
+ ws_route
258
+ end
259
+
260
+ # Find a matching WebSocket route for a given path.
261
+ # Returns [ws_route, params] or nil.
262
+ def find_ws_route(path)
263
+ normalized = path.gsub("\\", "/")
264
+ normalized = "/#{normalized}" unless normalized.start_with?("/")
265
+ normalized = normalized.chomp("/") unless normalized == "/"
266
+
267
+ ws_routes.each do |ws_route|
268
+ params = ws_route.match?(normalized)
269
+ return [ws_route, params] if params
270
+ end
271
+ nil
272
+ end
273
+
274
+ # Routes indexed by HTTP method for O(1) method lookup
275
+ def method_index
276
+ @method_index ||= Hash.new { |h, k| h[k] = [] }
277
+ end
278
+
279
+ def add(method, path, handler, auth_handler: nil, swagger_meta: {}, middleware: [], template: nil)
280
+ route = Route.new(method, path, handler,
281
+ auth_handler: auth_handler,
282
+ swagger_meta: swagger_meta,
283
+ middleware: middleware,
284
+ template: template)
285
+ routes << route
286
+ method_index[route.method] << route
287
+ Tina4::Log.debug("Route registered: #{method.upcase} #{path}")
288
+ route
289
+ end
290
+ # Convenience registration methods
291
+ def get(path, middleware: [], swagger_meta: {}, template: nil, &block)
292
+ add("GET", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
293
+ end
294
+
295
+ def post(path, middleware: [], swagger_meta: {}, template: nil, &block)
296
+ add("POST", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
297
+ end
298
+
299
+ def put(path, middleware: [], swagger_meta: {}, template: nil, &block)
300
+ add("PUT", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
301
+ end
302
+
303
+ def patch(path, middleware: [], swagger_meta: {}, template: nil, &block)
304
+ add("PATCH", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
305
+ end
306
+
307
+ def delete(path, middleware: [], swagger_meta: {}, template: nil, &block)
308
+ add("DELETE", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
309
+ end
310
+
311
+ def any(path, middleware: [], swagger_meta: {}, template: nil, &block)
312
+ add("ANY", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
313
+ end
314
+
315
+ def find_route(method, path)
316
+ normalized_method = method.upcase
317
+ # Normalize path once (not per-route)
318
+ normalized_path = path.gsub("\\", "/")
319
+ normalized_path = "/#{normalized_path}" unless normalized_path.start_with?("/")
320
+ normalized_path = normalized_path.chomp("/") unless normalized_path == "/"
321
+
322
+ # Check ANY routes first, then method-specific routes
323
+ candidates = (method_index["ANY"] || []) + (method_index[normalized_method] || [])
324
+ candidates.each do |route|
325
+ params = route.match_path(normalized_path)
326
+ return [route, params] if params
327
+ end
328
+ nil
329
+ end
330
+
331
+ # Find a route matching method + path. Returns [route, params] or nil.
332
+ # match(method, path) — consistent with Python, PHP, and Node.
333
+ def match(method, path)
334
+ find_route(method, path)
335
+ end
336
+
337
+ # Register a class-based middleware globally.
338
+ # The class should define static before_* and/or after_* methods.
339
+ # Example:
340
+ # class AuthMiddleware
341
+ # def self.before_auth(request, response)
342
+ # unless request.headers["authorization"]
343
+ # return [request, response.json({ error: "Unauthorized" }, 401)]
344
+ # end
345
+ # [request, response]
346
+ # end
347
+ # end
348
+ # Tina4::Router.use(AuthMiddleware)
349
+ def use(klass)
350
+ Tina4::Middleware.use(klass)
351
+ end
352
+
353
+ def clear!
354
+ @routes = []
355
+ @method_index = Hash.new { |h, k| h[k] = [] }
356
+ @ws_routes = []
357
+ end
358
+ alias clear clear!
359
+
360
+ def group(prefix, auth_handler: nil, middleware: [], &block)
361
+ GroupContext.new(prefix, auth_handler, middleware).instance_eval(&block)
362
+ end
363
+
364
+ # Load route files from a directory (file-based route discovery)
365
+ def load_routes(directory)
366
+ return unless Dir.exist?(directory)
367
+ Dir.glob(File.join(directory, "**/*.rb")).sort.each do |file|
368
+ begin
369
+ load file
370
+ Tina4::Log.debug("Route loaded: #{file}")
371
+ rescue => e
372
+ Tina4::Log.error("Failed to load route #{file}: #{e.message}")
373
+ end
374
+ end
375
+ end
376
+ end
377
+
378
+ class GroupContext
379
+ def initialize(prefix, auth_handler = nil, middleware = [])
380
+ @prefix = prefix.chomp("/")
381
+ @auth_handler = auth_handler
382
+ @middleware = middleware
383
+ end
384
+
385
+ %w[get post put patch delete any].each do |m|
386
+ define_method(m) do |path, middleware: [], swagger_meta: {}, template: nil, &handler|
387
+ full_path = "#{@prefix}#{path}"
388
+ combined_middleware = @middleware + middleware
389
+ Tina4::Router.add(m, full_path, handler,
390
+ auth_handler: @auth_handler,
391
+ swagger_meta: swagger_meta,
392
+ middleware: combined_middleware,
393
+ template: template)
394
+ end
395
+ end
396
+
397
+ # Nested groups
398
+ def group(prefix, auth_handler: nil, middleware: [], &block)
399
+ full_prefix = "#{@prefix}#{prefix}"
400
+ combined_middleware = @middleware + middleware
401
+ nested_auth = auth_handler || @auth_handler
402
+ GroupContext.new(full_prefix, nested_auth, combined_middleware).instance_eval(&block)
403
+ end
404
+ end
405
+ end
406
+ end