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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
|
|
5
|
+
module Trane
|
|
6
|
+
# Cross-checks drawn routes against the operation registry. Two independent
|
|
7
|
+
# checks, each with its own failure message:
|
|
8
|
+
# 1. Every route declaring `_trane_operation` must reference a registered
|
|
9
|
+
# operation (catches typos in `contract: { operation: :x }`).
|
|
10
|
+
# 2. Every such route must map to exactly one HTTP verb (a Trane operation
|
|
11
|
+
# is one verb + one path). Rails collapses `via: [:patch, :put]` into a
|
|
12
|
+
# single route whose #verb is "PATCH|PUT"; a `match` with no verb (or
|
|
13
|
+
# `via: :all`) yields a blank verb matching any method. Both are rejected.
|
|
14
|
+
#
|
|
15
|
+
# Pure `(routes, registry)` function — no Rails::Application coupling — so it
|
|
16
|
+
# can be exercised with fake route doubles in unit specs and with a real
|
|
17
|
+
# route set from the Engine or the `trane:check` rake task.
|
|
18
|
+
class RouteValidator
|
|
19
|
+
# @param routes [Enumerable] route objects responding to #verb, #defaults, #path
|
|
20
|
+
# @param registry [Module, Trane::Registry::Instance] responds to #operations
|
|
21
|
+
# @raise [Trane::RoutingContractError] on an unregistered operation or a multi/any-verb route
|
|
22
|
+
def self.validate!(routes, registry)
|
|
23
|
+
validate_operations_registered!(routes, registry)
|
|
24
|
+
validate_single_verb!(routes)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def validate_operations_registered!(routes, registry)
|
|
31
|
+
orphans = {}
|
|
32
|
+
|
|
33
|
+
routes.each do |route|
|
|
34
|
+
op = route.defaults[:_trane_operation]
|
|
35
|
+
next if op.blank?
|
|
36
|
+
next if registry.operations.key?(op.to_sym)
|
|
37
|
+
|
|
38
|
+
orphans[op] ||= route
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
return if orphans.empty?
|
|
42
|
+
|
|
43
|
+
operation_names = registry.operations.keys
|
|
44
|
+
details = orphans.map do |op, route|
|
|
45
|
+
suggestion = Trane.spelling_suggestion(op, operation_names)
|
|
46
|
+
line = "#{describe_route(route)} references operation :#{op}, but no such operation is registered"
|
|
47
|
+
line += " (did you mean :#{suggestion}?)" if suggestion
|
|
48
|
+
line
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
raise Trane::RoutingContractError, "Trane route/registry cross-check failed:\n #{details.join("\n ")}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_single_verb!(routes)
|
|
55
|
+
offenders = {}
|
|
56
|
+
|
|
57
|
+
routes.each do |route|
|
|
58
|
+
op = route.defaults[:_trane_operation]
|
|
59
|
+
next if op.blank?
|
|
60
|
+
next if single_http_verb?(route.verb)
|
|
61
|
+
|
|
62
|
+
offenders[op] ||= route
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
return if offenders.empty?
|
|
66
|
+
|
|
67
|
+
details = offenders.map { |op, route| "#{describe_route(route)} → operation :#{op}" }
|
|
68
|
+
raise Trane::RoutingContractError,
|
|
69
|
+
"Trane: each route with a contract: must map to exactly one HTTP verb, " \
|
|
70
|
+
"but these map to multiple verbs (or to any verb):\n #{details.join("\n ")}\n " \
|
|
71
|
+
"Split into separate operations or pick a single verb."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Rails 8.1 exposes #verb as a String ("GET", "PATCH|PUT", or "" for ANY).
|
|
75
|
+
# `verb.to_s` also handles the Regexp form older Rails versions expose:
|
|
76
|
+
# its #to_s carries the "|" for multi-verb alternations.
|
|
77
|
+
def single_http_verb?(verb)
|
|
78
|
+
s = verb.to_s
|
|
79
|
+
!s.strip.empty? && !s.include?("|")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def describe_route(route)
|
|
83
|
+
verb = route.verb
|
|
84
|
+
verb = "ANY" if verb.blank?
|
|
85
|
+
"#{verb} #{route.path.spec}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trane
|
|
4
|
+
# Prepended globally onto ActionDispatch::Routing::Mapper by the Engine
|
|
5
|
+
# initializer `trane.prepend_routing_extension`. Available in all
|
|
6
|
+
# `routes.draw` blocks. Routes without `contract:` pass through unchanged
|
|
7
|
+
# in O(1).
|
|
8
|
+
#
|
|
9
|
+
# Uses `*path_or_actions, **kwargs` to mirror Rails' own `Mapper::Resources#match`
|
|
10
|
+
# signature and handle every internal calling convention (normal path, hash-
|
|
11
|
+
# shorthand `"up" => "ctrl#action"`, zero-positional-arg `match` calls, etc.).
|
|
12
|
+
module RoutingExtension
|
|
13
|
+
# Single source of truth for the keys accepted inside `contract: { ... }`.
|
|
14
|
+
# Anything else is a typo and must fail loud at route-draw time rather
|
|
15
|
+
# than being silently ignored or deferred to request time.
|
|
16
|
+
CONTRACT_KEYS = %i[operation].freeze
|
|
17
|
+
|
|
18
|
+
# Intercepts route declarations that carry `contract: { operation: name }`.
|
|
19
|
+
# Strips the key, injects the operation name into `defaults[:_trane_operation]`
|
|
20
|
+
# (used at request time to resolve the contract), and sets `as: name` so the
|
|
21
|
+
# operation is discoverable as a named-route prefix in `bin/rails routes`.
|
|
22
|
+
#
|
|
23
|
+
# Routes without `contract:` are forwarded to super unchanged.
|
|
24
|
+
#
|
|
25
|
+
# Rails 7.x / 8.0 call match(*args, options_hash) via map_method — a plain
|
|
26
|
+
# Hash as the last positional argument. Rails 8.1+ uses proper keyword
|
|
27
|
+
# arguments. Both conventions must be handled here.
|
|
28
|
+
def match(*path_or_actions, **kwargs)
|
|
29
|
+
if kwargs.key?(:contract)
|
|
30
|
+
# Rails 8.1+ keyword-argument path
|
|
31
|
+
kwargs = kwargs.dup
|
|
32
|
+
contract = kwargs.delete(:contract)
|
|
33
|
+
_inject_trane_contract!(nil, kwargs, contract)
|
|
34
|
+
elsif path_or_actions.last.is_a?(Hash) && path_or_actions.last.key?(:contract)
|
|
35
|
+
# Rails 7.x / 8.0 positional-hash path (map_method calls match(*args, opts))
|
|
36
|
+
path_or_actions = path_or_actions.dup
|
|
37
|
+
options = path_or_actions.last.dup
|
|
38
|
+
path_or_actions[-1] = options
|
|
39
|
+
contract = options.delete(:contract)
|
|
40
|
+
_inject_trane_contract!(options, nil, contract)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
super(*path_or_actions, **kwargs)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Mutates either +options+ (positional hash) or +kwargs+ (keyword hash) in-place.
|
|
49
|
+
def _inject_trane_contract!(options, kwargs, contract)
|
|
50
|
+
_validate_contract_hash!(contract)
|
|
51
|
+
|
|
52
|
+
target = options || kwargs
|
|
53
|
+
operation = contract[:operation].to_s
|
|
54
|
+
defaults = target[:defaults] || {}
|
|
55
|
+
target[:defaults] = defaults.merge(_trane_operation: operation)
|
|
56
|
+
# Set :as to the operation name unless the caller already provided an
|
|
57
|
+
# explicit Symbol or String. ActionDispatch::Routing::Mapper uses a
|
|
58
|
+
# private DEFAULT = Object.new sentinel for "not set by user", which is
|
|
59
|
+
# truthy, so `target[:as] ||= operation` would silently skip it.
|
|
60
|
+
user_as = target[:as]
|
|
61
|
+
target[:as] = operation unless user_as.is_a?(Symbol) || user_as.is_a?(String)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Fails loud on malformed `contract:` metadata: unknown keys (typos) are
|
|
65
|
+
# reported first — with a "Did you mean?" suggestion when one is close —
|
|
66
|
+
# followed by a check that :operation is present and non-blank. Checking
|
|
67
|
+
# unknown keys first means a typo like `operaton:` is reported as an
|
|
68
|
+
# unknown key (with a suggestion) rather than as a missing :operation.
|
|
69
|
+
def _validate_contract_hash!(contract)
|
|
70
|
+
contract = {} unless contract.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
unknown = contract.keys - CONTRACT_KEYS
|
|
73
|
+
unless unknown.empty?
|
|
74
|
+
bad = unknown.first
|
|
75
|
+
message = "Unknown key `#{bad}` in contract:."
|
|
76
|
+
suggestion = Trane.spelling_suggestion(bad, CONTRACT_KEYS)
|
|
77
|
+
message += " Did you mean `#{suggestion}`?" if suggestion
|
|
78
|
+
raise Trane::RoutingContractError, message
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
op = contract[:operation]
|
|
82
|
+
raise Trane::RoutingContractError, "contract: requires a non-empty :operation" if op.nil? || op.to_s.strip.empty?
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trane
|
|
4
|
+
class Serializer
|
|
5
|
+
# @param response_definition [ResponseDefinition]
|
|
6
|
+
# @param registry [Module] Trane::Registry
|
|
7
|
+
# @param strict_mode [Symbol] :raise, :log, or :ignore
|
|
8
|
+
# Instances are frozen post-init; share freely across threads.
|
|
9
|
+
def initialize(response_definition, registry, strict_mode: :ignore)
|
|
10
|
+
@response_definition = response_definition
|
|
11
|
+
@registry = registry
|
|
12
|
+
@strict_mode = strict_mode
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Serialize data according to the response definition.
|
|
17
|
+
#
|
|
18
|
+
# @param data [Hash, Object] the data to serialize (hash of root-level keys)
|
|
19
|
+
# @param extra_attributes [Set<String>] dot-notation paths of extra fields to include
|
|
20
|
+
# @return [Hash] serialized result
|
|
21
|
+
def serialize(data, extra_attributes: ExtraAttributesFilter::EMPTY)
|
|
22
|
+
result = serialize_fields(@response_definition.fields, data, extra_attributes: extra_attributes, prefix: "")
|
|
23
|
+
|
|
24
|
+
if @strict_mode != :ignore
|
|
25
|
+
ContractValidator.validate_response!(
|
|
26
|
+
@response_definition, result, @registry, mode: @strict_mode
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
result
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Serialize a list of fields against a data source.
|
|
36
|
+
#
|
|
37
|
+
# @param fields [Array<FieldNode>] field definitions
|
|
38
|
+
# @param data [Hash, Object] the data to extract values from
|
|
39
|
+
# @param extra_attributes [Set<String>] extra attribute paths
|
|
40
|
+
# @param prefix [String] current dot-notation prefix
|
|
41
|
+
# @return [Hash]
|
|
42
|
+
def serialize_fields(fields, data, extra_attributes:, prefix:)
|
|
43
|
+
result = {}
|
|
44
|
+
|
|
45
|
+
fields.each do |field|
|
|
46
|
+
if field.extra
|
|
47
|
+
path = build_path(prefix, field.name)
|
|
48
|
+
next unless extra_attributes.include?(path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
value = extract_value(data, field.name)
|
|
52
|
+
result[field.name] = serialize_value(field, value, extra_attributes: extra_attributes, prefix: prefix)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
result
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Serialize a single field value.
|
|
59
|
+
def serialize_value(field, value, extra_attributes:, prefix:)
|
|
60
|
+
return nil if value.nil?
|
|
61
|
+
|
|
62
|
+
if field.type == :array
|
|
63
|
+
return serialize_array(field, value, extra_attributes: extra_attributes, prefix: prefix)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
unless field.children.empty?
|
|
67
|
+
new_prefix = build_path(prefix, field.name)
|
|
68
|
+
return serialize_fields(field.children, value, extra_attributes: extra_attributes, prefix: new_prefix)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
rep = @registry.representations[field.type]
|
|
72
|
+
if rep
|
|
73
|
+
new_prefix = build_path(prefix, field.name)
|
|
74
|
+
return serialize_fields(rep.fields, value, extra_attributes: extra_attributes, prefix: new_prefix)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if field.format == :iso8601 && value.respond_to?(:iso8601)
|
|
78
|
+
return value.iso8601
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Serialize an array field.
|
|
85
|
+
def serialize_array(field, value, extra_attributes:, prefix:)
|
|
86
|
+
unless array_like?(value)
|
|
87
|
+
handle_non_iterable_array(value, build_path(prefix, field.name))
|
|
88
|
+
return []
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if field.array_of || !field.children.empty?
|
|
92
|
+
element_prefix = build_path(prefix, field.name)
|
|
93
|
+
value.map do |element|
|
|
94
|
+
if field.array_of
|
|
95
|
+
rep = @registry.representations[field.array_of]
|
|
96
|
+
rep ? serialize_fields(rep.fields, element, extra_attributes: extra_attributes, prefix: element_prefix) : element
|
|
97
|
+
else
|
|
98
|
+
serialize_fields(field.children, element, extra_attributes: extra_attributes, prefix: element_prefix)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
else
|
|
102
|
+
# Fresh Array in both branches: never alias the caller's collection
|
|
103
|
+
# into the result, and normalize non-Array Enumerables (Set, lazy
|
|
104
|
+
# enumerators) into something JSON.generate can serialize. dup/to_a
|
|
105
|
+
# instead of an identity map: same semantics without dispatching a
|
|
106
|
+
# block per element (memcpy vs O(n) yields on large scalar arrays).
|
|
107
|
+
value.is_a?(Array) ? value.dup : value.to_a
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def array_like?(value)
|
|
112
|
+
value.is_a?(Array) || (value.is_a?(Enumerable) && !value.is_a?(Hash))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def handle_non_iterable_array(value, path)
|
|
116
|
+
return if @strict_mode == :ignore
|
|
117
|
+
|
|
118
|
+
message = "Trane: expected Array at #{path}, got #{value.class}"
|
|
119
|
+
case @strict_mode
|
|
120
|
+
when :raise
|
|
121
|
+
raise ContractViolation, message
|
|
122
|
+
when :log
|
|
123
|
+
Trane.log_warning(message)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Lazily compose dot-notation path; allocates only when a consumer
|
|
128
|
+
# (extras gate, nested-rep recursion, or strict-mode violation) needs it.
|
|
129
|
+
def build_path(prefix, leaf_sym)
|
|
130
|
+
prefix.empty? ? leaf_sym.name : "#{prefix}.#{leaf_sym}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Extract a value from data (supports both Hash and objects).
|
|
134
|
+
#
|
|
135
|
+
# For objects, only fields the receiver explicitly responds to are
|
|
136
|
+
# extracted; anything else returns nil. NoMethodError raised inside a
|
|
137
|
+
# getter is left to propagate so genuine bugs aren't swallowed.
|
|
138
|
+
def extract_value(data, field_name)
|
|
139
|
+
if data.is_a?(Hash)
|
|
140
|
+
return data[field_name] if data.key?(field_name)
|
|
141
|
+
data[field_name.name]
|
|
142
|
+
elsif data.respond_to?(field_name)
|
|
143
|
+
data.public_send(field_name)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "trane"
|
|
4
|
+
|
|
5
|
+
module Trane
|
|
6
|
+
# Test helper for temporarily reconfiguring Trane.
|
|
7
|
+
# Opt-in via: require "trane/testing"
|
|
8
|
+
module Testing
|
|
9
|
+
# Resets Configuration to the given attrs, freezes it, yields, then
|
|
10
|
+
# restores the original state. Guarantees restore even when the block
|
|
11
|
+
# raises.
|
|
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.
|
|
17
|
+
#
|
|
18
|
+
# `contracts_paths` is also configuration state (set from
|
|
19
|
+
# `config/application.rb` via the internal `_set_contracts_paths!`, not
|
|
20
|
+
# via `Trane.configure`), and `config.reset!` clears it along with
|
|
21
|
+
# `strict_mode`. This helper snapshots and restores both, so a host with a
|
|
22
|
+
# custom `contracts_paths` does not lose it across a call to this helper.
|
|
23
|
+
def self.with_configuration(**attrs)
|
|
24
|
+
raise Trane::Error, "Trane::Testing.with_configuration requires Rails.application" unless defined?(Rails) && Rails.application
|
|
25
|
+
|
|
26
|
+
config = Trane.configuration
|
|
27
|
+
snapshot = config._dump_state
|
|
28
|
+
|
|
29
|
+
config.reset!
|
|
30
|
+
attrs.each { |k, v| config.public_send(:"#{k}=", v) }
|
|
31
|
+
config.freeze!
|
|
32
|
+
|
|
33
|
+
yield config
|
|
34
|
+
ensure
|
|
35
|
+
# `config` is nil when the guard above raised; without this check the
|
|
36
|
+
# ensure block would replace that error with a NoMethodError.
|
|
37
|
+
config._restore_state!(snapshot) if config
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/trane/types.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
module Trane
|
|
7
|
+
module Types
|
|
8
|
+
PRIMITIVES = Set[
|
|
9
|
+
:string, :integer, :float, :boolean,
|
|
10
|
+
:date, :datetime, :object, :array
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
# Types that support enum: (scalar primitives only)
|
|
14
|
+
ENUMERABLE_TYPES = Set[
|
|
15
|
+
:string, :integer, :float, :boolean, :date, :datetime
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
HTTP_STATUS_RANGE = (100..599).freeze
|
|
19
|
+
|
|
20
|
+
def self.representation_reference?(type)
|
|
21
|
+
!type.nil? && !PRIMITIVES.include?(type)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Strict type-match: each value must be EXACTLY of the declared type's class.
|
|
25
|
+
# No coercion (Integer not accepted for :float, DateTime not accepted for :date, etc).
|
|
26
|
+
def self.value_matches_type?(value, type)
|
|
27
|
+
case type
|
|
28
|
+
when :string then value.is_a?(String)
|
|
29
|
+
when :integer then value.is_a?(Integer)
|
|
30
|
+
when :float then value.is_a?(Float)
|
|
31
|
+
when :boolean then value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
32
|
+
when :date then value.is_a?(Date) && !value.is_a?(DateTime)
|
|
33
|
+
# !! keeps the return strictly boolean: `defined?` yields nil (not
|
|
34
|
+
# false) when TimeWithZone isn't loaded, and nil would leak out.
|
|
35
|
+
when :datetime then value.is_a?(DateTime) || value.is_a?(Time) ||
|
|
36
|
+
(!!defined?(ActiveSupport::TimeWithZone) && value.is_a?(ActiveSupport::TimeWithZone))
|
|
37
|
+
else false
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Validates the structure and content of an enum: declaration. Raises ArgumentError if invalid.
|
|
42
|
+
def self.validate_enum!(name:, type:, enum:)
|
|
43
|
+
return if enum.nil?
|
|
44
|
+
|
|
45
|
+
unless enum.is_a?(Array)
|
|
46
|
+
raise ArgumentError, "field/param :#{name} enum: must be an Array (got #{enum.class})"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if enum.empty?
|
|
50
|
+
raise ArgumentError, "field/param :#{name} enum: must be a non-empty Array"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
unless ENUMERABLE_TYPES.include?(type)
|
|
54
|
+
raise ArgumentError,
|
|
55
|
+
"field/param :#{name} enum: is only supported for scalar primitive types " \
|
|
56
|
+
"(#{ENUMERABLE_TYPES.to_a.map { |t| ":#{t}" }.join(', ')}). Got :#{type}."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
enum.each do |value|
|
|
60
|
+
unless value_matches_type?(value, type)
|
|
61
|
+
raise ArgumentError,
|
|
62
|
+
"field/param :#{name} enum value #{value.inspect} (#{value.class}) is not coherent with type :#{type}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/trane.rb
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trane
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when a route's `contract:` metadata is malformed (unknown key,
|
|
7
|
+
# missing/blank :operation) or when a route's declared operation has no
|
|
8
|
+
# matching entry in the registry. Fails loud at boot/draw time instead of
|
|
9
|
+
# deferring the mistake to request time.
|
|
10
|
+
class RoutingContractError < Error; end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
require_relative "trane/version"
|
|
14
|
+
require_relative "trane/configuration"
|
|
15
|
+
require_relative "trane/types"
|
|
16
|
+
require_relative "trane/field_node"
|
|
17
|
+
require_relative "trane/field_builder"
|
|
18
|
+
require_relative "trane/param_definition"
|
|
19
|
+
require_relative "trane/operation_definition"
|
|
20
|
+
require_relative "trane/representation_definition"
|
|
21
|
+
require_relative "trane/error_registry"
|
|
22
|
+
require_relative "trane/registry"
|
|
23
|
+
require_relative "trane/serializer"
|
|
24
|
+
require_relative "trane/extra_attributes_filter"
|
|
25
|
+
require_relative "trane/boot_validator"
|
|
26
|
+
require_relative "trane/contract_loader"
|
|
27
|
+
require_relative "trane/route_validator"
|
|
28
|
+
require_relative "trane/contract_validator"
|
|
29
|
+
require_relative "trane/controller"
|
|
30
|
+
require_relative "trane/routing_extension"
|
|
31
|
+
|
|
32
|
+
module Trane
|
|
33
|
+
# Process-level Trane state. Trane supports exactly one Rails application
|
|
34
|
+
# per Ruby process — the standard Rails deployment model. The registry and
|
|
35
|
+
# configuration are created eagerly at require time so pre-boot DSL
|
|
36
|
+
# registrations (e.g. unit specs calling Trane.operation before Rails
|
|
37
|
+
# boots) land in the same objects the booted application later uses.
|
|
38
|
+
@registry = Registry::Instance.new
|
|
39
|
+
@configuration = Configuration.new
|
|
40
|
+
|
|
41
|
+
def self.configure
|
|
42
|
+
yield configuration
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns the process-level Configuration.
|
|
46
|
+
def self.configuration
|
|
47
|
+
@configuration
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns the process-level Registry::Instance.
|
|
51
|
+
def self.registry
|
|
52
|
+
@registry
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Restores Trane to a pristine state: empties the registry (snapshot and
|
|
56
|
+
# derived caches), clears the configuration (values and frozen flag), and
|
|
57
|
+
# invalidates the docs cache. Intended for test suites that need isolation
|
|
58
|
+
# between examples or that boot throwaway Rails applications.
|
|
59
|
+
def self.reset!
|
|
60
|
+
registry.reset!
|
|
61
|
+
configuration.reset!
|
|
62
|
+
Docs::Cache.invalidate!
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.operation(name, &block)
|
|
67
|
+
builder = OperationBuilder.new(name)
|
|
68
|
+
builder.instance_eval(&block)
|
|
69
|
+
registry.register_operation(builder.build)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.representation(name, &block)
|
|
73
|
+
builder = RepresentationBuilder.new(name)
|
|
74
|
+
builder.instance_eval(&block)
|
|
75
|
+
registry.register_representation(builder.build)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.errors(&block)
|
|
79
|
+
builder = ErrorsBuilder.new
|
|
80
|
+
builder.instance_eval(&block)
|
|
81
|
+
builder.definitions.each { |d| registry.register_error(d) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Logs a warning through Rails.logger when available, falling back to
|
|
85
|
+
# Kernel#warn (stderr) outside Rails. Single home for the guard chain so
|
|
86
|
+
# every warn-level message in the gem routes identically.
|
|
87
|
+
def self.log_warning(message)
|
|
88
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
89
|
+
Rails.logger.warn(message)
|
|
90
|
+
else
|
|
91
|
+
warn(message)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Returns the closest spelling match for term among candidates, or nil
|
|
96
|
+
# when no candidate is close enough. Backs the "Did you mean?" hints in
|
|
97
|
+
# both the routing contract: hash validator and the route/registry
|
|
98
|
+
# cross-check, so the two validations never drift on suggestion logic.
|
|
99
|
+
def self.spelling_suggestion(term, candidates)
|
|
100
|
+
require "did_you_mean"
|
|
101
|
+
DidYouMean::SpellChecker.new(dictionary: candidates.map(&:to_s)).correct(term.to_s).first
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
require_relative "trane/docs/service_definition"
|
|
106
|
+
require_relative "trane/docs/html_renderer"
|
|
107
|
+
require_relative "trane/docs/cache"
|
|
108
|
+
|
|
109
|
+
if defined?(Rails::Engine)
|
|
110
|
+
require_relative "trane/engine"
|
|
111
|
+
end
|