tina4ruby 3.12.10 → 3.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/tina4/auth.rb +14 -8
- data/lib/tina4/cli.rb +3 -3
- data/lib/tina4/dev_admin.rb +152 -20
- data/lib/tina4/error_overlay.rb +32 -2
- data/lib/tina4/feedback.rb +307 -0
- data/lib/tina4/mcp.rb +237 -10
- data/lib/tina4/plan.rb +41 -13
- data/lib/tina4/public/__feedback/widget.js +96 -0
- data/lib/tina4/rack_app.rb +85 -4
- data/lib/tina4/request.rb +8 -4
- data/lib/tina4/router.rb +137 -3
- data/lib/tina4/test.rb +179 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +2 -0
- metadata +5 -2
data/lib/tina4/router.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
3
6
|
module Tina4
|
|
4
7
|
class Route
|
|
5
8
|
attr_reader :method, :path, :handler, :auth_handler, :swagger_meta,
|
|
@@ -312,6 +315,25 @@ module Tina4
|
|
|
312
315
|
add("ANY", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
|
|
313
316
|
end
|
|
314
317
|
|
|
318
|
+
# Register an explicit HEAD route. By default the framework auto-handles
|
|
319
|
+
# HEAD by falling back to the GET route and stripping the body
|
|
320
|
+
# (RFC 9110 §9.3.2). Use this only when you need a HEAD handler that
|
|
321
|
+
# does something different from GET — e.g. cheaper existence-check
|
|
322
|
+
# logic, custom validator headers without the cost of building the body.
|
|
323
|
+
# The framework still strips the response body for you on the way out.
|
|
324
|
+
def head(path, middleware: [], swagger_meta: {}, template: nil, &block)
|
|
325
|
+
add("HEAD", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Register an explicit OPTIONS route. By default the framework auto-
|
|
329
|
+
# handles OPTIONS by building an Allow header from every method
|
|
330
|
+
# registered for the path and returning 204 (RFC 9110 §9.3.7). Use
|
|
331
|
+
# this to take over that behaviour — e.g. to return a richer OPTIONS
|
|
332
|
+
# payload describing the resource.
|
|
333
|
+
def options(path, middleware: [], swagger_meta: {}, template: nil, &block)
|
|
334
|
+
add("OPTIONS", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
|
|
335
|
+
end
|
|
336
|
+
|
|
315
337
|
def find_route(method, path)
|
|
316
338
|
normalized_method = method.upcase
|
|
317
339
|
# Normalize path once (not per-route)
|
|
@@ -325,9 +347,59 @@ module Tina4
|
|
|
325
347
|
params = route.match_path(normalized_path)
|
|
326
348
|
return [route, params] if params
|
|
327
349
|
end
|
|
350
|
+
|
|
351
|
+
# RFC 9110 §9.3.2: HEAD is identical to GET except for the absence
|
|
352
|
+
# of a response body. If no explicit HEAD route matched, fall back
|
|
353
|
+
# to the GET route — the dispatcher strips the body on the way out
|
|
354
|
+
# so the handler doesn't need to know HEAD even happened.
|
|
355
|
+
if normalized_method == "HEAD"
|
|
356
|
+
(method_index["GET"] || []).each do |route|
|
|
357
|
+
params = route.match_path(normalized_path)
|
|
358
|
+
return [route, params] if params
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
328
362
|
nil
|
|
329
363
|
end
|
|
330
364
|
|
|
365
|
+
# Return the list of HTTP methods registered for ``path``, in the order
|
|
366
|
+
# GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS. Used by the
|
|
367
|
+
# dispatcher to build the ``Allow:`` header on 405 / OPTIONS responses
|
|
368
|
+
# (RFC 9110 §10.2.1, §9.3.7).
|
|
369
|
+
#
|
|
370
|
+
# If GET is registered for the path, HEAD is appended implicitly
|
|
371
|
+
# (HEAD auto-fallback). OPTIONS is appended whenever the path has any
|
|
372
|
+
# registered method (the framework auto-handles OPTIONS).
|
|
373
|
+
def methods_allowed_for_path(path)
|
|
374
|
+
normalized_path = path.gsub("\\", "/")
|
|
375
|
+
normalized_path = "/#{normalized_path}" unless normalized_path.start_with?("/")
|
|
376
|
+
normalized_path = normalized_path.chomp("/") unless normalized_path == "/"
|
|
377
|
+
|
|
378
|
+
method_order = %w[GET POST PUT PATCH DELETE HEAD OPTIONS]
|
|
379
|
+
seen = []
|
|
380
|
+
any_matched = false
|
|
381
|
+
|
|
382
|
+
method_index.each do |m, routes_for_method|
|
|
383
|
+
next if routes_for_method.empty?
|
|
384
|
+
matched = routes_for_method.any? { |r| r.match_path(normalized_path) }
|
|
385
|
+
next unless matched
|
|
386
|
+
if m == "ANY"
|
|
387
|
+
any_matched = true
|
|
388
|
+
elsif method_order.include?(m)
|
|
389
|
+
seen << m unless seen.include?(m)
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
seen = method_order.dup if any_matched
|
|
394
|
+
|
|
395
|
+
if !seen.empty?
|
|
396
|
+
seen << "HEAD" if seen.include?("GET") && !seen.include?("HEAD")
|
|
397
|
+
seen << "OPTIONS" unless seen.include?("OPTIONS")
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
method_order.select { |m| seen.include?(m) }
|
|
401
|
+
end
|
|
402
|
+
|
|
331
403
|
# When TINA4_TRAILING_SLASH_REDIRECT is truthy, the rack app uses this
|
|
332
404
|
# to detect whether the *original* (un-stripped) path differed from the
|
|
333
405
|
# canonical form so it can issue a 301 redirect. Default false — silent
|
|
@@ -369,17 +441,79 @@ module Tina4
|
|
|
369
441
|
GroupContext.new(prefix, auth_handler, middleware).instance_eval(&block)
|
|
370
442
|
end
|
|
371
443
|
|
|
372
|
-
# Load route files from a directory (file-based route discovery)
|
|
444
|
+
# Load route files from a directory (file-based route discovery).
|
|
445
|
+
#
|
|
446
|
+
# Idempotent: files already loaded by a previous call are skipped, so
|
|
447
|
+
# calling load_routes repeatedly (e.g. on /__dev/api/reload) only
|
|
448
|
+
# picks up NEW files. Records the directory so #rescan_routes! can
|
|
449
|
+
# re-run without re-passing it.
|
|
373
450
|
def load_routes(directory)
|
|
374
451
|
return unless Dir.exist?(directory)
|
|
375
|
-
|
|
452
|
+
|
|
453
|
+
@loaded_route_files ||= {}
|
|
454
|
+
@last_routes_dir = directory
|
|
455
|
+
|
|
456
|
+
files = Dir.glob(File.join(directory, "**/*.rb")).sort
|
|
457
|
+
total = files.length
|
|
458
|
+
files.each do |file|
|
|
459
|
+
next if @loaded_route_files[file]
|
|
376
460
|
begin
|
|
377
461
|
load file
|
|
462
|
+
@loaded_route_files[file] = true
|
|
378
463
|
Tina4::Log.debug("Route loaded: #{file}")
|
|
379
|
-
rescue => e
|
|
464
|
+
rescue ScriptError, StandardError => e
|
|
465
|
+
# ScriptError catches SyntaxError, which is NOT a StandardError —
|
|
466
|
+
# a bare `rescue => e` would let a syntax-broken route file crash
|
|
467
|
+
# the whole discovery pass.
|
|
380
468
|
Tina4::Log.error("Failed to load route #{file}: #{e.message}")
|
|
469
|
+
record_broken_route_import(file, e)
|
|
381
470
|
end
|
|
382
471
|
end
|
|
472
|
+
|
|
473
|
+
# Zero-routes warning — src/routes/ has .rb files but the router
|
|
474
|
+
# is still empty. Almost certainly the user forgot Tina4::Router.get.
|
|
475
|
+
if total > 0 && routes.empty?
|
|
476
|
+
Tina4::Log.warning(
|
|
477
|
+
"Auto-discover found #{total} .rb file(s) in #{directory} but no routes registered. " \
|
|
478
|
+
"Each route file must call Tina4::Router.get / .post / etc."
|
|
479
|
+
)
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Re-run the most recent load_routes — called by /__dev/api/reload so
|
|
484
|
+
# files dropped into src/routes/ after server boot get picked up
|
|
485
|
+
# without a restart. No-op if load_routes has never been called.
|
|
486
|
+
def rescan_routes!
|
|
487
|
+
return [] if @last_routes_dir.nil? || @last_routes_dir.empty?
|
|
488
|
+
before = routes.length
|
|
489
|
+
load_routes(@last_routes_dir)
|
|
490
|
+
added = routes.length - before
|
|
491
|
+
Tina4::Log.info("Re-discovered #{added} new route(s) on reload") if added.positive?
|
|
492
|
+
added
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Test-only helper — reset the loaded-files state so tests can scan
|
|
496
|
+
# the same directory multiple times with different file contents.
|
|
497
|
+
def reset_route_discovery!
|
|
498
|
+
@loaded_route_files = {}
|
|
499
|
+
@last_routes_dir = nil
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Write a .broken sentinel so /health and the dev dashboard surface
|
|
503
|
+
# auto-discover failures instead of swallowing them into a log line.
|
|
504
|
+
def record_broken_route_import(file, error)
|
|
505
|
+
broken_dir = File.join(Dir.pwd, "data", ".broken")
|
|
506
|
+
FileUtils.mkdir_p(broken_dir) unless Dir.exist?(broken_dir)
|
|
507
|
+
slug = file.gsub(%r{[/\\]}, "_")
|
|
508
|
+
payload = JSON.generate(
|
|
509
|
+
type: "auto_discover_failure",
|
|
510
|
+
file: file,
|
|
511
|
+
error: "#{error.class}: #{error.message}"
|
|
512
|
+
)
|
|
513
|
+
File.write(File.join(broken_dir, "discover_#{slug}.broken"), payload)
|
|
514
|
+
rescue StandardError
|
|
515
|
+
# If the .broken write itself fails, the original error is already
|
|
516
|
+
# in the log — nothing more to do.
|
|
383
517
|
end
|
|
384
518
|
end
|
|
385
519
|
|
data/lib/tina4/test.rb
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Tina4 — The Intelligent Native Application 4ramework
|
|
4
|
+
# Copyright 2007 - current Tina4
|
|
5
|
+
# License: MIT https://opensource.org/licenses/MIT
|
|
6
|
+
|
|
7
|
+
module Tina4
|
|
8
|
+
# Tina4 xUnit-style test base class — class-based test suites with HTTP
|
|
9
|
+
# helpers and positional assertions. Zero external dependencies.
|
|
10
|
+
#
|
|
11
|
+
# Documentation chapter 18 has long described:
|
|
12
|
+
#
|
|
13
|
+
# class UserApiTest < Tina4::Test
|
|
14
|
+
# def test_health
|
|
15
|
+
# resp = get("/health")
|
|
16
|
+
# assert_equal_value(resp.status, 200)
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Until 3.13.0 this class did not exist — examples crashed with
|
|
21
|
+
# "uninitialized constant Tina4::Test". Ruby parity of the
|
|
22
|
+
# Python `tina4_python.test.Test` and PHP `Tina4\Test` classes.
|
|
23
|
+
#
|
|
24
|
+
# The class has a built-in runner (no Minitest/RSpec required):
|
|
25
|
+
#
|
|
26
|
+
# results = Tina4::Test.run_all # discovers all subclasses
|
|
27
|
+
# # => { passed: 12, failed: 0, errors: 0, details: [...] }
|
|
28
|
+
#
|
|
29
|
+
# Or run a single suite class:
|
|
30
|
+
#
|
|
31
|
+
# UserApiTest.run!
|
|
32
|
+
#
|
|
33
|
+
# HTTP helpers (get/post/put/patch/delete) delegate to TestClient.
|
|
34
|
+
# Positional assertions match the Python (actual, expected, message)
|
|
35
|
+
# shape used throughout the cross-framework docs.
|
|
36
|
+
class Test
|
|
37
|
+
# Class-level registry so run_all can discover every subclass without
|
|
38
|
+
# filesystem scanning.
|
|
39
|
+
@subclasses = []
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
attr_reader :subclasses
|
|
43
|
+
|
|
44
|
+
def inherited(subclass)
|
|
45
|
+
super
|
|
46
|
+
Test.subclasses << subclass
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Run every test method (`test_*`) on the calling subclass. Returns
|
|
50
|
+
# a hash with passed/failed/errors counts and per-test details.
|
|
51
|
+
def run!
|
|
52
|
+
instance_methods(false).grep(/\Atest_/).sort.each_with_object(
|
|
53
|
+
{ passed: 0, failed: 0, errors: 0, details: [] }
|
|
54
|
+
) do |method, results|
|
|
55
|
+
suite = new
|
|
56
|
+
begin
|
|
57
|
+
suite.send(:set_up)
|
|
58
|
+
suite.send(method)
|
|
59
|
+
suite.send(:tear_down)
|
|
60
|
+
results[:passed] += 1
|
|
61
|
+
results[:details] << { suite: name, test: method, status: "passed" }
|
|
62
|
+
rescue AssertionError => e
|
|
63
|
+
results[:failed] += 1
|
|
64
|
+
results[:details] << { suite: name, test: method, status: "failed", message: e.message }
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
results[:errors] += 1
|
|
67
|
+
results[:details] << { suite: name, test: method, status: "error", message: "#{e.class}: #{e.message}" }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Discover and run every Tina4::Test subclass.
|
|
73
|
+
def run_all(quiet: false)
|
|
74
|
+
results = { passed: 0, failed: 0, errors: 0, details: [] }
|
|
75
|
+
Test.subclasses.each do |klass|
|
|
76
|
+
out = klass.run!
|
|
77
|
+
results[:passed] += out[:passed]
|
|
78
|
+
results[:failed] += out[:failed]
|
|
79
|
+
results[:errors] += out[:errors]
|
|
80
|
+
results[:details].concat(out[:details])
|
|
81
|
+
end
|
|
82
|
+
unless quiet
|
|
83
|
+
puts "Tina4 Test results: #{results[:passed]} passed, #{results[:failed]} failed, #{results[:errors]} errors"
|
|
84
|
+
end
|
|
85
|
+
results
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ── Lifecycle hooks ──────────────────────────────────────────────
|
|
90
|
+
# snake_case Tina4 idiom; override in subclasses.
|
|
91
|
+
|
|
92
|
+
def set_up
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def tear_down
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# ── HTTP test client (lazy) ───────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
def test_client
|
|
101
|
+
@test_client ||= Tina4::TestClient.new
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def get(path, headers: nil)
|
|
105
|
+
test_client.get(path, headers: headers)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def post(path, json: nil, body: nil, headers: nil)
|
|
109
|
+
test_client.post(path, json: json, body: body, headers: headers)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def put(path, json: nil, body: nil, headers: nil)
|
|
113
|
+
test_client.put(path, json: json, body: body, headers: headers)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def patch(path, json: nil, body: nil, headers: nil)
|
|
117
|
+
test_client.patch(path, json: json, body: body, headers: headers)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def delete(path, headers: nil)
|
|
121
|
+
test_client.delete(path, headers: headers)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# ── Positional assertions — (actual, expected, message) shape ─────
|
|
125
|
+
|
|
126
|
+
def assert_equal_value(actual, expected, message = nil)
|
|
127
|
+
return if actual == expected
|
|
128
|
+
|
|
129
|
+
raise AssertionError, message || "Expected #{expected.inspect}, got #{actual.inspect}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def assert_not_equal_value(actual, expected, message = nil)
|
|
133
|
+
return unless actual == expected
|
|
134
|
+
|
|
135
|
+
raise AssertionError, message || "Expected #{actual.inspect} != #{expected.inspect}, but they are equal"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def assert_true(value, message = nil)
|
|
139
|
+
return if value
|
|
140
|
+
|
|
141
|
+
raise AssertionError, message || "Expected truthy, got #{value.inspect}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def assert_false(value, message = nil)
|
|
145
|
+
return unless value
|
|
146
|
+
|
|
147
|
+
raise AssertionError, message || "Expected falsy, got #{value.inspect}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def assert_nil_value(value, message = nil)
|
|
151
|
+
return if value.nil?
|
|
152
|
+
|
|
153
|
+
raise AssertionError, message || "Expected nil, got #{value.inspect}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def assert_not_nil_value(value, message = nil)
|
|
157
|
+
return unless value.nil?
|
|
158
|
+
|
|
159
|
+
raise AssertionError, message || "Expected non-nil, got nil"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def assert_raises(expected_class, message = nil)
|
|
163
|
+
raise ArgumentError, "Block required" unless block_given?
|
|
164
|
+
|
|
165
|
+
begin
|
|
166
|
+
yield
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
return if e.is_a?(expected_class)
|
|
169
|
+
|
|
170
|
+
raise AssertionError,
|
|
171
|
+
message || "Expected #{expected_class}, got #{e.class}: #{e.message}"
|
|
172
|
+
end
|
|
173
|
+
raise AssertionError, message || "Expected #{expected_class} to be raised, but nothing was"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# AssertionError raised by Tina4::Test assertion helpers on failure.
|
|
178
|
+
class AssertionError < StandardError; end
|
|
179
|
+
end
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4.rb
CHANGED
|
@@ -44,6 +44,7 @@ require_relative "tina4/events"
|
|
|
44
44
|
require_relative "tina4/plan"
|
|
45
45
|
require_relative "tina4/project_index"
|
|
46
46
|
require_relative "tina4/dev_admin"
|
|
47
|
+
require_relative "tina4/feedback"
|
|
47
48
|
require_relative "tina4/messenger"
|
|
48
49
|
require_relative "tina4/dev_mailbox"
|
|
49
50
|
require_relative "tina4/ai"
|
|
@@ -53,6 +54,7 @@ require_relative "tina4/response_cache"
|
|
|
53
54
|
require_relative "tina4/html_element"
|
|
54
55
|
require_relative "tina4/error_overlay"
|
|
55
56
|
require_relative "tina4/test_client"
|
|
57
|
+
require_relative "tina4/test"
|
|
56
58
|
require_relative "tina4/docs"
|
|
57
59
|
require_relative "tina4/mcp"
|
|
58
60
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.13.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-06-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -306,6 +306,7 @@ files:
|
|
|
306
306
|
- lib/tina4/env.rb
|
|
307
307
|
- lib/tina4/error_overlay.rb
|
|
308
308
|
- lib/tina4/events.rb
|
|
309
|
+
- lib/tina4/feedback.rb
|
|
309
310
|
- lib/tina4/field_types.rb
|
|
310
311
|
- lib/tina4/frond.rb
|
|
311
312
|
- lib/tina4/gallery/auth/meta.json
|
|
@@ -337,6 +338,7 @@ files:
|
|
|
337
338
|
- lib/tina4/orm.rb
|
|
338
339
|
- lib/tina4/plan.rb
|
|
339
340
|
- lib/tina4/project_index.rb
|
|
341
|
+
- lib/tina4/public/__feedback/widget.js
|
|
340
342
|
- lib/tina4/public/css/tina4.css
|
|
341
343
|
- lib/tina4/public/css/tina4.min.css
|
|
342
344
|
- lib/tina4/public/favicon.ico
|
|
@@ -400,6 +402,7 @@ files:
|
|
|
400
402
|
- lib/tina4/templates/errors/502.twig
|
|
401
403
|
- lib/tina4/templates/errors/503.twig
|
|
402
404
|
- lib/tina4/templates/errors/base.twig
|
|
405
|
+
- lib/tina4/test.rb
|
|
403
406
|
- lib/tina4/test_client.rb
|
|
404
407
|
- lib/tina4/testing.rb
|
|
405
408
|
- lib/tina4/validator.rb
|