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.
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trane
4
+ VERSION = "0.1.0"
5
+ 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