tina4ruby 3.12.6 → 3.12.8

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: f06595b833e1c285fbcadb4211e9a3ccbebda10bea925398a9c4f42e8efc243a
4
- data.tar.gz: b6bf9dee3edcee5fb67bd566c64611f1f66e7543f672e4fe97e0ecdf014a247d
3
+ metadata.gz: 351a496d9e7573b3c2f9c45bb170612d3c9f553f787b937d1a402b0f351cb427
4
+ data.tar.gz: bec0461f253af01dbf747d346bda6d705eada5538fd36adb7741ae2d6a0fafd1
5
5
  SHA512:
6
- metadata.gz: 9a853450a4e85d57a335e578cac344bc36d7af46c01e7593c4023ff05a87fcccf11a251950510d7ba821d4037f05c4d64372c67337323f580b7cc6e09e8baba5
7
- data.tar.gz: ec476de6af1ec9b16a62263ccd97a2ce36942d729ec2d8b16ab449f64b39cc1926a9d0ba9271ef1ff4427aaf1a38db77273b95e0f28e3a03afbecd295c745d01
6
+ metadata.gz: a86b778c89cffba395091c7f1d5e7c9748ca6b95387b0748f4bef5f47d0c4c606f932f535ff0a6b5eef7de8e74970ba25b6c2b53a5d3bc63df3c3f29efae5020
7
+ data.tar.gz: 755890be8270b90bcbbc20d5fae1e76aad5e507346c6490196077143576268723736ed5f88e7ab6e10fc1391f59806dc25bcb18b5ca47730151e76c3b1946bab
@@ -43,8 +43,18 @@ module Tina4
43
43
  path = env["PATH_INFO"] || "/"
44
44
  request_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
45
 
46
- # Fast-path: OPTIONS preflight
47
- return Tina4::CorsMiddleware.preflight_response(env) if method == "OPTIONS"
46
+ # Fast-path: CORS preflight. Real CORS preflight requests carry an
47
+ # Origin header AND an Access-Control-Request-Method header — the
48
+ # browser is asking "may I send this method?" before the actual
49
+ # request. If neither is present, the OPTIONS is a plain protocol-
50
+ # introspection request (link checker, monitoring probe, RFC 9110
51
+ # §9.3.7 OPTIONS) and must fall through to the router's generic
52
+ # Allow-header response. Otherwise we'd shadow the framework's own
53
+ # OPTIONS support and force every operator to hand-register CORS
54
+ # exceptions for every introspection client.
55
+ if method == "OPTIONS" && (env["HTTP_ORIGIN"] || env["HTTP_ACCESS_CONTROL_REQUEST_METHOD"])
56
+ return Tina4::CorsMiddleware.preflight_response(env)
57
+ end
48
58
 
49
59
  # WebSocket upgrade — match against registered ws_routes
50
60
  if websocket_upgrade?(env)
@@ -87,8 +97,43 @@ module Tina4
87
97
  rack_response = handle_route(env, route, path_params)
88
98
  matched_pattern = route.path
89
99
  else
