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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0fabf8d80352adbe88869422f34a1b246b78b32518fc2af532520ca6c9b42de6
4
- data.tar.gz: 2c72a84965aacb8bff923af520882d8683a6e33f286760935da57296e8556395
3
+ metadata.gz: 15dba6b06367507e244932e3e2951fc09dbe0a43219edd7ad9d2b110b23b6599
4
+ data.tar.gz: 8ef665c037cde9c0be79336ad749a23055cf6973f9a73f92ca37bc0c05b2a933
5
5
  SHA512:
6
- metadata.gz: b5a40834b40f735228410ae3a4f65d464cc305a291306dd5fecb28f55c54f4aca7c6d576899aed158bdc60b55ed0e51a7686c39aa5c6fdd375defde1ab2e4d8e
7
- data.tar.gz: e8de68a5e64d089111e37ef49f7949a960ff4fe748fcd01b12fc1fda6ef5a094f7a394a328429ddf9d83e24e6b5dadc483d99a867a16eeebc58014efa05e097b
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
@@ -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 = nil
121
- @contracts_paths = nil
122
- @on_missing_operation = nil
123
- @frozen = false
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: @strict_mode,
133
- contracts_paths: @contracts_paths,
134
- on_missing_operation: @on_missing_operation,
135
- frozen: @frozen
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 = state[:strict_mode]
141
- @contracts_paths = state[:contracts_paths]
142
- @on_missing_operation = state[:on_missing_operation]
143
- @frozen = state[:frozen]
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: { errors: [ { key: error_def.key.to_s, message: exception.message } ] },
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
- # 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.
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
- 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
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: `strict_mode` the only
14
- # attribute settable via `Trane.configure` is read when a response is
15
- # rendered (Trane::Controller::Renderer), never when routes are drawn. The
16
- # API name is not configurable at all; it is Rails.application.name.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Trane
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
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.0
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