tina4ruby 3.13.3 → 3.13.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6806b64505d23caba2d53a7b08b1411aa1e0ca924ea883d660dedae1c81f5766
4
- data.tar.gz: e168b0a72f99e8cab66c1532a0909d480e10e330c65104749ec8dafb4e26837d
3
+ metadata.gz: f5a305c0c2ee88795cac09fabe5c275165fb83d6da1d83bc301a8097660d711c
4
+ data.tar.gz: 68ee188aef588fd0e09d7779771de3eb47ed7ab5d9cfe426d3840b3015000064
5
5
  SHA512:
6
- metadata.gz: fd1d7d614b01886cb2e70ffcd04503ce3155073350f70dc92e6414816c5993a39b63b91965f49c30ea3275a6c72b62eea510ba64db74db0384ad925d7bdb0b76
7
- data.tar.gz: 745992728883c28e11d3716977a9bb9e5f58d4a043b7aa285c6dc8113b8c95d3ffef10ac7b52aa339ef0172c308687f6c3004926af1daf1f4a3737601c008994
6
+ metadata.gz: af2cb8dd1b51f7569d864aa41c4f0c52105696bea48e6c6fb50dd76b1d9bd11cf5e724cfdccb717bf5d42e2e84f032e10af1ada89ce9a3b74e660bc0a6f7c4f0
7
+ data.tar.gz: 75b8d8f25a7efcfb38606414e1457b3bdbf0bedbe3d1ee0ec3b5dea2ee0a58ddb5292a9f1078dc212a9cc43df0625122e3a288d2fb78437479cd15bc26efeecd
data/lib/tina4/frond.rb CHANGED
@@ -21,6 +21,33 @@ module Tina4
21
21
  end
22
22
 
23
23
  class Frond
24
+ # -- Class-level registries ------------------------------------------------
25
+ # Persist globals, filters, and tests across hot-reloads and across module
26
+ # boundaries. When app.rb does ``Tina4::Frond.add_filter("money") { ... }``
27
+ # at startup before any instance exists, the registration sits here. Every
28
+ # subsequent ``Tina4::Frond.new`` drains these into its instance-local
29
+ # registries — so hot-reloads (which re-execute ``frond = Frond.new``) and
30
+ # late-constructed engines automatically inherit prior registrations.
31
+ #
32
+ # The same-name dual-callable (class + instance) methods below let callers
33
+ # write either ``Tina4::Frond.add_filter(...)`` (class-level only) or
34
+ # ``frond.add_filter(...)`` (updates both the class registry and the
35
+ # instance's live filter map). Parity with tina4-python's
36
+ # ``_ClassOrInstanceMethod`` descriptor.
37
+ @@class_filters = {}
38
+ @@class_globals = {}
39
+ @@class_tests = {}
40
+
41
+ # Clear the class-level globals/filters/tests registries.
42
+ #
43
+ # Useful in test fixtures to prevent leaking state between tests. Does
44
+ # NOT affect built-in filters or globals — only user-registered ones.
45
+ def self.clear_registry
46
+ @@class_filters = {}
47
+ @@class_globals = {}
48
+ @@class_tests = {}
49
+ end
50
+
24
51
  # -- Token types ----------------------------------------------------------
25
52
  TEXT = :text
26
53
  VAR = :var # {{ ... }}
@@ -187,6 +214,16 @@ module Tina4
187
214
 
188
215
  # Built-in global functions
189
216
  register_builtin_globals
217
+
218
+ # Drain class-level registries into this instance. Filters and tests
219
+ # registered via ``Tina4::Frond.add_filter`` BEFORE this instance was
220
+ # constructed flow in here. Globals likewise. This is the key to the
221
+ # static-facade: ``app.rb`` registers once at startup, and every
222
+ # Frond instance created later (including those born from hot-reloads)
223
+ # automatically inherits the registration. Parity with tina4-python.
224
+ @filters.merge!(@@class_filters)
225
+ @globals.merge!(@@class_globals)
226
+ @tests.merge!(@@class_tests)
190
227
  end
191
228
 
192
229
  # Render a template file with data. Uses token caching for performance.
@@ -249,19 +286,61 @@ module Tina4
249
286
  @dotted_split_cache.clear