90
- rack_response = handle_404(path)
91
- matched_pattern = nil
100
+ # RFC 9110 conformance — before falling through to 404, check whether
101
+ # the PATH is known to the router under any OTHER method.
102
+ # - OPTIONS request → 204 with Allow header (§9.3.7)
103
+ # - Any other method (PUT on GET-only, TRACE, CONNECT, etc.)
104
+ # → 405 with Allow header (§15.5.6 + §10.2.1)
105
+ allowed = Tina4::Router.methods_allowed_for_path(path)
106
+ if !allowed.empty?
107
+ allow_header = allowed.join(", ")
108
+ if method.to_s.upcase == "OPTIONS"
109
+ rack_response = [204, { "allow" => allow_header, "content-length" => "0" }, [""]]
110
+ else
111
+ body = %({"error":"Method Not Allowed","path":"#{path}","method":"#{method}","allow":[#{allowed.map { |m| %("#{m}") }.join(",")}],"status":405})
112
+ rack_response = [405, {
113
+ "allow" => allow_header,
114
+ "content-type" => "application/json",
115
+ "content-length" => body.bytesize.to_s
116
+ }, [body]]
117
+ end
118
+ matched_pattern = nil
119
+ else
120
+ rack_response = handle_404(path)
121
+ matched_pattern = nil
122
+ end
123
+ end
124
+
125
+ # RFC 9110 §9.3.2: a HEAD response MUST NOT include content. Strip
126
+ # the body unconditionally and record what Content-Length the GET
127
+ # would have sent. Cache validators / link checkers / monitoring
128
+ # probes use that header to estimate sizes.
129
+ if method.to_s.upcase == "HEAD"
130
+ status, headers, body_parts = rack_response
131
+ joined = body_parts.respond_to?(:join) ? body_parts.join : body_parts.to_s
132
+ unless joined.empty?
133
+ new_headers = headers.dup
134
+ new_headers["content-length"] = joined.bytesize.to_s
135
+ rack_response = [status, new_headers, [""]]
136
+ end
92
137
  end
93
138
 
94
139
  # Capture request for dev inspector
data/lib/tina4/router.rb CHANGED
@@ -312,6 +312,25 @@ module Tina4
312
312
  add("ANY", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
313
313
  end
314
314
 
315
+ # Register an explicit HEAD route. By default the framework auto-handles
316
+ # HEAD by falling back to the GET route and stripping the body
317
+ # (RFC 9110 §9.3.2). Use this only when you need a HEAD handler that
318
+ # does something different from GET — e.g. cheaper existence-check
319
+ # logic, custom validator headers without the cost of building the body.
320
+ # The framework still strips the response body for you on the way out.
321
+ def head(path, middleware: [], swagger_meta: {}, template: nil, &block)
322
+ add("HEAD", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
323
+ end
324
+
325
+ # Register an explicit OPTIONS route. By default the framework auto-
326
+ # handles OPTIONS by building an Allow header from every method
327
+ # registered for the path and returning 204 (RFC 9110 §9.3.7). Use
328
+ # this to take over that behaviour — e.g. to return a richer OPTIONS
329
+ # payload describing the resource.
330
+ def options(path, middleware: [], swagger_meta: {}, template: nil, &block)
331
+ add("OPTIONS", path, block, middleware: middleware, swagger_meta: swagger_meta, template: template)
332
+ end
333
+
315
334
  def find_route(method, path)
316
335
  normalized_method = method.upcase
317
336
  # Normalize path once (not per-route)
@@ -325,9 +344,59 @@ module Tina4
325
344
  params = route.match_path(normalized_path)
326
345
  return [route, params] if params
327
346
  end
347
+
348
+ # RFC 9110 §9.3.2: HEAD is identical to GET except for the absence
349
+ # of a response body. If no explicit HEAD route matched, fall back
350
+ # to the GET route — the dispatcher strips the body on the way out
351
+ # so the handler doesn't need to know HEAD even happened.
352
+ if normalized_method == "HEAD"
353
+ (method_index["GET"] || []).each do |route|
354
+ params = route.match_path(normalized_path)
355
+ return [route, params] if params
356
+ end
357
+ end
358
+
328
359
  nil
329
360
  end
330
361
 
362
+ # Return the list of HTTP methods registered for ``path``, in the order
363
+ # GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS. Used by the
364
+ # dispatcher to build the ``Allow:`` header on 405 / OPTIONS responses
365
+ # (RFC 9110 §10.2.1, §9.3.7).
366
+ #
367
+ # If GET is registered for the path, HEAD is appended implicitly
368
+ # (HEAD auto-fallback). OPTIONS is appended whenever the path has any
369
+ # registered method (the framework auto-handles OPTIONS).
370
+ def methods_allowed_for_path(path)
371
+ normalized_path = path.gsub("\\", "/")
372
+ normalized_path = "/#{normalized_path}" unless normalized_path.start_with?("/")
373
+ normalized_path = normalized_path.chomp("/") unless normalized_path == "/"
374
+
375
+ method_order = %w[GET POST PUT PATCH DELETE HEAD OPTIONS]
376
+ seen = []
377
+ any_matched = false
378
+
379
+ method_index.each do |m, routes_for_method|
380
+ next if routes_for_method.empty?
381
+ matched = routes_for_method.any? { |r| r.match_path(normalized_path) }
382
+ next unless matched
383
+ if m == "ANY"
384
+ any_matched = true
385
+ elsif method_order.include?(m)
386
+ seen << m unless seen.include?(m)
387
+ end
388
+ end
389
+
390
+ seen = method_order.dup if any_matched
391
+
392
+ if !seen.empty?
393
+ seen << "HEAD" if seen.include?("GET") && !seen.include?("HEAD")
394
+ seen << "OPTIONS" unless seen.include?("OPTIONS")
395
+ end
396
+
397
+ method_order.select { |m| seen.include?(m) }
398
+ end
399
+
331
400
  # When TINA4_TRAILING_SLASH_REDIRECT is truthy, the rack app uses this
332
401
  # to detect whether the *original* (un-stripped) path differed from the
333
402
  # canonical form so it can issue a 301 redirect. Default false — silent
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.12.6"
4
+ VERSION = "3.12.8"
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.12.6
4
+ version: 3.12.8
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-05-06 00:00:00.000000000 Z
11
+ date: 2026-05-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack