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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE.txt +21 -0
- data/README.md +151 -0
- data/lib/tasks/trane.rake +17 -0
- data/lib/trane/boot_validator.rb +64 -0
- data/lib/trane/configuration.rb +146 -0
- data/lib/trane/contract_loader.rb +29 -0
- data/lib/trane/contract_validator.rb +88 -0
- data/lib/trane/controller/error_handler.rb +169 -0
- data/lib/trane/controller/renderer.rb +104 -0
- data/lib/trane/controller.rb +26 -0
- data/lib/trane/docs/app.rb +36 -0
- data/lib/trane/docs/cache.rb +76 -0
- data/lib/trane/docs/html_renderer.rb +104 -0
- data/lib/trane/docs/service_definition.rb +173 -0
- data/lib/trane/docs/templates/index.html.erb +489 -0
- data/lib/trane/engine.rb +148 -0
- data/lib/trane/error_registry.rb +30 -0
- data/lib/trane/extra_attributes_filter.rb +41 -0
- data/lib/trane/field_builder.rb +81 -0
- data/lib/trane/field_node.rb +18 -0
- data/lib/trane/operation_definition.rb +146 -0
- data/lib/trane/param_definition.rb +25 -0
- data/lib/trane/registry.rb +324 -0
- data/lib/trane/representation_definition.rb +24 -0
- data/lib/trane/route_validator.rb +89 -0
- data/lib/trane/routing_extension.rb +85 -0
- data/lib/trane/serializer.rb +147 -0
- data/lib/trane/testing.rb +40 -0
- data/lib/trane/types.rb +67 -0
- data/lib/trane/version.rb +5 -0
- data/lib/trane.rb +111 -0
- metadata +167 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0fabf8d80352adbe88869422f34a1b246b78b32518fc2af532520ca6c9b42de6
|
|
4
|
+
data.tar.gz: 2c72a84965aacb8bff923af520882d8683a6e33f286760935da57296e8556395
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b5a40834b40f735228410ae3a4f65d464cc305a291306dd5fecb28f55c54f4aca7c6d576899aed158bdc60b55ed0e51a7686c39aa5c6fdd375defde1ab2e4d8e
|
|
7
|
+
data.tar.gz: e8de68a5e64d089111e37ef49f7949a960ff4fe748fcd01b12fc1fda6ef5a094f7a394a328429ddf9d83e24e6b5dadc483d99a867a16eeebc58014efa05e097b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-08-14
|
|
9
|
+
|
|
10
|
+
First public release.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Contract DSL: `Trane.operation`, `Trane.representation`, and `Trane.errors`
|
|
15
|
+
for declaring API contracts as code, auto-loaded from `app/api_contract/`
|
|
16
|
+
(configurable via `config.trane.contracts_paths`).
|
|
17
|
+
- `render contract:` controller integration with deterministic serialization:
|
|
18
|
+
responses contain exactly the declared fields (extra `extra: true` fields
|
|
19
|
+
are client-opt-in via `extra_attributes[]`).
|
|
20
|
+
- Structured error handling: raised exceptions map to a registered error
|
|
21
|
+
catalog and render a consistent JSON error envelope; unhandled exceptions
|
|
22
|
+
are reported through `Rails.error` and logged before rendering a generic
|
|
23
|
+
500 (verbose details only in local environments).
|
|
24
|
+
- Strict contract validation (`:raise` / `:log` / `:ignore`, auto-detected by
|
|
25
|
+
environment): missing keys, undeclared keys, and composite values in scalar
|
|
26
|
+
leaf fields.
|
|
27
|
+
- Boot-time validation of contract referential integrity, route `contract:`
|
|
28
|
+
metadata validation with "did you mean" hints, and a `trane:check` rake
|
|
29
|
+
task.
|
|
30
|
+
- Auto-generated documentation (HTML + Service Definition JSON) served by a
|
|
31
|
+
mountable engine, cached per boot/reload.
|
|
32
|
+
- Fail-closed defaults: `render contract:` on a route without contract
|
|
33
|
+
metadata raises by default (`on_missing_operation` opt-out), and
|
|
34
|
+
configuration setters reject unknown modes.
|
|
35
|
+
|
|
36
|
+
[0.1.0]: https://github.com/thisisqubika/trane/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Qubika
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/thisisqubika/trane/main/docs/trane-logo.png" width="380" alt="Trane logo">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Trane
|
|
6
|
+
|
|
7
|
+
[](https://github.com/thisisqubika/trane/actions/workflows/ci.yml)
|
|
8
|
+
|
|
9
|
+
Contract enforcement and documentation layer for Rails APIs.
|
|
10
|
+
|
|
11
|
+
Declare your API's contract — operations, representations, errors — as code,
|
|
12
|
+
and Trane takes it from there:
|
|
13
|
+
|
|
14
|
+
- **Deterministic serialization**: responses contain exactly the fields the
|
|
15
|
+
contract declares, nothing more. No accidental `password_digest` in a JSON.
|
|
16
|
+
- **Contract validation**: drift between what you declared and what you serve
|
|
17
|
+
fails loud in development and gets logged in production.
|
|
18
|
+
- **Structured error handling**: raise your domain exceptions; clients get a
|
|
19
|
+
consistent JSON error envelope with the right status code.
|
|
20
|
+
- **Auto-generated documentation**: polished HTML + machine-readable JSON,
|
|
21
|
+
always in sync with the contract, served from a mountable engine.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Add Trane to your Gemfile:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
gem "trane"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then run:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bundle install
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Supported versions
|
|
38
|
+
|
|
39
|
+
| Component | Versions |
|
|
40
|
+
|---|---|
|
|
41
|
+
| Ruby | >= 3.2 |
|
|
42
|
+
| Rails | 7.2, 8.0, 8.1 |
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
**1. Define your error catalog** — `app/api_contract/errors.rb`:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
Trane.errors do
|
|
50
|
+
error :UserNotFound, status_code: 404, description: "User not found"
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**2. Define a representation** — `app/api_contract/representations/user.rb`:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
Trane.representation :user do
|
|
58
|
+
field :id, type: :integer
|
|
59
|
+
field :name, type: :string
|
|
60
|
+
field :email, type: :string
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**3. Define an operation** — `app/api_contract/operations/users.rb`:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
Trane.operation :get_user do
|
|
68
|
+
summary "Get a user by id"
|
|
69
|
+
|
|
70
|
+
request do
|
|
71
|
+
path :id, type: :integer
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
response 200 do
|
|
75
|
+
field :user, type: :user
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
errors do
|
|
79
|
+
key :UserNotFound
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**4. Include the controller concern** (in your API base controller):
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
class ApplicationController < ActionController::API
|
|
88
|
+
include Trane::Controller
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**5. Render through the contract**:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
class UsersController < ApplicationController
|
|
96
|
+
def show
|
|
97
|
+
@user = User.find(params[:id])
|
|
98
|
+
render contract: { user: @user }
|
|
99
|
+
rescue ActiveRecord::RecordNotFound
|
|
100
|
+
raise UserNotFound
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**6. Wire the route to the operation** — `config/routes.rb`:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
get "/users/:id", to: "users#show", contract: { operation: :get_user }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
That's it. `GET /users/1` now serves exactly the declared fields, and a
|
|
112
|
+
missing user renders `{"errors":[{"key":"UserNotFound","message":"User not
|
|
113
|
+
found"}]}` with a 404.
|
|
114
|
+
|
|
115
|
+
**Optional — mount the documentation endpoints**:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
# config/routes.rb
|
|
119
|
+
unless Rails.env.production?
|
|
120
|
+
mount Trane::Engine, at: "/my-api/docs"
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`GET /my-api/docs` serves the HTML documentation and `/my-api/docs.json` the
|
|
125
|
+
machine-readable service definition. The docs expose your full API surface —
|
|
126
|
+
see [securing the docs endpoint](https://github.com/thisisqubika/trane/blob/main/docs/wiki/Documentation-Endpoints.md#securing-the-docs-endpoint)
|
|
127
|
+
before mounting in production.
|
|
128
|
+
|
|
129
|
+
## Documentation
|
|
130
|
+
|
|
131
|
+
The full guides live in the wiki:
|
|
132
|
+
|
|
133
|
+
| Guide | What it covers |
|
|
134
|
+
|---|---|
|
|
135
|
+
| [Configuration](https://github.com/thisisqubika/trane/blob/main/docs/wiki/Configuration.md) | Options, contract file locations and loading order, lifecycle, testing helper |
|
|
136
|
+
| [Representations](https://github.com/thisisqubika/trane/blob/main/docs/wiki/Representations.md) | Fields, formats, arrays, references, passthrough |
|
|
137
|
+
| [Operations](https://github.com/thisisqubika/trane/blob/main/docs/wiki/Operations.md) | Request DSL (path/query/body), response DSL, error keys |
|
|
138
|
+
| [Error Handling](https://github.com/thisisqubika/trane/blob/main/docs/wiki/Error-Handling.md) | Error catalog, exception matching, response envelope, unhandled errors |
|
|
139
|
+
| [Controller Integration](https://github.com/thisisqubika/trane/blob/main/docs/wiki/Controller-Integration.md) | The mixins, `render contract:`, status mapping, raising errors |
|
|
140
|
+
| [Routes](https://github.com/thisisqubika/trane/blob/main/docs/wiki/Routes.md) | The `contract:` route keyword |
|
|
141
|
+
| [Serialization](https://github.com/thisisqubika/trane/blob/main/docs/wiki/Serialization.md) | Value extraction, nil handling, nesting |
|
|
142
|
+
| [Extra Attributes](https://github.com/thisisqubika/trane/blob/main/docs/wiki/Extra-Attributes.md) | Optional fields clients opt into per request |
|
|
143
|
+
| [Validation](https://github.com/thisisqubika/trane/blob/main/docs/wiki/Validation.md) | Strict response validation, boot-time checks, `trane:check` |
|
|
144
|
+
| [Documentation Endpoints](https://github.com/thisisqubika/trane/blob/main/docs/wiki/Documentation-Endpoints.md) | Mounting, securing, the Service Definition JSON |
|
|
145
|
+
| [Field Types Reference](https://github.com/thisisqubika/trane/blob/main/docs/wiki/Field-Types-Reference.md) | Every type, option, and declaration variant |
|
|
146
|
+
| [Architecture](https://github.com/thisisqubika/trane/blob/main/docs/wiki/Architecture.md) | Process-level state, concurrency model, legacy API |
|
|
147
|
+
| [Complete Example](https://github.com/thisisqubika/trane/blob/main/docs/wiki/Complete-Example.md) | A full CRUD example, end to end |
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
MIT License. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :trane do
|
|
4
|
+
desc "Validate Trane contract registry (load files + cross-reference check)"
|
|
5
|
+
task check: :environment do
|
|
6
|
+
Trane::Registry.validate!
|
|
7
|
+
|
|
8
|
+
Rails.application.reload_routes_unless_loaded
|
|
9
|
+
Trane::RouteValidator.validate!(Rails.application.routes.routes, Trane.registry)
|
|
10
|
+
|
|
11
|
+
ops = Trane::Registry.operations.size
|
|
12
|
+
reps = Trane::Registry.representations.size
|
|
13
|
+
errs = Trane::Registry.errors.size
|
|
14
|
+
puts "OK - Trane registry validates clean (#{ops} operations, #{reps} representations, #{errs} errors); " \
|
|
15
|
+
"route/registry cross-check passed."
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trane
|
|
4
|
+
class BootValidator
|
|
5
|
+
# Validate referential integrity of the registry.
|
|
6
|
+
# Ensures that all representation references in operations exist,
|
|
7
|
+
# and all error keys referenced by operations are registered.
|
|
8
|
+
# Consumes the precomputed errors_by_name index from the snapshot.
|
|
9
|
+
#
|
|
10
|
+
# @param registry [Module] Trane::Registry
|
|
11
|
+
# @raise [Trane::Error] if any references are invalid
|
|
12
|
+
def self.validate!(registry)
|
|
13
|
+
errors = []
|
|
14
|
+
errors_by_name = registry.errors_by_name
|
|
15
|
+
|
|
16
|
+
registry.operations.each do |op_name, op|
|
|
17
|
+
op.responses.each do |status, resp|
|
|
18
|
+
resp.fields.each do |field|
|
|
19
|
+
errors.concat(validate_field_references(field, registry, context: "operation :#{op_name} response #{status}"))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
op.error_keys.each do |key|
|
|
24
|
+
key_str = key.to_s
|
|
25
|
+
next if errors_by_name.key?(key_str)
|
|
26
|
+
|
|
27
|
+
errors << "operation :#{op_name} references error :#{key}, but no such error is registered"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
return if errors.empty?
|
|
32
|
+
|
|
33
|
+
raise Trane::Error, "Trane boot validation failed:\n #{errors.join("\n ")}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def validate_field_references(field, registry, context:, path: [])
|
|
40
|
+
errors = []
|
|
41
|
+
joined_path = (path + [ field.name ]).join(".")
|
|
42
|
+
|
|
43
|
+
if field.type && Types.representation_reference?(field.type)
|
|
44
|
+
unless registry.representations.key?(field.type)
|
|
45
|
+
errors << "#{context}: field :#{joined_path} references representation :#{field.type}, which does not exist"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if field.array_of && Types.representation_reference?(field.array_of)
|
|
50
|
+
unless registry.representations.key?(field.array_of)
|
|
51
|
+
errors << "#{context}: field :#{joined_path} has array of :#{field.array_of}, which does not exist as a representation"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
child_path = path + [ field.name ]
|
|
56
|
+
field.children.each do |child|
|
|
57
|
+
errors.concat(validate_field_references(child, registry, context: context, path: child_path))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
errors
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trane
|
|
4
|
+
class Configuration
|
|
5
|
+
# Default contracts paths resolved relative to the Rails application root.
|
|
6
|
+
# Hosts that need a different location should set
|
|
7
|
+
# `config.trane.contracts_paths` in `config/application.rb`.
|
|
8
|
+
DEFAULT_CONTRACTS_PATHS = [ "app/api_contract" ].freeze
|
|
9
|
+
|
|
10
|
+
# Valid strict_mode values (nil is also accepted: auto-detect by env).
|
|
11
|
+
STRICT_MODES = %i[raise log ignore].freeze
|
|
12
|
+
|
|
13
|
+
# Valid modes for on_missing_operation (what `render contract:` does when
|
|
14
|
+
# the route did not declare `contract: { operation: ... }`).
|
|
15
|
+
ON_MISSING_OPERATION_MODES = %i[raise log fallback].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :strict_mode
|
|
18
|
+
|
|
19
|
+
# Returns the process-level Configuration instance via the Trane shim.
|
|
20
|
+
# Existing call sites (Trane::Configuration.instance.X) continue to work.
|
|
21
|
+
def self.instance
|
|
22
|
+
Trane.configuration
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
reset!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Marks the configuration as frozen. Subsequent setter calls raise
|
|
30
|
+
# FrozenError. Called by the Engine after :load_config_initializers
|
|
31
|
+
# so that runtime code cannot mutate config from another thread or
|
|
32
|
+
# request.
|
|
33
|
+
def freeze!
|
|
34
|
+
@frozen = true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def frozen_config?
|
|
38
|
+
@frozen
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Rejects unknown modes at assignment time: an unrecognized value would
|
|
42
|
+
# otherwise fall outside every consumer's case statement and silently
|
|
43
|
+
# disable contract validation (fail-open by typo).
|
|
44
|
+
def strict_mode=(value)
|
|
45
|
+
raise FrozenError, "Trane::Configuration is frozen; cannot modify strict_mode after boot" if @frozen
|
|
46
|
+
unless value.nil? || STRICT_MODES.include?(value)
|
|
47
|
+
raise Trane::Error,
|
|
48
|
+
"strict_mode must be nil (auto-detect) or one of " \
|
|
49
|
+
"#{STRICT_MODES.map(&:inspect).join(', ')} (got #{value.inspect})"
|
|
50
|
+
end
|
|
51
|
+
@strict_mode = value
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# What `render contract:` does when the route did not declare
|
|
55
|
+
# `contract: { operation: ... }` (so no contract can be resolved):
|
|
56
|
+
#
|
|
57
|
+
# :raise — fail loud with Trane::Error (default). Without a contract
|
|
58
|
+
# the field filtering cannot run, and serving the data
|
|
59
|
+
# unserialized would expose every attribute of the object.
|
|
60
|
+
# :log — serve the data unserialized, logging a warning per request.
|
|
61
|
+
# :fallback — serve the data unserialized, silently.
|
|
62
|
+
def on_missing_operation
|
|
63
|
+
@on_missing_operation || :raise
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def on_missing_operation=(value)
|
|
67
|
+
raise FrozenError, "Trane::Configuration is frozen; cannot modify on_missing_operation after boot" if @frozen
|
|
68
|
+
unless ON_MISSING_OPERATION_MODES.include?(value)
|
|
69
|
+
raise Trane::Error,
|
|
70
|
+
"on_missing_operation must be one of #{ON_MISSING_OPERATION_MODES.map(&:inspect).join(', ')} " \
|
|
71
|
+
"(got #{value.inspect})"
|
|
72
|
+
end
|
|
73
|
+
@on_missing_operation = value
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns the effective strict mode for the current environment.
|
|
77
|
+
#
|
|
78
|
+
# @return [Symbol] :raise, :log, or :ignore
|
|
79
|
+
def effective_strict_mode
|
|
80
|
+
return @strict_mode if @strict_mode
|
|
81
|
+
|
|
82
|
+
if defined?(Rails)
|
|
83
|
+
case Rails.env.to_s
|
|
84
|
+
when "development", "test" then :raise
|
|
85
|
+
when "production" then :log
|
|
86
|
+
else :log
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
:raise
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns the configured contracts paths, falling back to DEFAULT_CONTRACTS_PATHS
|
|
94
|
+
# when none have been explicitly set.
|
|
95
|
+
#
|
|
96
|
+
# To override, set `config.trane.contracts_paths = [...]` in
|
|
97
|
+
# `config/application.rb` — NOT in `config/initializers/trane.rb`, which
|
|
98
|
+
# runs too late for the Engine's `trane.ignore_autoload_paths` initializer.
|
|
99
|
+
def contracts_paths
|
|
100
|
+
@contracts_paths || DEFAULT_CONTRACTS_PATHS
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Internal — populated by the Engine from app.config.trane.contracts_paths.
|
|
104
|
+
# Host code should use `config.trane.contracts_paths = [...]` in
|
|
105
|
+
# config/application.rb, NOT call this method directly.
|
|
106
|
+
def _set_contracts_paths!(value)
|
|
107
|
+
raise FrozenError, "Trane::Configuration is frozen; cannot modify contracts_paths after boot" if @frozen
|
|
108
|
+
raise Trane::Error, "contracts_paths must be an Array" unless value.is_a?(Array)
|
|
109
|
+
raise Trane::Error, "contracts_paths must not be empty" if value.empty?
|
|
110
|
+
value.each_with_index do |entry, i|
|
|
111
|
+
raise Trane::Error, "contracts_paths[#{i}] must be a String or Pathname" unless entry.is_a?(String) || entry.is_a?(Pathname)
|
|
112
|
+
str = entry.to_s
|
|
113
|
+
raise Trane::Error, "contracts_paths[#{i}] must not be blank" if str.strip.empty?
|
|
114
|
+
raise Trane::Error, "contracts_paths[#{i}]: glob patterns are not supported" if str.match?(/[*?]/)
|
|
115
|
+
end
|
|
116
|
+
@contracts_paths = value.map(&:to_s)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def reset!
|
|
120
|
+
@strict_mode = nil
|
|
121
|
+
@contracts_paths = nil
|
|
122
|
+
@on_missing_operation = nil
|
|
123
|
+
@frozen = false
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Internal — full state snapshot/restore for Trane::Testing.
|
|
127
|
+
# Lives here, next to the ivars it enumerates, so adding a new
|
|
128
|
+
# configuration attribute forces updating this list in the same file
|
|
129
|
+
# (instead of silently losing it across a with_configuration block).
|
|
130
|
+
def _dump_state
|
|
131
|
+
{
|
|
132
|
+
strict_mode: @strict_mode,
|
|
133
|
+
contracts_paths: @contracts_paths,
|
|
134
|
+
on_missing_operation: @on_missing_operation,
|
|
135
|
+
frozen: @frozen
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
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]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trane
|
|
4
|
+
# Single source of truth for the contract-file load order, used by both
|
|
5
|
+
# the Engine's to_prepare block and any test harness that replays the
|
|
6
|
+
# boot-time load (spec/integration/integration_helper.rb) — keeping the
|
|
7
|
+
# two from silently diverging.
|
|
8
|
+
#
|
|
9
|
+
# Order:
|
|
10
|
+
# Phase 1 — every errors.rb from every base path (in declaration order).
|
|
11
|
+
# Phase 2 — all other .rb files from every base path (sorted within each).
|
|
12
|
+
module ContractLoader
|
|
13
|
+
# Yields the absolute path (String) of each contract file in load order.
|
|
14
|
+
#
|
|
15
|
+
# @param root [Pathname] the application root (e.g. Rails.root)
|
|
16
|
+
# @param contracts_paths [Array<String>] base paths relative to root
|
|
17
|
+
def self.each_file(root, contracts_paths)
|
|
18
|
+
errors_files = contracts_paths.map { |path| root.join(path, "errors.rb") }
|
|
19
|
+
errors_files.each { |file| yield file.to_s if file.exist? }
|
|
20
|
+
|
|
21
|
+
contracts_paths.each_with_index do |path, i|
|
|
22
|
+
errors_file = errors_files[i].to_s
|
|
23
|
+
Dir[root.join(path, "**/*.rb")].sort.each do |file|
|
|
24
|
+
yield file unless file == errors_file
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trane
|
|
4
|
+
class ContractViolation < Trane::Error; end
|
|
5
|
+
|
|
6
|
+
class ContractValidator
|
|
7
|
+
# Validate that a serialized result conforms to the response definition.
|
|
8
|
+
#
|
|
9
|
+
# @param response_def [ResponseDefinition]
|
|
10
|
+
# @param result [Hash] the serialized output
|
|
11
|
+
# @param registry [Module] Trane::Registry
|
|
12
|
+
# @param mode [Symbol] :raise or :log
|
|
13
|
+
def self.validate_response!(response_def, result, registry, mode:)
|
|
14
|
+
violations = collect_violations(response_def.fields, result, registry, prefix: "")
|
|
15
|
+
return if violations.empty?
|
|
16
|
+
|
|
17
|
+
message = "Trane contract violations (status #{response_def.status}):\n #{violations.join("\n ")}"
|
|
18
|
+
case mode
|
|
19
|
+
when :raise
|
|
20
|
+
raise ContractViolation, message
|
|
21
|
+
when :log
|
|
22
|
+
Trane.log_warning(message)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def collect_violations(fields, result, registry, prefix:)
|
|
30
|
+
violations = []
|
|
31
|
+
|
|
32
|
+
declared_keys = registry.validator_declared_field_names_for(fields)
|
|
33
|
+
|
|
34
|
+
# Check for missing declared (non-extra) keys
|
|
35
|
+
declared_keys.each do |key|
|
|
36
|
+
unless result.key?(key)
|
|
37
|
+
violations << "#{format_path(prefix, key)}: missing from response"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Check for undeclared keys
|
|
42
|
+
allowed_keys = registry.validator_field_names_for(fields)
|
|
43
|
+
result.each_key do |key|
|
|
44
|
+
unless allowed_keys.include?(key)
|
|
45
|
+
violations << "#{format_path(prefix, key)}: undeclared field in response"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Recurse into representation fields; flag composite values that
|
|
50
|
+
# landed in scalar leaves
|
|
51
|
+
fields.each do |field|
|
|
52
|
+
value = result[field.name]
|
|
53
|
+
next if value.nil?
|
|
54
|
+
|
|
55
|
+
rep = registry.representations[field.type] if field.type
|
|
56
|
+
if rep && value.is_a?(Hash)
|
|
57
|
+
child_prefix = format_path(prefix, field.name)
|
|
58
|
+
violations.concat(collect_violations(rep.fields, value, registry, prefix: child_prefix))
|
|
59
|
+
elsif composite_scalar_violation?(field, value)
|
|
60
|
+
violations << "#{format_path(prefix, field.name)}: composite #{value.class} value " \
|
|
61
|
+
"in scalar field (declared type :#{field.type})"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
violations
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A Hash or Array in a field declared as a scalar leaf means the
|
|
69
|
+
# caller passed a composite object where a value was expected — the
|
|
70
|
+
# serializer emits leaf values verbatim, so the whole object (every
|
|
71
|
+
# attribute, e.g. a full model as_json) would reach the client.
|
|
72
|
+
# Only scalar leaf types are checked: :object is free-form by
|
|
73
|
+
# declaration, :array carries Arrays, representation-typed fields
|
|
74
|
+
# recurse above, and fields with children are structurally filtered
|
|
75
|
+
# by the serializer.
|
|
76
|
+
def composite_scalar_violation?(field, value)
|
|
77
|
+
return false unless Types::ENUMERABLE_TYPES.include?(field.type)
|
|
78
|
+
return false unless field.children.nil? || field.children.empty?
|
|
79
|
+
|
|
80
|
+
value.is_a?(Hash) || value.is_a?(Array)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def format_path(prefix, key)
|
|
84
|
+
prefix.empty? ? key.to_s : "#{prefix}.#{key}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|