trane 0.1.0 → 0.2.1
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/CHANGELOG.md +47 -0
- data/config/routes.rb +8 -0
- data/lib/trane/configuration.rb +88 -12
- data/lib/trane/controller/error_handler.rb +49 -27
- data/lib/trane/controller/renderer.rb +17 -0
- data/lib/trane/testing.rb +6 -4
- data/lib/trane/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 15dba6b06367507e244932e3e2951fc09dbe0a43219edd7ad9d2b110b23b6599
|
|
4
|
+
data.tar.gz: 8ef665c037cde9c0be79336ad749a23055cf6973f9a73f92ca37bc0c05b2a933
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 41df9927bece8008f621a03c3f1f9210cfc8a30413ef5a661472a3f33bd49af97135b0a1628d4828129866bee9820c96b9287a33659a9d7ea14fccb7546328ee
|
|
7
|
+
data.tar.gz: 46762216d0b9f83fae26a92f36d4c3628db58cc9c8ce6b8009f34feeb5392d89d9cf80673e94d18d02a89654b0b3a979d8a4d778f91e9a21875ad938276e96f8
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,51 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.1] - 2026-09-01
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- The gem now packages `config/routes.rb`. `spec.files` listed only `lib/` and
|
|
13
|
+
the three top-level documents, so the Engine's routes never shipped:
|
|
14
|
+
`mount Trane::Engine` gave an Engine with no routes and the documentation
|
|
15
|
+
endpoints answered **404** for anyone installing from rubygems.org. 0.1.0 and
|
|
16
|
+
0.2.0 are both affected — the bug is invisible to a host pinning the gem by
|
|
17
|
+
`git:`, which fetches the whole checkout, so it went unnoticed until a host
|
|
18
|
+
consumed the published gem. `spec/trane/packaging_spec.rb` now asserts the
|
|
19
|
+
packaged file list directly, since the rest of the suite runs against the
|
|
20
|
+
checkout and cannot see what the gemspec omits.
|
|
21
|
+
|
|
22
|
+
## [0.2.0] - 2026-08-27
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `success_envelope` (set via `Trane.configure`): a callable applied to the
|
|
27
|
+
serialized response hash before encoding, so hosts can wrap every success
|
|
28
|
+
payload in their own envelope. Defaults to identity.
|
|
29
|
+
- `error_envelope` (set via `Trane.configure`): a callable receiving
|
|
30
|
+
`(exception, definition)` — `definition` is `nil` when no error is
|
|
31
|
+
registered — that builds the error response body. The status code stays
|
|
32
|
+
derived from the definition. The default reproduces the previous
|
|
33
|
+
`{"errors":[{"key","message"}]}` shape, verbosity gating included.
|
|
34
|
+
- `rescue_rails_reserved` (set via `Trane.configure`): when `true`,
|
|
35
|
+
Rails-reserved exceptions with no registered error are served through the
|
|
36
|
+
envelope instead of being re-raised, as a plain 500 — the reserved
|
|
37
|
+
exception's native status mapping (e.g. `ActiveRecord::RecordNotFound`'s
|
|
38
|
+
404) is discarded, not preserved. It also moves their reporting: Rails'
|
|
39
|
+
exception middleware no longer sees them, so Trane reports them instead, as
|
|
40
|
+
`handled: true` / `severity: :warning` rather than Rails' `handled: false` /
|
|
41
|
+
`severity: :error`, and with a longer log line. Same event count, more log
|
|
42
|
+
volume. Defaults to `false`.
|
|
43
|
+
|
|
44
|
+
Both envelope callables must return a Hash, and Trane checks rather than
|
|
45
|
+
encoding whatever comes back. A `success_envelope` that returns something else
|
|
46
|
+
raises `Trane::Error`; an `error_envelope` that returns something else, or that
|
|
47
|
+
raises, degrades to the built-in shape and logs a warning — it cannot raise,
|
|
48
|
+
because it runs inside a `rescue_from` and would escape to Rails' static error
|
|
49
|
+
page.
|
|
50
|
+
|
|
51
|
+
No breaking changes: every default reproduces 0.1.0 behaviour exactly.
|
|
52
|
+
|
|
8
53
|
## [0.1.0] - 2026-08-14
|
|
9
54
|
|
|
10
55
|
First public release.
|
|
@@ -33,4 +78,6 @@ First public release.
|
|
|
33
78
|
metadata raises by default (`on_missing_operation` opt-out), and
|
|
34
79
|
configuration setters reject unknown modes.
|
|
35
80
|
|
|
81
|
+
[0.2.1]: https://github.com/thisisqubika/trane/releases/tag/v0.2.1
|
|
82
|
+
[0.2.0]: https://github.com/thisisqubika/trane/releases/tag/v0.2.0
|
|
36
83
|
[0.1.0]: https://github.com/thisisqubika/trane/releases/tag/v0.1.0
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Routes for the Trane::Engine. The host mounts this engine at a path
|
|
4
|
+
# of its choice; everything under that mount goes to the docs Rack app,
|
|
5
|
+
# which dispatches HTML vs JSON by inspecting the request path.
|
|
6
|
+
Trane::Engine.routes.draw do
|
|
7
|
+
mount Trane::Docs::App.new => "/"
|
|
8
|
+
end
|
data/lib/trane/configuration.rb
CHANGED
|
@@ -14,6 +14,32 @@ module Trane
|
|
|
14
14
|
# the route did not declare `contract: { operation: ... }`).
|
|
15
15
|
ON_MISSING_OPERATION_MODES = %i[raise log fallback].freeze
|
|
16
16
|
|
|
17
|
+
# Applied to the serialized response hash immediately before it is encoded,
|
|
18
|
+
# so a host can wrap every success payload in its own envelope. Identity by
|
|
19
|
+
# default: the response is exactly what the contract declares.
|
|
20
|
+
DEFAULT_SUCCESS_ENVELOPE = ->(body) { body }
|
|
21
|
+
|
|
22
|
+
# Builds the response body for a rescued exception. Receives the exception
|
|
23
|
+
# and the resolved ErrorDefinition, or nil when no error is registered for
|
|
24
|
+
# it. The status code is NOT the hook's concern: the handler derives it from
|
|
25
|
+
# the definition (or 500), so a host cannot accidentally decouple body and
|
|
26
|
+
# status.
|
|
27
|
+
#
|
|
28
|
+
# The default reproduces the built-in shape, verbosity gating included:
|
|
29
|
+
# exception messages are written for a log audience and can carry SQL,
|
|
30
|
+
# record values or internal hostnames, so only local environments see them.
|
|
31
|
+
DEFAULT_ERROR_ENVELOPE = lambda do |exception, definition|
|
|
32
|
+
if definition
|
|
33
|
+
{ errors: [ { key: definition.key.to_s, message: exception.message } ] }
|
|
34
|
+
elsif defined?(Rails) && Rails.env.local?
|
|
35
|
+
{ errors: [ { key: "InternalServerError",
|
|
36
|
+
message: "#{exception.class}: #{exception.message}" } ] }
|
|
37
|
+
else
|
|
38
|
+
{ errors: [ { key: "InternalServerError",
|
|
39
|
+
message: "An unexpected error occurred" } ] }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
17
43
|
attr_reader :strict_mode
|
|
18
44
|
|
|
19
45
|
# Returns the process-level Configuration instance via the Trane shim.
|
|
@@ -73,6 +99,47 @@ module Trane
|
|
|
73
99
|
@on_missing_operation = value
|
|
74
100
|
end
|
|
75
101
|
|
|
102
|
+
def success_envelope
|
|
103
|
+
@success_envelope || DEFAULT_SUCCESS_ENVELOPE
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def success_envelope=(value)
|
|
107
|
+
raise FrozenError, "Trane::Configuration is frozen; cannot modify success_envelope after boot" if @frozen
|
|
108
|
+
unless value.respond_to?(:call)
|
|
109
|
+
raise Trane::Error, "success_envelope must respond to #call (got #{value.inspect})"
|
|
110
|
+
end
|
|
111
|
+
@success_envelope = value
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def error_envelope
|
|
115
|
+
@error_envelope || DEFAULT_ERROR_ENVELOPE
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def error_envelope=(value)
|
|
119
|
+
raise FrozenError, "Trane::Configuration is frozen; cannot modify error_envelope after boot" if @frozen
|
|
120
|
+
unless value.respond_to?(:call)
|
|
121
|
+
raise Trane::Error, "error_envelope must respond to #call (got #{value.inspect})"
|
|
122
|
+
end
|
|
123
|
+
@error_envelope = value
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Whether a Rails-reserved exception (one in
|
|
127
|
+
# ActionDispatch::ExceptionWrapper.rescue_responses) without a registered
|
|
128
|
+
# Trane error is re-raised (false, the default — Rails' own middleware
|
|
129
|
+
# applies its status mapping) or served through the configured error
|
|
130
|
+
# envelope as an unhandled error (true).
|
|
131
|
+
def rescue_rails_reserved
|
|
132
|
+
@rescue_rails_reserved.nil? ? false : @rescue_rails_reserved
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def rescue_rails_reserved=(value)
|
|
136
|
+
raise FrozenError, "Trane::Configuration is frozen; cannot modify rescue_rails_reserved after boot" if @frozen
|
|
137
|
+
unless value == true || value == false
|
|
138
|
+
raise Trane::Error, "rescue_rails_reserved must be true or false (got #{value.inspect})"
|
|
139
|
+
end
|
|
140
|
+
@rescue_rails_reserved = value
|
|
141
|
+
end
|
|
142
|
+
|
|
76
143
|
# Returns the effective strict mode for the current environment.
|
|
77
144
|
#
|
|
78
145
|
# @return [Symbol] :raise, :log, or :ignore
|
|
@@ -117,10 +184,13 @@ module Trane
|
|
|
117
184
|
end
|
|
118
185
|
|
|
119
186
|
def reset!
|
|
120
|
-
@strict_mode
|
|
121
|
-
@contracts_paths
|
|
122
|
-
@on_missing_operation
|
|
123
|
-
@
|
|
187
|
+
@strict_mode = nil
|
|
188
|
+
@contracts_paths = nil
|
|
189
|
+
@on_missing_operation = nil
|
|
190
|
+
@success_envelope = nil
|
|
191
|
+
@error_envelope = nil
|
|
192
|
+
@rescue_rails_reserved = nil
|
|
193
|
+
@frozen = false
|
|
124
194
|
end
|
|
125
195
|
|
|
126
196
|
# Internal — full state snapshot/restore for Trane::Testing.
|
|
@@ -129,18 +199,24 @@ module Trane
|
|
|
129
199
|
# (instead of silently losing it across a with_configuration block).
|
|
130
200
|
def _dump_state
|
|
131
201
|
{
|
|
132
|
-
strict_mode:
|
|
133
|
-
contracts_paths:
|
|
134
|
-
on_missing_operation:
|
|
135
|
-
|
|
202
|
+
strict_mode: @strict_mode,
|
|
203
|
+
contracts_paths: @contracts_paths,
|
|
204
|
+
on_missing_operation: @on_missing_operation,
|
|
205
|
+
success_envelope: @success_envelope,
|
|
206
|
+
error_envelope: @error_envelope,
|
|
207
|
+
rescue_rails_reserved: @rescue_rails_reserved,
|
|
208
|
+
frozen: @frozen
|
|
136
209
|
}
|
|
137
210
|
end
|
|
138
211
|
|
|
139
212
|
def _restore_state!(state)
|
|
140
|
-
@strict_mode
|
|
141
|
-
@contracts_paths
|
|
142
|
-
@on_missing_operation
|
|
143
|
-
@
|
|
213
|
+
@strict_mode = state[:strict_mode]
|
|
214
|
+
@contracts_paths = state[:contracts_paths]
|
|
215
|
+
@on_missing_operation = state[:on_missing_operation]
|
|
216
|
+
@success_envelope = state[:success_envelope]
|
|
217
|
+
@error_envelope = state[:error_envelope]
|
|
218
|
+
@rescue_rails_reserved = state[:rescue_rails_reserved]
|
|
219
|
+
@frozen = state[:frozen]
|
|
144
220
|
end
|
|
145
221
|
end
|
|
146
222
|
end
|
|
@@ -25,7 +25,10 @@ module Trane
|
|
|
25
25
|
# exception middleware applies its default status mapping. Hosts that
|
|
26
26
|
# want to swallow these into the Trane envelope can register them
|
|
27
27
|
# explicitly via `Trane.errors { error "ActiveRecord::RecordNotFound", ... }`;
|
|
28
|
-
# the Trane lookup wins over the re-raise path.
|
|
28
|
+
# the Trane lookup wins over the re-raise path. Alternatively, setting
|
|
29
|
+
# `Trane.configuration.rescue_rails_reserved = true` swallows every
|
|
30
|
+
# reserved exception without registering each one by hand — useful for an
|
|
31
|
+
# API that must answer JSON on every path (default: false).
|
|
29
32
|
#
|
|
30
33
|
# SECURITY NOTE: a registered error's envelope carries the exception's
|
|
31
34
|
# runtime #message, in EVERY environment including production. Framework
|
|
@@ -85,10 +88,10 @@ module Trane
|
|
|
85
88
|
|
|
86
89
|
if error_def
|
|
87
90
|
render(
|
|
88
|
-
json:
|
|
91
|
+
json: _trane_error_body(exception, error_def),
|
|
89
92
|
status: error_def.status_code
|
|
90
93
|
)
|
|
91
|
-
elsif _trane_rails_reserved?(klass)
|
|
94
|
+
elsif _trane_rails_reserved?(klass) && !Trane.configuration.rescue_rails_reserved
|
|
92
95
|
raise exception
|
|
93
96
|
else
|
|
94
97
|
_trane_unhandled_error(exception)
|
|
@@ -136,33 +139,52 @@ module Trane
|
|
|
136
139
|
klass.ancestors.any? { |ancestor| names.include?(ancestor.name) }
|
|
137
140
|
end
|
|
138
141
|
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
# Rails'
|
|
146
|
-
#
|
|
142
|
+
# The report is NOT the envelope's concern and stays unconditional: this
|
|
143
|
+
# rescue_from runs before Rails' exception-reporting middleware, so
|
|
144
|
+
# without it a 500 in production leaves no log line and no tracker event.
|
|
145
|
+
#
|
|
146
|
+
# That applies to the rescue_rails_reserved path too, and it is why the
|
|
147
|
+
# report is not skipped there: serving a reserved exception through the
|
|
148
|
+
# envelope means Rails' middleware never sees it either, so skipping here
|
|
149
|
+
# would leave no trace at all — strictly less than the re-raising default,
|
|
150
|
+
# under which ActionDispatch reports it as handled: false / :error. Trane
|
|
151
|
+
# reports it as handled: true / :warning instead. The event count is the
|
|
152
|
+
# same; the log line is longer. See docs/wiki/Configuration.md.
|
|
147
153
|
def _trane_unhandled_error(exception)
|
|
148
154
|
_trane_report_unhandled(exception)
|
|
149
155
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
156
|
+
render(
|
|
157
|
+
json: _trane_error_body(exception, nil),
|
|
158
|
+
status: :internal_server_error
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Applies error_envelope, degrading to the built-in shape when the host's
|
|
163
|
+
# callable misbehaves.
|
|
164
|
+
#
|
|
165
|
+
# It does NOT raise, unlike the success path. We are already inside a
|
|
166
|
+
# rescue_from handler, and an exception raised in one is not re-dispatched
|
|
167
|
+
# to another: it propagates to Rails' exception middleware, so the client
|
|
168
|
+
# gets the static error page — HTML, or an empty body where the host has
|
|
169
|
+
# no public/500.html. That is precisely the outcome a host configuring an
|
|
170
|
+
# envelope is trying to avoid, so a broken envelope must not cause it.
|
|
171
|
+
# Falling back keeps the response JSON and puts the misconfiguration in
|
|
172
|
+
# the log, where it is actionable.
|
|
173
|
+
def _trane_error_body(exception, definition)
|
|
174
|
+
body = Trane.configuration.error_envelope.call(exception, definition)
|
|
175
|
+
return body if body.is_a?(::Hash)
|
|
176
|
+
|
|
177
|
+
Trane.log_warning(
|
|
178
|
+
"[Trane] error_envelope returned #{body.class}, expected Hash; " \
|
|
179
|
+
"serving the built-in error shape instead."
|
|
180
|
+
)
|
|
181
|
+
Trane::Configuration::DEFAULT_ERROR_ENVELOPE.call(exception, definition)
|
|
182
|
+
rescue StandardError => e
|
|
183
|
+
Trane.log_warning(
|
|
184
|
+
"[Trane] error_envelope raised #{e.class}: #{e.message}; " \
|
|
185
|
+
"serving the built-in error shape instead."
|
|
186
|
+
)
|
|
187
|
+
Trane::Configuration::DEFAULT_ERROR_ENVELOPE.call(exception, definition)
|
|
166
188
|
end
|
|
167
189
|
end
|
|
168
190
|
end
|
|
@@ -72,6 +72,7 @@ module Trane
|
|
|
72
72
|
strict = Trane.configuration.effective_strict_mode
|
|
73
73
|
serializer = registry.compiled_serializer_for(response_def, strict)
|
|
74
74
|
hash = serializer.serialize(contract_data, extra_attributes: extra_attrs)
|
|
75
|
+
hash = _trane_enveloped(hash)
|
|
75
76
|
|
|
76
77
|
body = ::JSON.generate(hash)
|
|
77
78
|
super(
|
|
@@ -93,6 +94,22 @@ module Trane
|
|
|
93
94
|
op&.to_sym
|
|
94
95
|
end
|
|
95
96
|
|
|
97
|
+
# Applies success_envelope and insists the result is still a Hash.
|
|
98
|
+
# `JSON.generate` happily encodes a nil, a String or an Array, so an
|
|
99
|
+
# envelope with a wrong return type would serve `null` — or a bare
|
|
100
|
+
# string — with a 200 and no signal at all. Raising instead is the
|
|
101
|
+
# fail-loud default the rest of the gem holds to, and it is safe here:
|
|
102
|
+
# Trane::Error is a StandardError, so the host's own rescue_from turns
|
|
103
|
+
# it into a reported 500 rather than letting it escape.
|
|
104
|
+
def _trane_enveloped(hash)
|
|
105
|
+
result = Trane.configuration.success_envelope.call(hash)
|
|
106
|
+
return result if result.is_a?(::Hash)
|
|
107
|
+
|
|
108
|
+
raise Trane::Error,
|
|
109
|
+
"Trane: success_envelope must return a Hash, got #{result.class}. " \
|
|
110
|
+
"It receives the serialized response hash and returns the hash to encode."
|
|
111
|
+
end
|
|
112
|
+
|
|
96
113
|
def _trane_log_missing_operation
|
|
97
114
|
Trane.log_warning(
|
|
98
115
|
"[Trane] render contract: called on a route without contract metadata; " \
|
data/lib/trane/testing.rb
CHANGED
|
@@ -10,10 +10,12 @@ module Trane
|
|
|
10
10
|
# restores the original state. Guarantees restore even when the block
|
|
11
11
|
# raises.
|
|
12
12
|
#
|
|
13
|
-
# This helper does not touch the route set:
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
13
|
+
# This helper does not touch the route set: none of the attributes
|
|
14
|
+
# settable via `Trane.configure` (`strict_mode`, `on_missing_operation`,
|
|
15
|
+
# `success_envelope`, `error_envelope`, `rescue_rails_reserved`) are read
|
|
16
|
+
# at route-draw time — `strict_mode`, for instance, is read when a
|
|
17
|
+
# response is rendered (Trane::Controller::Renderer). The API name is not
|
|
18
|
+
# configurable at all; it is Rails.application.name.
|
|
17
19
|
#
|
|
18
20
|
# `contracts_paths` is also configuration state (set from
|
|
19
21
|
# `config/application.rb` via the internal `_set_contracts_paths!`, not
|
data/lib/trane/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: trane
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ignacio Jorge
|
|
@@ -109,6 +109,7 @@ files:
|
|
|
109
109
|
- CHANGELOG.md
|
|
110
110
|
- LICENSE.txt
|
|
111
111
|
- README.md
|
|
112
|
+
- config/routes.rb
|
|
112
113
|
- lib/tasks/trane.rake
|
|
113
114
|
- lib/trane.rb
|
|
114
115
|
- lib/trane/boot_validator.rb
|