250
287
  end
251
288
 
289
+ # Register a custom filter on the class registry only.
290
+ #
291
+ # Callable as ``Tina4::Frond.add_filter("money") { |v| ... }`` at app
292
+ # startup BEFORE any instance exists. The registration is remembered at
293
+ # class level so every later ``Tina4::Frond.new`` inherits it. To also
294
+ # update a live instance's filter map, use the instance method form.
295
+ def self.add_filter(name, &blk)
296
+ @@class_filters[name.to_s] = blk
297
+ end
298
+
299
+ # Register a custom test on the class registry only.
300
+ #
301
+ # Same dual-callable semantics as ``add_filter`` — see that method for
302
+ # the static-facade pattern.
303
+ def self.add_test(name, &blk)
304
+ @@class_tests[name.to_s] = blk
305
+ end
306
+
307
+ # Register a global variable on the class registry only.
308
+ #
309
+ # Same dual-callable semantics as ``add_filter`` — see that method for
310
+ # the static-facade pattern.
311
+ def self.add_global(name, value)
312
+ @@class_globals[name.to_s] = value
313
+ end
314
+
252
315
  # Register a custom filter.
316
+ #
317
+ # Updates BOTH the class registry (so future ``Tina4::Frond.new`` picks
318
+ # the filter up) AND this instance's live filter map (so the change is
319
+ # visible to subsequent renders on the current engine).
253
320
  def add_filter(name, &blk)
321
+ self.class.add_filter(name, &blk)
254
322
  @filters[name.to_s] = blk
323
+ self
255
324
  end
256
325
 
257
326
  # Register a custom test.
327
+ #
328
+ # Updates BOTH the class registry and this instance's live tests map.
329
+ # See ``add_filter`` for the dual-write semantics.
258
330
  def add_test(name, &blk)
331
+ self.class.add_test(name, &blk)
259
332
  @tests[name.to_s] = blk
333
+ self
260
334
  end
261
335
 
262
336
  # Register a global variable available in all templates.
337
+ #
338
+ # Updates BOTH the class registry and this instance's live globals map.
339
+ # See ``add_filter`` for the dual-write semantics.
263
340
  def add_global(name, value)
341
+ self.class.add_global(name, value)
264
342
  @globals[name.to_s] = value
343
+ self
265
344
  end
266
345
 
267
346
  # Enable sandbox mode.
data/lib/tina4/mcp.rb CHANGED
@@ -612,8 +612,12 @@ module Tina4
612
612
  line = err_lines.first.strip
613
613
  # Strip the absolute project_root prefix so the error reads
614
614
  # as "src/routes/foo.rb:3: syntax error, ..." instead of the
615
- # full /Users/... path.
616
- line.sub("#{project_root}/", "")
615
+ # full /Users/... path. Use gsub because Ruby 3.2's MRI parser
616
+ # double-prints the path — once as the file label prefix and
617
+ # once inside the (SyntaxError) reference — and the test asserts
618
+ # the absolute path appears nowhere in import_error.
619
+ # See: spec/mcp_spec.rb defensive file_write test (CI Ruby 3.2).
620
+ line.gsub("#{project_root}/", "")
617
621
  end
618
622
 
619
623
  redact_env = lambda do |key, value|
@@ -108,22 +108,29 @@ module Tina4
108
108
  else
109
109
  # RFC 9110 conformance — before falling through to 404, check whether
110
110
  # the PATH is known to the router under any OTHER method.
111
- # - OPTIONS request → 204 with Allow header (§9.3.7)
111
+ # - OPTIONS request → 204 with Allow header (§9.3.7). Bare OPTIONS
112
+ # on an unknown path also returns 204 (empty Allow header) —
113
+ # OPTIONS is a discovery method; rejecting unknown probes with
114
+ # 404 confuses link checkers and monitoring tools and breaks
115
+ # CORS preflight that lacks the Origin/ACRM headers our earlier
116
+ # fast-path requires. Matches PHP/Node behaviour. Fixes
117
+ # spec/rack_app_spec.rb OPTIONS preflight.
112
118
  # - Any other method (PUT on GET-only, TRACE, CONNECT, etc.)
