trane 0.1.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.
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Trane
6
+ module Controller
7
+ # Mixin that captures StandardError subclasses and maps them to
8
+ # registered Trane errors. Resolves the exception class via the
9
+ # precomputed errors_by_name index on the Registry snapshot —
10
+ # single lookup on FQDN match, with a short-name fallback (via
11
+ # String#rpartition) for hosts that registered errors by short
12
+ # name. Include in your API base controller. Does NOT add
13
+ # `render contract:` support — combine with Trane::Controller::Renderer
14
+ # or use the composer Trane::Controller.
15
+ #
16
+ # WARNING: this installs rescue_from StandardError at the class
17
+ # level. Including in a base controller that also serves HTML
18
+ # would catch errors Devise / Pundit / etc. expect to bubble up.
19
+ # Recommended: include only in API base controllers.
20
+ #
21
+ # Rails-reserved exceptions (those in
22
+ # ActionDispatch::ExceptionWrapper.rescue_responses, e.g.
23
+ # ActiveRecord::RecordNotFound → 404, ActionController::ParameterMissing → 400)
24
+ # are re-raised when no Trane error is registered for them, so Rails'
25
+ # exception middleware applies its default status mapping. Hosts that
26
+ # want to swallow these into the Trane envelope can register them
27
+ # explicitly via `Trane.errors { error "ActiveRecord::RecordNotFound", ... }`;
28
+ # the Trane lookup wins over the re-raise path.
29
+ #
30
+ # SECURITY NOTE: a registered error's envelope carries the exception's
31
+ # runtime #message, in EVERY environment including production. Framework
32
+ # and library exception messages are written for logs and may reveal
33
+ # internals (model names, query conditions). Prefer the recommended
34
+ # pattern (docs/wiki/Error-Handling.md) — rescue the framework exception
35
+ # and raise a domain error with a curated message — and register framework
36
+ # exceptions directly only when their messages are acceptable to expose.
37
+ #
38
+ # PRODUCTION NOTE: re-raised exceptions surface via Rails' default
39
+ # middleware. Keep `config.consider_all_requests_local = false` in
40
+ # production so `ActionDispatch::ShowExceptions` serves the static
41
+ # `public/404.html` / `500.html` pages. With `consider_all_requests_local`
42
+ # enabled in production, `ActionDispatch::DebugExceptions` would expose
43
+ # the exception class + message + backtrace.
44
+ module ErrorHandler
45
+ extend ActiveSupport::Concern
46
+
47
+ included do
48
+ rescue_from StandardError, with: :_trane_handle_error
49
+ end
50
+
51
+ # Returns the frozen Set of Rails-reserved exception class NAMES
52
+ # (Strings) derived from ActionDispatch::ExceptionWrapper.rescue_responses.
53
+ # Memoized on first call (after Rails boot completes). Returns an empty
54
+ # Set when Rails is not loaded.
55
+ #
56
+ # Names, not Class objects, on purpose: memoizing classes would pin
57
+ # host-registered (Zeitwerk-reloadable) exception constants in the gem
58
+ # forever, and after a development reload the stale Class would no
59
+ # longer match its reloaded replacement. Names survive reloads.
60
+ #
61
+ # NOTE: callers MUST invoke this post-boot. The Rails Engine initializers
62
+ # merge AR/AC entries into `rescue_responses` during boot; calling this
63
+ # earlier would memoize an incomplete list. In practice the first error
64
+ # arrives in a request thread, which is always post-boot — but tests or
65
+ # explicit pre-boot invocations would have to reset @rails_reserved_names
66
+ # afterwards.
67
+ def self.rails_reserved_names
68
+ @rails_reserved_names ||= begin
69
+ if defined?(ActionDispatch::ExceptionWrapper)
70
+ Set.new(ActionDispatch::ExceptionWrapper.rescue_responses.keys.grep(String)).freeze
71
+ else
72
+ Set.new.freeze
73
+ end
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def _trane_handle_error(exception)
80
+ klass = exception.class
81
+ return _trane_unhandled_error(exception) unless klass.name
82
+
83
+ index = Trane.registry.errors_by_name
84
+ error_def = index[klass.name] || _trane_short_name_match(index, klass)
85
+
86
+ if error_def
87
+ render(
88
+ json: { errors: [ { key: error_def.key.to_s, message: exception.message } ] },
89
+ status: error_def.status_code
90
+ )
91
+ elsif _trane_rails_reserved?(klass)
92
+ raise exception
93
+ else
94
+ _trane_unhandled_error(exception)
95
+ end
96
+ end
97
+
98
+ # rescue_from handles the exception BEFORE Rails' exception-reporting
99
+ # middleware can see it, so without this hook a 500 in production
100
+ # would leave no trace at all: no log line, no stacktrace, and no
101
+ # event in middleware-based error trackers. Report through
102
+ # Rails.error (the ErrorReporter interface trackers subscribe to)
103
+ # and write the class + message + backtrace to the log.
104
+ def _trane_report_unhandled(exception)
105
+ return unless defined?(Rails)
106
+
107
+ if Rails.respond_to?(:error) && Rails.error
108
+ Rails.error.report(exception, handled: true, source: "trane")
109
+ end
110
+
111
+ if Rails.respond_to?(:logger) && Rails.logger
112
+ Rails.logger.error(
113
+ "[Trane] unhandled #{exception.class}: #{exception.message}\n" \
114
+ "#{Array(exception.backtrace).first(20).join("\n")}"
115
+ )
116
+ end
117
+ end
118
+
119
+ # Short-name fallback, restricted to errors REGISTERED by short name.
120
+ # The errors_by_name index also aliases FQDN registrations under their
121
+ # demodulized name (BootValidator resolves operation error_keys through
122
+ # those aliases), but matching here through an alias would let an
123
+ # unrelated exception (SomeGem::UserNotFound) hijack a registered
124
+ # Errors::UserNotFound and send a foreign library's message to the
125
+ # client — FQDN registrations must match exactly.
126
+ def _trane_short_name_match(index, klass)
127
+ short = klass.name.rpartition("::").last
128
+ candidate = index[short]
129
+ candidate if candidate && candidate.key == short
130
+ end
131
+
132
+ def _trane_rails_reserved?(klass)
133
+ names = ErrorHandler.rails_reserved_names
134
+ return false if names.empty?
135
+
136
+ klass.ancestors.any? { |ancestor| names.include?(ancestor.name) }
137
+ end
138
+
139
+ # Verbose output is allow-listed to LOCAL environments (development
140
+ # and test, via Rails.env.local?) rather than deny-listed against
141
+ # production: a custom environment (staging, uat, preprod) must get
142
+ # the generic message by default. Exception messages are written by
143
+ # libraries that assume a log audience — they can carry SQL, record
144
+ # values, or internal hostnames — and this rescue_from renders before
145
+ # Rails' exception middleware, so the host's
146
+ # consider_all_requests_local setting cannot protect these responses.
147
+ def _trane_unhandled_error(exception)
148
+ _trane_report_unhandled(exception)
149
+
150
+ if defined?(Rails) && Rails.env.local?
151
+ render(
152
+ json: {
153
+ errors: [ {
154
+ key: "InternalServerError",
155
+ message: "#{exception.class}: #{exception.message}"
156
+ } ]
157
+ },
158
+ status: :internal_server_error
159
+ )
160
+ else
161
+ render(
162
+ json: { errors: [ { key: "InternalServerError", message: "An unexpected error occurred" } ] },
163
+ status: :internal_server_error
164
+ )
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "json"
5
+ require "rack/utils"
6
+
7
+ module Trane
8
+ module Controller
9
+ # Mixin that adds `render contract: data` support to a controller.
10
+ # Include in your API base controller (e.g. Api::BaseController <
11
+ # ActionController::API). Does NOT install any rescue_from handlers —
12
+ # combine with Trane::Controller::ErrorHandler or use the composer
13
+ # Trane::Controller for both.
14
+ #
15
+ # Caveat: if the including class also `prepend`s a custom `#render`,
16
+ # the prepended one runs first; chain via `super` to reach this one.
17
+ module Renderer
18
+ extend ActiveSupport::Concern
19
+
20
+ def render(options = nil, extra_options = {}, &block)
21
+ if options.is_a?(Hash) && options.key?(:contract)
22
+ if extra_options.is_a?(Hash) && extra_options.key?(:callback)
23
+ raise ArgumentError,
24
+ "Trane: render contract: does not support :callback (JSONP). " \
25
+ "Use render json: directly if JSONP is required."
26
+ end
27
+
28
+ contract_data = options[:contract]
29
+ status = options[:status] || :ok
30
+ status_int = ::Rack::Utils.status_code(status)
31
+
32
+ op_name = _trane_operation_name
33
+ unless op_name
34
+ # The route did not inject _trane_operation, so no contract can
35
+ # be resolved and the field filtering cannot run. Serving the
36
+ # data unserialized would expose every attribute of the object
37
+ # (fail-open on the gem's only field filter), so the default is
38
+ # to fail loud, consistent with the unknown-operation and
39
+ # missing-response paths below. Hosts opt into the old behavior
40
+ # via config.on_missing_operation = :log / :fallback.
41
+ case Trane.configuration.on_missing_operation
42
+ when :fallback
43
+ return super(json: contract_data, status: status, **extra_options, &block)
44
+ when :log
45
+ _trane_log_missing_operation
46
+ return super(json: contract_data, status: status, **extra_options, &block)
47
+ else
48
+ raise Trane::Error,
49
+ "Trane: render contract: was called but the route did not declare a contract, " \
50
+ "so the response cannot be serialized or filtered. " \
51
+ "Add `contract: { operation: :<operation_name> }` to this route in routes.rb, " \
52
+ "or set `config.on_missing_operation` to :log or :fallback to serve the data unserialized."
53
+ end
54
+ end
55
+
56
+ registry = Trane.registry
57
+
58
+ op = registry.operations[op_name]
59
+ unless op
60
+ raise Trane::Error,
61
+ "Trane: no operation registered for '#{op_name}'. " \
62
+ "Define it with Trane.operation(:#{op_name}) { ... }"
63
+ end
64
+
65
+ response_def = op.responses[status_int]
66
+ unless response_def
67
+ raise Trane::Error,
68
+ "Trane: operation '#{op_name}' has no response defined for status #{status_int}."
69
+ end
70
+
71
+ extra_attrs = ExtraAttributesFilter.parse(params)
72
+ strict = Trane.configuration.effective_strict_mode
73
+ serializer = registry.compiled_serializer_for(response_def, strict)
74
+ hash = serializer.serialize(contract_data, extra_attributes: extra_attrs)
75
+
76
+ body = ::JSON.generate(hash)
77
+ super(
78
+ plain: body,
79
+ status: status,
80
+ content_type: extra_options.fetch(:content_type, "application/json; charset=utf-8"),
81
+ **extra_options.except(:content_type),
82
+ &block
83
+ )
84
+ else
85
+ super
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def _trane_operation_name
92
+ op = request.path_parameters[:_trane_operation]
93
+ op&.to_sym
94
+ end
95
+
96
+ def _trane_log_missing_operation
97
+ Trane.log_warning(
98
+ "[Trane] render contract: called on a route without contract metadata; " \
99
+ "serving unserialized JSON. Add `contract: { operation: ... }` to the route in routes.rb."
100
+ )
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require_relative "controller/renderer"
5
+ require_relative "controller/error_handler"
6
+
7
+ module Trane
8
+ # Convenience composer that includes both Trane::Controller::Renderer
9
+ # and Trane::Controller::ErrorHandler. Use this when you want both
10
+ # behaviors. For finer control (e.g., render override without the
11
+ # rescue_from), include the submodules directly.
12
+ #
13
+ # WARNING: include this only in API-specific base controllers (e.g.
14
+ # Api::BaseController < ActionController::API). The ErrorHandler
15
+ # piece captures StandardError subclasses globally — including in
16
+ # ApplicationController of an app that mixes HTML/JSON would
17
+ # intercept exceptions Devise / Pundit / etc. expect to bubble up.
18
+ module Controller
19
+ extend ActiveSupport::Concern
20
+
21
+ included do
22
+ include Renderer
23
+ include ErrorHandler
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trane
4
+ module Docs
5
+ # Rack middleware-style app that serves documentation endpoints.
6
+ # Mounted by the Trane::Engine at the path chosen by the host application.
7
+ class App
8
+ def call(env)
9
+ request = Rack::Request.new(env)
10
+ if request.path_info.end_with?(".json")
11
+ serve_json
12
+ else
13
+ serve_html
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def serve_json
20
+ [ 200, response_headers("application/json; charset=utf-8"), [ Cache.json ] ]
21
+ end
22
+
23
+ def serve_html
24
+ [ 200, response_headers("text/html; charset=utf-8"), [ Cache.html ] ]
25
+ end
26
+
27
+ # nosniff keeps browsers from second-guessing the declared MIME type.
28
+ def response_headers(content_type)
29
+ {
30
+ "content-type" => content_type,
31
+ "x-content-type-options" => "nosniff"
32
+ }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Trane
6
+ module Docs
7
+ # Memoized JSON + HTML output of the ServiceDefinition. The pair lives
8
+ # inside a single frozen Snapshot ivar built atomically by
9
+ # `precompute!`: every Snapshot instance carries a `(json, html)`
10
+ # pair derived from one ServiceDefinition.generate call, so no
11
+ # caller can observe a torn pair WITHIN a single snapshot
12
+ # generation. The previous design wrote `@json` and `@html` in
13
+ # sequence, leaving a window where a reader saw new JSON paired
14
+ # with old HTML across a Rails reload.
15
+ #
16
+ # Consistency is per-snapshot-instance, not per-call-pair: a thread
17
+ # that reads `Cache.json` then `Cache.html` may observe two
18
+ # different snapshots if `precompute!` runs between the two calls.
19
+ # In production this is unobservable (each Rack request hits ONE of
20
+ # `/docs.json` or `/docs`, never both), and cross-request
21
+ # ordering across reloads is out of scope for this module.
22
+ #
23
+ # `precompute!` is ALWAYS lazy — triggered by the first `json`/`html`
24
+ # read via `ensure_snapshot`. Trane::Engine's `to_prepare` block calls
25
+ # `invalidate!` (not `precompute!`): during `to_prepare` the host routes
26
+ # are not drawn yet (it runs before the Finisher's
27
+ # set_routes_reloader_hook, in every environment), so precomputing there
28
+ # would build a snapshot from an empty route set and every operation would
29
+ # fall back to method "GET" with an empty path. Deferring to the first
30
+ # post-boot read — inside a request, with the routes drawn — is what makes
31
+ # the docs report each operation's real HTTP verb and path. Concurrent
32
+ # cold readers may both build a snapshot; one assignment wins, the other is
33
+ # GC'd — wasted work, never inconsistent state.
34
+ module Cache
35
+ Snapshot = Data.define(:json, :html)
36
+
37
+ class << self
38
+ def json
39
+ ensure_snapshot
40
+ @snapshot&.json
41
+ end
42
+
43
+ def html
44
+ ensure_snapshot
45
+ @snapshot&.html
46
+ end
47
+
48
+ # Build a fresh (json, html) Snapshot from the current
49
+ # ServiceDefinition and swap it in atomically.
50
+ #
51
+ # @return [void]
52
+ def precompute!
53
+ # The only Rails read in the docs layer: the service name is
54
+ # Rails.application.name, never Trane configuration.
55
+ app = Rails.application
56
+ definition = ServiceDefinition.generate(app.routes.routes, service_name: app.name)
57
+ @snapshot = Snapshot.new(
58
+ json: JSON.generate(definition),
59
+ html: HtmlRenderer.render(definition)
60
+ )
61
+ nil
62
+ end
63
+
64
+ def invalidate!
65
+ @snapshot = nil
66
+ end
67
+
68
+ private
69
+
70
+ def ensure_snapshot
71
+ precompute! if @snapshot.nil?
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module Trane
6
+ module Docs
7
+ class HtmlRenderer
8
+ METHOD_COLORS = {
9
+ "GET" => "#61affe",
10
+ "POST" => "#49cc90",
11
+ "PUT" => "#fca130",
12
+ "PATCH" => "#fca130",
13
+ "DELETE" => "#f93e3e"
14
+ }.freeze
15
+
16
+ TEMPLATE_PATH = File.expand_path("templates/index.html.erb", __dir__)
17
+ TEMPLATE = ERB.new(File.read(TEMPLATE_PATH), trim_mode: "-").freeze
18
+
19
+ def self.render(definition)
20
+ new(definition).to_html
21
+ end
22
+
23
+ def initialize(definition)
24
+ @definition = definition
25
+ end
26
+
27
+ def to_html
28
+ TEMPLATE.result(binding)
29
+ end
30
+
31
+ private
32
+
33
+ def service
34
+ @definition[:service]
35
+ end
36
+
37
+ def operations
38
+ @definition[:operations] || []
39
+ end
40
+
41
+ def representations
42
+ @definition[:representations] || []
43
+ end
44
+
45
+ def errors
46
+ @definition[:errors] || []
47
+ end
48
+
49
+ def method_color(method)
50
+ METHOD_COLORS[method.to_s.upcase] || "#999"
51
+ end
52
+
53
+ def h(text)
54
+ ERB::Util.html_escape(text.to_s)
55
+ end
56
+
57
+ def representation_link(type_name)
58
+ type_str = type_name.to_s
59
+ reps = representations.map { |r| r[:name] }
60
+ if reps.include?(type_str)
61
+ "<a href=\"#rep-#{h(type_str)}\" class=\"type-link\">#{h(type_str)}</a>"
62
+ else
63
+ "<span class=\"type\">#{h(type_str)}</span>"
64
+ end
65
+ end
66
+
67
+ def render_field_row(field, indent = 0, include_required: false, buffer: +"")
68
+ name = h(field[:name])
69
+ type = field[:type] || "object"
70
+ type_display = if field[:array_of]
71
+ "array of #{representation_link(field[:array_of])}"
72
+ else
73
+ representation_link(type)
74
+ end
75
+ extra_badge = field[:extra] ? ' <span class="badge extra">extra</span>' : ""
76
+ format_badge = field[:format] ? " <span class=\"badge format\">#{h(field[:format])}</span>" : ""
77
+ enum_display = field[:enum] ? %(<div class="enum-values">enum: #{field[:enum].map { |v| h(v) }.join(", ")}</div>) : ""
78
+ padding = indent * 20
79
+
80
+ buffer << %(<tr><td style="padding-left: #{padding + 12}px"><code>#{name}</code>#{extra_badge}</td>)
81
+ buffer << %(<td>#{type_display}#{format_badge}#{enum_display}</td>)
82
+
83
+ if include_required
84
+ required_badge = case field[:required]
85
+ when true then '<span class="badge required">required</span>'
86
+ when false then '<span class="badge optional">optional</span>'
87
+ else "—"
88
+ end
89
+ buffer << %(<td>#{required_badge}</td>)
90
+ end
91
+
92
+ buffer << "</tr>\n"
93
+
94
+ if field[:children]
95
+ field[:children].each do |child|
96
+ render_field_row(child, indent + 1, include_required: include_required, buffer: buffer)
97
+ end
98
+ end
99
+
100
+ buffer
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Trane
6
+ module Docs
7
+ class ServiceDefinition
8
+ # Generate a complete service definition hash from the registry and routes.
9
+ #
10
+ # @param routes [ActionDispatch::Routing::RouteSet::NamedRouteCollection, Array] Rails routes
11
+ # @param service_name [String] published as `service.name`. Trane does not
12
+ # configure the API name: callers inside a Rails application pass
13
+ # `Rails.application.name`. Required — there is no default.
14
+ # @return [Hash]
15
+ def self.generate(routes, service_name:)
16
+ new(routes, service_name: service_name).to_h
17
+ end
18
+
19
+ def initialize(routes, service_name:)
20
+ @routes = routes
21
+ @service_name = service_name
22
+ @registry = Trane.registry
23
+ end
24
+
25
+ def to_h
26
+ {
27
+ service: {
28
+ name: @service_name
29
+ },
30
+ operations: build_operations,
31
+ representations: build_representations,
32
+ errors: build_errors
33
+ }
34
+ end
35
+
36
+ private
37
+
38
+ def build_operations
39
+ route_map = extract_route_map
40
+
41
+ @registry.operations.map do |name, op|
42
+ route_info = route_map[name.to_s] || {}
43
+
44
+ entry = {
45
+ id: name.to_s,
46
+ summary: op.summary,
47
+ method: route_info[:method] || "GET",
48
+ path: route_info[:path] || ""
49
+ }
50
+
51
+ entry[:request] = build_request(op.request) if op.request
52
+ entry[:responses] = build_responses(op.responses)
53
+ entry[:errors] = op.error_keys.map(&:to_s) unless op.error_keys.empty?
54
+
55
+ entry
56
+ end
57
+ end
58
+
59
+ def build_request(request_def)
60
+ result = {}
61
+
62
+ unless request_def.params.empty?
63
+ result[:params] = request_def.params.map do |p|
64
+ entry = { name: p.name.to_s, type: p.type.to_s, location: p.location.to_s, required: p.required }
65
+ entry[:enum] = serialize_enum(p.enum) unless p.enum.nil?
66
+ entry
67
+ end
68
+ end
69
+
70
+ unless request_def.body_fields.empty?
71
+ result[:body] = build_field_list(request_def.body_fields)
72
+ end
73
+
74
+ result
75
+ end
76
+
77
+ def build_responses(responses)
78
+ responses.map do |status, resp|
79
+ { status: status, fields: build_field_list(resp.fields) }
80
+ end
81
+ end
82
+
83
+ def build_field_list(fields)
84
+ fields.map { |f| build_field_hash(f) }
85
+ end
86
+
87
+ def build_field_hash(field)
88
+ entry = { name: field.name.to_s }
89
+ entry[:type] = field.type.to_s if field.type
90
+ entry[:format] = field.format.to_s if field.format
91
+ entry[:extra] = true if field.extra
92
+ entry[:array_of] = field.array_of.to_s if field.array_of
93
+ entry[:required] = field.required unless field.required.nil?
94
+ entry[:enum] = serialize_enum(field.enum) unless field.enum.nil?
95
+
96
+ unless field.children.empty?
97
+ entry[:children] = build_field_list(field.children)
98
+ end
99
+
100
+ entry
101
+ end
102
+
103
+ def build_representations
104
+ @registry.representations.map do |name, rep|
105
+ {
106
+ name: name.to_s,
107
+ fields: build_field_list(rep.fields)
108
+ }
109
+ end
110
+ end
111
+
112
+ def build_errors
113
+ @registry.errors.map do |_key, err|
114
+ {
115
+ key: err.key.to_s,
116
+ status_code: err.status_code,
117
+ description: err.description
118
+ }
119
+ end
120
+ end
121
+
122
+ def serialize_enum(values)
123
+ values.map do |v|
124
+ case v
125
+ when Date, DateTime, Time then v.iso8601
126
+ else v
127
+ end
128
+ end
129
+ end
130
+
131
+ def extract_route_map
132
+ map = {}
133
+
134
+ @routes.each do |route|
135
+ defaults = route.defaults
136
+ op_name = defaults[:_trane_operation]
137
+ next unless op_name
138
+
139
+ path = route.path.spec.to_s.gsub("(.:format)", "")
140
+ method = extract_method(route)
141
+
142
+ # Store only the first route per operation: defensive against
143
+ # any host that explicitly declares the same _trane_operation on
144
+ # multiple `match` lines. Rails 8.1.2 collapses `via: [:patch,
145
+ # :put]` into a single route with verb "PATCH|PUT", so this
146
+ # `||=` is a no-op for that case (the verb is canonicalised by
147
+ # `extract_method` to "PATCH").
148
+ map[op_name] ||= { method: method, path: path }
149
+ end
150
+
151
+ map
152
+ end
153
+
154
+ # NOTE: Trane::RouteValidator guarantees that a route with a contract
155
+ # maps to exactly one HTTP verb, so the `"PATCH|PUT"` (String) and
156
+ # `/^PATCH|PUT$/` (Regexp) multi-verb branches below no longer occur at
157
+ # runtime for contract routes. They are kept as defensive fallbacks.
158
+ def extract_method(route)
159
+ verb = route.verb
160
+ case verb
161
+ when String then verb.split("|").first
162
+ when Regexp then verb.source.scan(/[A-Z]+/).first || verb.source
163
+ else
164
+ if verb.respond_to?(:verb)
165
+ verb.verb.to_s
166
+ else
167
+ verb.to_s
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end