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 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
+ [![CI](https://github.com/thisisqubika/trane/actions/workflows/ci.yml/badge.svg)](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