113
- # → 405 with Allow header (§15.5.6 + §10.2.1)
119
+ # → 405 with Allow header (§15.5.6 + §10.2.1) when the path
120
+ # exists; → 404 when nothing about the path is known.
114
121
  allowed = Tina4::Router.methods_allowed_for_path(path)
115
- if !allowed.empty?
122
+ if method.to_s.upcase == "OPTIONS"
123
+ allow_header = allowed.empty? ? "" : allowed.join(", ")
124
+ rack_response = [204, { "allow" => allow_header, "content-length" => "0" }, [""]]
125
+ matched_pattern = nil
126
+ elsif !allowed.empty?
116
127
  allow_header = allowed.join(", ")
117
- if method.to_s.upcase == "OPTIONS"
118
- rack_response = [204, { "allow" => allow_header, "content-length" => "0" }, [""]]
119
- else
120
- body = %({"error":"Method Not Allowed","path":"#{path}","method":"#{method}","allow":[#{allowed.map { |m| %("#{m}") }.join(",")}],"status":405})
121
- rack_response = [405, {
122
- "allow" => allow_header,
123
- "content-type" => "application/json",
124
- "content-length" => body.bytesize.to_s
125
- }, [body]]
126
- end
128
+ body = %({"error":"Method Not Allowed","path":"#{path}","method":"#{method}","allow":[#{allowed.map { |m| %("#{m}") }.join(",")}],"status":405})
129
+ rack_response = [405, {
130
+ "allow" => allow_header,
131
+ "content-type" => "application/json",
132
+ "content-length" => body.bytesize.to_s
133
+ }, [body]]
127
134
  matched_pattern = nil
128
135
  else
129
136
  rack_response = handle_404(path)
@@ -317,19 +324,43 @@ module Tina4
317
324
  end
318
325
  end
319
326
 
320
- # Execute handler inject path params by name, then request/response
327
+ # Build the route-handler call as a lambda so function-style
328
+ # middleware can wrap it. Path params are still bound by name —
329
+ # the continuation just forwards the (possibly-mutated)
330
+ # request/response pair the outer middleware chose to pass in.
321
331
  handler_params = route.handler.parameters.map(&:last)
322
332
  route_params = path_params || {}
323
- args = handler_params.map do |name|
324
- if route_params.key?(name)
325
- route_params[name]
326
- elsif name == :request || name == :req
327
- request
328
- else
329
- response
333
+ invoke_handler = lambda do |req, resp|
334
+ args = handler_params.map do |name|
335
+ if route_params.key?(name)
336
+ route_params[name]
337
+ elsif name == :request || name == :req
338
+ req
339
+ else
340
+ resp
341
+ end
342
+ end
343
+ args.empty? ? route.handler.call : route.handler.call(*args)
344
+ end
345
+
346
+ # Fold any function-style middleware on this route into a
347
+ # Russian-doll chain wrapping the handler. First declared is the
348
+ # outermost layer — it receives the request first, calls
349
+ # next_handler to descend, and runs its "after" code on the way
350
+ # out. Class-based middleware (before_*/after_*) is handled
351
+ # separately by run_middleware above and never goes through here.
352
+ # tina4-book#141 PY-10-01 (cross-framework parity).
353
+ fn_mws = route.respond_to?(:function_middleware) ? route.function_middleware : []
354
+ if fn_mws.empty?
355
+ result = invoke_handler.call(request, response)
356
+ else
357
+ chain = invoke_handler
358
+ fn_mws.reverse_each do |mw|
359
+ inner = chain
360
+ chain = lambda { |req, resp| mw.call(req, resp, inner) }
330
361
  end
362
+ result = chain.call(request, response)
331
363
  end
332
- result = args.empty? ? route.handler.call : route.handler.call(*args)
333
364
 
334
365
  # Template rendering: when a template is set and the handler returned a Hash,
335
366
  # render the template with the hash as data and return the HTML response.
data/lib/tina4/request.rb CHANGED
@@ -35,6 +35,63 @@ module Tina4
35
35
  end
36
36
  end
37
37
 
38
+ # Hash subclass for HTTP headers — string keys are case-insensitive.
39
+ #
40
+ # HTTP header field-names are case-insensitive per RFC 7230 §3.2. With
41
+ # this class, request.headers["Content-Type"], .headers["content-type"],
42
+ # and .headers["CONTENT-TYPE"] all return the same value. Keys are
43
+ # stored lowercase internally.
44
+ #
45
+ # Cross-framework parity: same behaviour ships in tina4-python
46
+ # (CaseInsensitiveDict), tina4-php (Tina4\Request), tina4-nodejs
47
+ # (Tina4Request). tina4-book#141 PY-10-03 — chapter 10 examples
48
+ # documented headers["Content-Type"] for years; this makes them work.
49
+ class CaseInsensitiveHash < Hash
50
+ def [](key)
51
+ super(normalize_key(key))
52
+ end
53
+
54
+ def []=(key, value)
55
+ super(normalize_key(key), value)
56
+ end
57
+
58
+ def fetch(key, *args, &block)
59
+ super(normalize_key(key), *args, &block)
60
+ end
61
+
62
+ def key?(key)
63
+ super(normalize_key(key))
64
+ end
65
+ alias has_key? key?
66
+ alias include? key?
67
+ alias member? key?
68
+
69
+ def delete(key, &block)
70
+ super(normalize_key(key), &block)
71
+ end
72
+
73
+ def store(key, value)
74
+ super(normalize_key(key), value)
75
+ end
76
+
77
+ def merge(other)
78
+ result = dup
79
+ other.each { |k, v| result[k] = v }
80
+ result
81
+ end
82
+
83
+ def merge!(other)
84
+ other.each { |k, v| self[k] = v }
85
+ self
86
+ end
87
+
88
+ private
89
+
90
+ def normalize_key(key)
91
+ key.is_a?(String) ? key.downcase : key
92
+ end
93
+ end
94
+
38
95
  class Request
39
96
  attr_reader :env, :method, :path, :query_string, :content_type,
40
97
  :path_params, :ip
@@ -139,7 +196,10 @@ module Tina4
139
196
  end
140
197
 
141
198
  def header(name)
142
- headers[name.to_s.downcase.gsub("-", "_")]
199
+ # Headers are stored in a CaseInsensitiveHash keyed by lowercase-
200
+ # dashed names ("content-type", "x-api-key"). The hash normalises
201
+ # the lookup case automatically, so pass the dashed form through.
202
+ headers[name.to_s.tr("_", "-")]
143
203
  end
144
204
 
145
205
  def json_body
@@ -169,12 +229,22 @@ module Tina4
169
229
  end
170
230
 
171
231
  def extract_headers
172
- h = {}
232
+ h = CaseInsensitiveHash.new
173
233
  @env.each do |key, value|
174
234
  if key.start_with?("HTTP_")
175
- h[key[5..-1].downcase] = value
235
+ # Rack normalises "Content-Type" to "HTTP_CONTENT_TYPE" — we
236
+ # store as "content-type" (lowercase, dashed) so both
237
+ # headers["Content-Type"] and the legacy headers["content_type"]
238
+ # via the `header()` helper resolve to the same value. The
239
+ # CaseInsensitiveHash handles the case-insensitive part.
240
+ h[key[5..-1].tr("_", "-").downcase] = value
176
241
  end
177
242
  end
243
+ # CONTENT_TYPE / CONTENT_LENGTH live at the top level of the Rack
244
+ # env (no HTTP_ prefix per the Rack spec) — surface them too so
245
+ # request.headers["Content-Type"] works for POST bodies.
246
+ h["content-type"] = @env["CONTENT_TYPE"] if @env["CONTENT_TYPE"] && !@env["CONTENT_TYPE"].empty?
247
+ h["content-length"] = @env["CONTENT_LENGTH"] if @env["CONTENT_LENGTH"] && !@env["CONTENT_LENGTH"].to_s.empty?
178
248
  h
179
249
  end
180
250
 
data/lib/tina4/router.rb CHANGED
@@ -17,9 +17,11 @@ module Tina4
17
17
  @swagger_meta = swagger_meta
18
18
  @middleware = middleware.freeze
19
19
  @template = template&.freeze
20
- # Write routes are secure by default, unless custom middleware is registered
21
- # (developer handles auth themselves via middleware)
22
- @auth_required = %w[POST PUT PATCH DELETE].include?(@method) && middleware.empty?
20
+ # Write routes are secure by default bearer-token auth is enforced
21
+ # on POST/PUT/PATCH/DELETE regardless of attached middleware.
22
+ # Middleware is additive, never an auth bypass. tina4-book#141
23
+ # PY-10-02. Call .no_auth on the route to opt out explicitly.
24
+ @auth_required = %w[POST PUT PATCH DELETE].include?(@method)
23
25
  @cached = false
24
26
  @param_names = []
25
27
  @path_regex = compile_pattern(@path)
@@ -50,13 +52,18 @@ module Tina4
50
52
  # Dual-mode: getter (no args) returns the middleware array;
51
53
  # setter (with args) appends middleware and returns self for chaining.
52
54
  # Router.post("/api") { ... }.middleware(AuthMiddleware)
55
+ #
56
+ # Middleware is purely additive — registering middleware NEVER flips
57
+ # @auth_required off. The secure-by-default gate for write methods
58
+ # (POST/PUT/PATCH/DELETE) stays in effect; if a route truly wants to
59
+ # opt out of the built-in bearer check, call .no_auth explicitly.
60
+ # tina4-book#141 PY-10-02 — previously, attaching ANY middleware
61
+ # silently turned off auth_required, which let attackers bypass auth
62
+ # by routing through a logging middleware. Cross-framework parity.
53
63
  def middleware(*middleware_classes)
54
64
  return @middleware if middleware_classes.empty?
55
65
 
56
66
  @middleware = @middleware.dup + middleware_classes
57
- # Custom middleware means developer handles auth — disable built-in gate
58
- # unless .secure was explicitly called.
59
- @auth_required = false unless @auth_required
60
67
  self
61
68
  end
62
69
 
@@ -83,15 +90,50 @@ module Tina4
83
90
  end
84
91
  end
85
92
 
86
- # Run per-route middleware chain; returns true if all pass
93
+ # Run per-route CLASS-based middleware. Function-style middleware
94
+ # (Proc/Lambda taking 3+ args: req, resp, next_handler) is skipped
95
+ # here — it's dispatched separately via #function_middleware which
96
+ # wraps the route handler in a continuation chain (see rack_app.rb).
97
+ #
98
+ # Returns true if all class-based middleware passed, false if any
99
+ # returned literal false (halt request).
87
100
  def run_middleware(request, response)
88
101
  @middleware.each do |mw|
102
+ next if Route.function_middleware?(mw)
89
103
  result = mw.call(request, response)
90
104
  return false if result == false
91
105
  end
92
106
  true
93
107
  end
94
108
 
109
+ # Function-style middleware attached to this route, in declaration
110
+ # order. The route dispatcher folds them into a Russian-doll
111
+ # continuation chain — first declared is the OUTERMOST layer (runs
112
+ # first on the way in, last on the way out). tina4-book#141
113
+ # PY-10-01 — chapter 10 documented 8+ examples of function middleware
114
+ # for years; before this fix the framework silently ignored them.
115
+ def function_middleware
116
+ @middleware.select { |mw| Route.function_middleware?(mw) }
117
+ end
118
+
119
+ # Detect Express/FastAPI-style function middleware.
120
+ #
121
+ # A Proc/Lambda/Method whose arity indicates 3+ positional params
122
+ # (req, resp, next_handler). Ruby arity quirk: required-args-only
123
+ # arity is non-negative; if the callable accepts a splat or
124
+ # optionals, arity is negated (-required-1). We treat arity >= 3
125
+ # OR arity <= -4 as function-style. Anything else (a class with
126
+ # before_*/after_* methods, or a 2-arg callable) is treated as
127
+ # class-style and goes through #run_middleware.
128
+ def self.function_middleware?(mw)
129
+ return false if mw.is_a?(Class) || mw.is_a?(Module)
130
+ return false unless mw.respond_to?(:arity)
131
+ ar = mw.arity
132
+ ar >= 3 || ar <= -4
133
+ rescue StandardError
134
+ false
135
+ end
136
+
95
137
  private
96
138
 
97
139
  def normalize_path(path)
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.3"
4
+ VERSION = "3.13.5"
5
5
  end
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.13.3
4
+ version: 3.13.5
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-06-03 00:00:00.000000000 Z
11
+ date: 2026-06-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack