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,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trane
|
|
4
|
+
class FieldBuilder
|
|
5
|
+
attr_reader :fields
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@fields = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Define a field in the current context.
|
|
12
|
+
#
|
|
13
|
+
# @param name [Symbol] field name
|
|
14
|
+
# @param type [Symbol, nil] field type keyword — mandatory unless `of:` or a block is provided
|
|
15
|
+
# @param extra [Boolean] whether this is an optional extra field
|
|
16
|
+
# @param format [Symbol, nil] format hint (e.g., :iso8601)
|
|
17
|
+
# @param of [Symbol, nil] element type for arrays (infers type: :array)
|
|
18
|
+
# @param enum [Array, nil] set of allowed values; only valid for scalar primitive types
|
|
19
|
+
# @yield optional block for inline nested fields (infers type: :object)
|
|
20
|
+
def field(name, type: nil, extra: false, format: nil, of: nil, enum: nil, &block)
|
|
21
|
+
@fields << _build_field_node(
|
|
22
|
+
name: name, type: type, extra: extra, format: format,
|
|
23
|
+
of: of, enum: enum, child_builder_class: FieldBuilder,
|
|
24
|
+
required: nil, &block
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# Shared field construction logic used by both FieldBuilder and BodyBuilder.
|
|
31
|
+
# Validates presence of at least one type specifier, resolves the type, validates
|
|
32
|
+
# enum constraints, recursively builds children, and returns a FieldNode.
|
|
33
|
+
#
|
|
34
|
+
# @param child_builder_class [Class] builder class to use for nested fields
|
|
35
|
+
# @param required [Boolean, nil] whether the field is required (only meaningful for body fields)
|
|
36
|
+
def _build_field_node(name:, type:, extra:, format:, of:, enum:, child_builder_class:, required:, &block)
|
|
37
|
+
if name.nil? || name.to_s.empty?
|
|
38
|
+
raise ArgumentError, "field name cannot be nil or empty"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if type.nil? && of.nil? && !block_given?
|
|
42
|
+
raise ArgumentError,
|
|
43
|
+
"field #{name.inspect} must specify type:, of:, or provide a block"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
resolved_type = _resolve_type(type, of, block_given?)
|
|
47
|
+
Trane::Types.validate_enum!(name: name, type: resolved_type, enum: enum) if enum
|
|
48
|
+
|
|
49
|
+
children = if block_given?
|
|
50
|
+
child_builder = child_builder_class.new
|
|
51
|
+
child_builder.instance_eval(&block)
|
|
52
|
+
child_builder.fields
|
|
53
|
+
else
|
|
54
|
+
[]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
FieldNode.new(
|
|
58
|
+
name: name,
|
|
59
|
+
type: resolved_type,
|
|
60
|
+
extra: extra,
|
|
61
|
+
format: format,
|
|
62
|
+
array_of: of,
|
|
63
|
+
required: required,
|
|
64
|
+
enum: enum,
|
|
65
|
+
children: children
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Resolve the effective field type from the three type-specifier inputs.
|
|
70
|
+
# Contract: always returns a non-nil Symbol; raises if the precondition in
|
|
71
|
+
# `_build_field_node` is bypassed (i.e. all three inputs falsy).
|
|
72
|
+
def _resolve_type(type, of, has_block)
|
|
73
|
+
return :array if of
|
|
74
|
+
return type if type
|
|
75
|
+
return :object if has_block
|
|
76
|
+
|
|
77
|
+
raise ArgumentError,
|
|
78
|
+
"_resolve_type called without type, of, or block — precondition bypassed"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trane
|
|
4
|
+
FieldNode = Data.define(:name, :type, :extra, :format, :array_of, :required, :enum, :children) do
|
|
5
|
+
def initialize(name:, type: nil, extra: false, format: nil, array_of: nil, required: nil, enum: nil, children: [])
|
|
6
|
+
super(
|
|
7
|
+
name: name.to_sym,
|
|
8
|
+
type: type&.to_sym,
|
|
9
|
+
extra: extra,
|
|
10
|
+
format: format&.to_sym,
|
|
11
|
+
array_of: array_of&.to_sym,
|
|
12
|
+
required: required,
|
|
13
|
+
enum: enum&.freeze,
|
|
14
|
+
children: children.freeze
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trane
|
|
4
|
+
RequestDefinition = Data.define(:params, :body_fields) do
|
|
5
|
+
def initialize(params: [], body_fields: [])
|
|
6
|
+
super(params: params.freeze, body_fields: body_fields.freeze)
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
ResponseDefinition = Data.define(:status, :fields) do
|
|
11
|
+
def initialize(status:, fields: [])
|
|
12
|
+
status_int = status.to_i
|
|
13
|
+
unless Trane::Types::HTTP_STATUS_RANGE.cover?(status_int)
|
|
14
|
+
raise ArgumentError,
|
|
15
|
+
"ResponseDefinition status #{status.inspect} is not a valid HTTP status code " \
|
|
16
|
+
"(must be in #{Trane::Types::HTTP_STATUS_RANGE})"
|
|
17
|
+
end
|
|
18
|
+
super(status: status_int, fields: fields.freeze)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
OperationDefinition = Data.define(:name, :summary, :request, :responses, :error_keys) do
|
|
23
|
+
def initialize(name:, summary: nil, request: nil, responses: {}, error_keys: [])
|
|
24
|
+
if name.nil? || name.to_s.empty?
|
|
25
|
+
raise ArgumentError, "OperationDefinition name cannot be nil or empty"
|
|
26
|
+
end
|
|
27
|
+
super(
|
|
28
|
+
name: name.to_sym,
|
|
29
|
+
summary: summary,
|
|
30
|
+
request: request,
|
|
31
|
+
responses: responses.freeze,
|
|
32
|
+
error_keys: error_keys.freeze
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Builder for the `body do ... end` block inside a request.
|
|
38
|
+
# Extends FieldBuilder to accept the `required:` kwarg on body fields.
|
|
39
|
+
class BodyBuilder < FieldBuilder
|
|
40
|
+
def field(name, type: nil, extra: false, format: nil, of: nil, required: false, enum: nil, &block)
|
|
41
|
+
@fields << _build_field_node(
|
|
42
|
+
name: name, type: type, extra: extra, format: format,
|
|
43
|
+
of: of, enum: enum, child_builder_class: BodyBuilder,
|
|
44
|
+
required: required, &block
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Builder for the `request do ... end` block
|
|
50
|
+
class RequestBuilder
|
|
51
|
+
def initialize
|
|
52
|
+
@params = []
|
|
53
|
+
@body_fields = []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def path(name, type:)
|
|
57
|
+
raise ArgumentError, "path #{name.inspect} type: cannot be nil" if type.nil?
|
|
58
|
+
|
|
59
|
+
@params << ParamDefinition.new(name: name, type: type, required: true, location: :path)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def query(name, type:, required: false, enum: nil)
|
|
63
|
+
raise ArgumentError, "query #{name.inspect} type: cannot be nil" if type.nil?
|
|
64
|
+
|
|
65
|
+
Trane::Types.validate_enum!(name: name, type: type, enum: enum) if enum
|
|
66
|
+
@params << ParamDefinition.new(name: name, type: type, required: required, location: :query, enum: enum)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def body(&block)
|
|
70
|
+
builder = BodyBuilder.new
|
|
71
|
+
builder.instance_eval(&block)
|
|
72
|
+
@body_fields = builder.fields
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build
|
|
76
|
+
RequestDefinition.new(params: @params, body_fields: @body_fields)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Builder for the `response STATUS do ... end` block
|
|
81
|
+
class ResponseBuilder < FieldBuilder
|
|
82
|
+
def initialize(status)
|
|
83
|
+
super()
|
|
84
|
+
@status = status.to_i
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build
|
|
88
|
+
ResponseDefinition.new(status: @status, fields: @fields)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Builder for the `errors do ... end` block inside an operation
|
|
93
|
+
class ErrorKeysBuilder
|
|
94
|
+
attr_reader :keys
|
|
95
|
+
|
|
96
|
+
def initialize
|
|
97
|
+
@keys = []
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def key(error_key)
|
|
101
|
+
@keys << error_key.to_sym
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Builder for `Trane.operation :name do ... end`
|
|
106
|
+
class OperationBuilder
|
|
107
|
+
def initialize(name)
|
|
108
|
+
@name = name.to_sym
|
|
109
|
+
@summary = nil
|
|
110
|
+
@request_builder = nil
|
|
111
|
+
@responses = {}
|
|
112
|
+
@error_keys = []
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def summary(text)
|
|
116
|
+
@summary = text
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def request(&block)
|
|
120
|
+
@request_builder = RequestBuilder.new
|
|
121
|
+
@request_builder.instance_eval(&block)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def response(status, &block)
|
|
125
|
+
builder = ResponseBuilder.new(status)
|
|
126
|
+
builder.instance_eval(&block)
|
|
127
|
+
@responses[status.to_i] = builder.build
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def errors(&block)
|
|
131
|
+
builder = ErrorKeysBuilder.new
|
|
132
|
+
builder.instance_eval(&block)
|
|
133
|
+
@error_keys = builder.keys
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def build
|
|
137
|
+
OperationDefinition.new(
|
|
138
|
+
name: @name,
|
|
139
|
+
summary: @summary,
|
|
140
|
+
request: @request_builder&.build,
|
|
141
|
+
responses: @responses,
|
|
142
|
+
error_keys: @error_keys
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trane
|
|
4
|
+
ParamDefinition = Data.define(:name, :type, :required, :location, :enum) do
|
|
5
|
+
# Build a frozen param definition for an operation request schema.
|
|
6
|
+
#
|
|
7
|
+
# @param name [Symbol, String] coerced to Symbol
|
|
8
|
+
# @param type [Symbol, String] coerced to Symbol
|
|
9
|
+
# @param location [Symbol, String] one of +:path+, +:query+; coerced to Symbol
|
|
10
|
+
# @param required [Boolean] whether the param must be present in the request.
|
|
11
|
+
# Defaults to +false+. DSL callers override per location: +path+ always
|
|
12
|
+
# passes +true+; +query+ honours the caller's +required:+ keyword (also
|
|
13
|
+
# defaulting to +false+).
|
|
14
|
+
# @param enum [Array, nil] frozen on assignment
|
|
15
|
+
def initialize(name:, type:, location:, required: false, enum: nil)
|
|
16
|
+
super(
|
|
17
|
+
name: name.to_sym,
|
|
18
|
+
type: type.to_sym,
|
|
19
|
+
required: required,
|
|
20
|
+
location: location.to_sym,
|
|
21
|
+
enum: enum&.freeze
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trane
|
|
4
|
+
# Thread-safe registry of operations, representations, and errors.
|
|
5
|
+
#
|
|
6
|
+
# The registry maintains a single frozen snapshot containing all three
|
|
7
|
+
# categories. Reads (`Registry.operations`, etc.) are lock-free: one
|
|
8
|
+
# ivar read returning a frozen Hash. Writes happen atomically via
|
|
9
|
+
# `Registry.replace!`, which constructs a new snapshot in a builder
|
|
10
|
+
# and swaps it in a single assignment (atomic under MRI GVL).
|
|
11
|
+
#
|
|
12
|
+
# For ad-hoc registrations outside `replace!` (specs, REPL), the
|
|
13
|
+
# `register_*` methods do copy-on-write to a new snapshot — O(n) per
|
|
14
|
+
# write but bounded by spec sizes (typically < 100 entries).
|
|
15
|
+
#
|
|
16
|
+
# Class methods on this module delegate to the process-level
|
|
17
|
+
# `Registry::Instance` via `Trane.registry`. Existing call sites
|
|
18
|
+
# (`Trane::Registry.reset!` etc.) continue to work unchanged.
|
|
19
|
+
module Registry
|
|
20
|
+
EMPTY_ERRORS_BY_NAME = {}.freeze
|
|
21
|
+
|
|
22
|
+
EMPTY_SNAPSHOT = {
|
|
23
|
+
operations: {}.freeze,
|
|
24
|
+
representations: {}.freeze,
|
|
25
|
+
errors: {}.freeze,
|
|
26
|
+
errors_by_name: EMPTY_ERRORS_BY_NAME
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Builder collected inside a `Registry::Instance#replace!` block.
|
|
30
|
+
class SnapshotBuilder
|
|
31
|
+
def initialize
|
|
32
|
+
@operations = {}
|
|
33
|
+
@representations = {}
|
|
34
|
+
@errors = {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def register_operation(definition)
|
|
38
|
+
@operations[definition.name] = definition
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def register_representation(definition)
|
|
42
|
+
@representations[definition.name] = definition
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def register_error(definition)
|
|
46
|
+
@errors[definition.key] = definition
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def freeze_and_build
|
|
50
|
+
errors_by_name = self.class.build_errors_by_name(@errors)
|
|
51
|
+
{
|
|
52
|
+
operations: @operations.freeze,
|
|
53
|
+
representations: @representations.freeze,
|
|
54
|
+
errors: @errors.freeze,
|
|
55
|
+
errors_by_name: errors_by_name
|
|
56
|
+
}.freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Build a frozen FQDN+short-name index over the given errors Hash.
|
|
60
|
+
# Raises Trane::Error on short-name collision between two distinct FQDNs.
|
|
61
|
+
# A pure function of its input — class-level so Instance#register_error
|
|
62
|
+
# can rebuild the index without allocating a builder.
|
|
63
|
+
def self.build_errors_by_name(errors)
|
|
64
|
+
return EMPTY_ERRORS_BY_NAME if errors.empty?
|
|
65
|
+
|
|
66
|
+
index = {}
|
|
67
|
+
buckets = Hash.new { |h, k| h[k] = [] }
|
|
68
|
+
|
|
69
|
+
errors.each do |fqdn, defn|
|
|
70
|
+
index[fqdn] = defn
|
|
71
|
+
short = fqdn.rpartition("::").last
|
|
72
|
+
buckets[short] << defn unless short == fqdn
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
buckets.each do |short, defs|
|
|
76
|
+
if defs.size >= 2
|
|
77
|
+
fqdns = defs.map(&:key).sort
|
|
78
|
+
message = "Trane boot: error short-name collision on \"#{short}\".\n" \
|
|
79
|
+
"Conflicting registrations: #{fqdns.join(', ')}.\n" \
|
|
80
|
+
"Fix by renaming one, or by referencing them by FQDN in operation `errors` blocks " \
|
|
81
|
+
"(e.g. key :\"#{fqdns.first}\")."
|
|
82
|
+
raise Trane::Error, message
|
|
83
|
+
end
|
|
84
|
+
index[short] = defs.first unless index.key?(short)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
index.freeze
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Trane registry. Owns its own snapshot ivar and replacement mutex;
|
|
92
|
+
# instances are independent of one another.
|
|
93
|
+
class Instance
|
|
94
|
+
# Single process-wide thread-local key holding a Hash of
|
|
95
|
+
# { instance object_id => active SnapshotBuilder } for the builders
|
|
96
|
+
# opened on the current thread. Keyed per instance so a builder is
|
|
97
|
+
# only visible to `register_*` calls on the same instance AND the
|
|
98
|
+
# same thread; entries are deleted in `replace!`'s ensure, so the
|
|
99
|
+
# table is empty between calls. One constant Symbol replaces the
|
|
100
|
+
# previous per-instance dynamic symbols, which pinned a symbol and a
|
|
101
|
+
# thread-local entry per discarded instance for the thread's lifetime
|
|
102
|
+
# (`thread_variable_set(key, nil)` does not delete the key).
|
|
103
|
+
ACTIVE_BUILDERS_KEY = :trane_active_builders
|
|
104
|
+
|
|
105
|
+
# Defensive bound for the three object_id-keyed caches below
|
|
106
|
+
# (@compiled_serializers, @validator_field_names,
|
|
107
|
+
# @validator_declared_field_names). Under their documented invariant —
|
|
108
|
+
# keys are objects owned by the current frozen snapshot — the caches
|
|
109
|
+
# are cleared on every snapshot change and stay small (one entry per
|
|
110
|
+
# response/fields object in the registry). The bound only matters if a
|
|
111
|
+
# caller violates the invariant by passing transient objects built per
|
|
112
|
+
# request: past it, results are built without caching (a perf
|
|
113
|
+
# degradation) instead of growing the caches without limit (a leak).
|
|
114
|
+
MAX_CACHE_ENTRIES = 10_000
|
|
115
|
+
|
|
116
|
+
def initialize
|
|
117
|
+
@snapshot = EMPTY_SNAPSHOT
|
|
118
|
+
@replace_mutex = Mutex.new
|
|
119
|
+
@compiled_serializers = {}
|
|
120
|
+
@validator_field_names = {}
|
|
121
|
+
@validator_declared_field_names = {}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def operations
|
|
125
|
+
@snapshot[:operations]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def representations
|
|
129
|
+
@snapshot[:representations]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def errors
|
|
133
|
+
@snapshot[:errors]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Returns the frozen FQDN+short-name index built when the snapshot was last replaced.
|
|
137
|
+
# Keys are Strings; values are Trane::ErrorDefinition instances.
|
|
138
|
+
def errors_by_name
|
|
139
|
+
@snapshot[:errors_by_name]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Atomic bulk replacement. Use this for reload paths (Railtie's
|
|
143
|
+
# `to_prepare`, integration test setup). The block receives a
|
|
144
|
+
# `SnapshotBuilder`; on successful completion of the block, the
|
|
145
|
+
# built snapshot replaces the current one in a single assignment.
|
|
146
|
+
# If the block raises, the prior snapshot is preserved.
|
|
147
|
+
#
|
|
148
|
+
# Concurrent writers are serialised by a per-instance mutex;
|
|
149
|
+
# readers remain lock-free.
|
|
150
|
+
#
|
|
151
|
+
# Nested calls (on the same thread) are not supported and raise.
|
|
152
|
+
def replace!
|
|
153
|
+
raise Trane::Error, "nested Registry.replace! is not supported" if active_builder
|
|
154
|
+
|
|
155
|
+
@replace_mutex.synchronize do
|
|
156
|
+
builder = SnapshotBuilder.new
|
|
157
|
+
active_builders[object_id] = builder
|
|
158
|
+
yield builder
|
|
159
|
+
@snapshot = builder.freeze_and_build
|
|
160
|
+
clear_derived_caches
|
|
161
|
+
ensure
|
|
162
|
+
active_builders.delete(object_id)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Incremental registration paths. Used by specs and any caller
|
|
167
|
+
# outside a `replace!` block. Copy-on-write under @replace_mutex,
|
|
168
|
+
# so concurrent CoW writes are serialised with the same guarantee
|
|
169
|
+
# as `replace!`. The `active_builder` early-return skips the mutex
|
|
170
|
+
# acquisition when these methods are called from inside a
|
|
171
|
+
# `replace!` block — re-acquiring a non-reentrant Mutex from the
|
|
172
|
+
# same thread would deadlock. For bulk operations, prefer `replace!`.
|
|
173
|
+
def register_operation(definition)
|
|
174
|
+
b = active_builder
|
|
175
|
+
return b.register_operation(definition) if b
|
|
176
|
+
|
|
177
|
+
@replace_mutex.synchronize do
|
|
178
|
+
s = @snapshot
|
|
179
|
+
@snapshot = s.merge(operations: s[:operations].merge(definition.name => definition).freeze).freeze
|
|
180
|
+
clear_derived_caches
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def register_representation(definition)
|
|
185
|
+
b = active_builder
|
|
186
|
+
return b.register_representation(definition) if b
|
|
187
|
+
|
|
188
|
+
@replace_mutex.synchronize do
|
|
189
|
+
s = @snapshot
|
|
190
|
+
@snapshot = s.merge(representations: s[:representations].merge(definition.name => definition).freeze).freeze
|
|
191
|
+
clear_derived_caches
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def register_error(definition)
|
|
196
|
+
b = active_builder
|
|
197
|
+
return b.register_error(definition) if b
|
|
198
|
+
|
|
199
|
+
@replace_mutex.synchronize do
|
|
200
|
+
s = @snapshot
|
|
201
|
+
new_errors = s[:errors].merge(definition.key => definition).freeze
|
|
202
|
+
new_index = SnapshotBuilder.build_errors_by_name(new_errors)
|
|
203
|
+
@snapshot = s.merge(errors: new_errors, errors_by_name: new_index).freeze
|
|
204
|
+
clear_derived_caches
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def reset!
|
|
209
|
+
@snapshot = EMPTY_SNAPSHOT
|
|
210
|
+
clear_derived_caches
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def validate!
|
|
214
|
+
Trane::BootValidator.validate!(self)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Returns a memoized Trane::Serializer for the given ResponseDefinition
|
|
218
|
+
# and strict_mode pair. Instances are built lazily on first access and
|
|
219
|
+
# cached until the registry snapshot changes (via replace!, reset!, or
|
|
220
|
+
# any of the register_* copy-on-write paths).
|
|
221
|
+
#
|
|
222
|
+
# INVARIANT: response_def must be owned by the current snapshot (i.e.
|
|
223
|
+
# reachable from `operations`). The cache is keyed by object_id without
|
|
224
|
+
# holding the object, so it can never notice a dead key; passing
|
|
225
|
+
# transient objects built per call would grow it until
|
|
226
|
+
# MAX_CACHE_ENTRIES, after which results are built uncached.
|
|
227
|
+
#
|
|
228
|
+
# Thread-safety: concurrent first access on the same key may build two
|
|
229
|
+
# Serializers; last write wins. Subsequent reads share the cached
|
|
230
|
+
# instance. Serializer is frozen post-init and safe to share across
|
|
231
|
+
# threads.
|
|
232
|
+
#
|
|
233
|
+
# @param response_def [Trane::ResponseDefinition]
|
|
234
|
+
# @param strict_mode [Symbol] :raise, :log, or :ignore
|
|
235
|
+
# @return [Trane::Serializer]
|
|
236
|
+
def compiled_serializer_for(response_def, strict_mode)
|
|
237
|
+
key = [ response_def.object_id, strict_mode ]
|
|
238
|
+
cached = @compiled_serializers[key]
|
|
239
|
+
return cached if cached
|
|
240
|
+
|
|
241
|
+
built = Trane::Serializer.new(response_def, self, strict_mode: strict_mode)
|
|
242
|
+
@compiled_serializers[key] = built if @compiled_serializers.size < MAX_CACHE_ENTRIES
|
|
243
|
+
built
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Cached frozen Set of all field names for a given fields collection.
|
|
247
|
+
# Used by ContractValidator to detect undeclared keys without per-request
|
|
248
|
+
# allocation. A Set (not an Array) because the consumer does one
|
|
249
|
+
# membership test per serialized key: with F fields that is O(F) total
|
|
250
|
+
# instead of the O(F^2) an Array scan would cost — measurable on every
|
|
251
|
+
# production response, where the validator runs in :log mode.
|
|
252
|
+
# Same lifecycle / invalidation / INVARIANT (snapshot-owned `fields`
|
|
253
|
+
# only) and MAX_CACHE_ENTRIES bound as @compiled_serializers.
|
|
254
|
+
#
|
|
255
|
+
# @param fields [Array<Trane::FieldNode>] frozen fields array
|
|
256
|
+
# @return [Set<Symbol>] frozen Set of field names
|
|
257
|
+
def validator_field_names_for(fields)
|
|
258
|
+
cached = @validator_field_names[fields.object_id]
|
|
259
|
+
return cached if cached
|
|
260
|
+
|
|
261
|
+
built = Set.new(fields.map(&:name)).freeze
|
|
262
|
+
@validator_field_names[fields.object_id] = built if @validator_field_names.size < MAX_CACHE_ENTRIES
|
|
263
|
+
built
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Cached frozen Array of non-`extra:` field names. Used by ContractValidator
|
|
267
|
+
# to detect missing declared keys. Same lifecycle / invalidation /
|
|
268
|
+
# INVARIANT (snapshot-owned `fields` only) and MAX_CACHE_ENTRIES bound
|
|
269
|
+
# as @compiled_serializers.
|
|
270
|
+
#
|
|
271
|
+
# @param fields [Array<Trane::FieldNode>] frozen fields array
|
|
272
|
+
# @return [Array<Symbol>] frozen Array of declared (non-extra) field names
|
|
273
|
+
def validator_declared_field_names_for(fields)
|
|
274
|
+
cached = @validator_declared_field_names[fields.object_id]
|
|
275
|
+
return cached if cached
|
|
276
|
+
|
|
277
|
+
built = fields.reject(&:extra).map(&:name).freeze
|
|
278
|
+
@validator_declared_field_names[fields.object_id] = built if @validator_declared_field_names.size < MAX_CACHE_ENTRIES
|
|
279
|
+
built
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private
|
|
283
|
+
|
|
284
|
+
# Drop every snapshot-derived cache. MUST be called on every path
|
|
285
|
+
# that replaces @snapshot — a stale entry here would be served keyed
|
|
286
|
+
# by an object_id belonging to the dead snapshot.
|
|
287
|
+
def clear_derived_caches
|
|
288
|
+
@compiled_serializers = {}
|
|
289
|
+
@validator_field_names = {}
|
|
290
|
+
@validator_declared_field_names = {}
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# The current thread's { instance object_id => builder } table,
|
|
294
|
+
# created lazily (one Hash per thread that has ever run replace!).
|
|
295
|
+
def active_builders
|
|
296
|
+
Thread.current.thread_variable_get(ACTIVE_BUILDERS_KEY) ||
|
|
297
|
+
{}.tap { |table| Thread.current.thread_variable_set(ACTIVE_BUILDERS_KEY, table) }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def active_builder
|
|
301
|
+
table = Thread.current.thread_variable_get(ACTIVE_BUILDERS_KEY)
|
|
302
|
+
table && table[object_id]
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
class << self
|
|
307
|
+
def operations; Trane.registry.operations; end
|
|
308
|
+
def representations; Trane.registry.representations; end
|
|
309
|
+
def errors; Trane.registry.errors; end
|
|
310
|
+
|
|
311
|
+
def errors_by_name; Trane.registry.errors_by_name; end
|
|
312
|
+
|
|
313
|
+
def replace!(&block); Trane.registry.replace!(&block); end
|
|
314
|
+
def register_operation(d); Trane.registry.register_operation(d); end
|
|
315
|
+
def register_representation(d); Trane.registry.register_representation(d); end
|
|
316
|
+
def register_error(d); Trane.registry.register_error(d); end
|
|
317
|
+
def reset!; Trane.registry.reset!; end
|
|
318
|
+
def validate!; Trane.registry.validate!; end
|
|
319
|
+
|
|
320
|
+
def validator_field_names_for(fields); Trane.registry.validator_field_names_for(fields); end
|
|
321
|
+
def validator_declared_field_names_for(fields); Trane.registry.validator_declared_field_names_for(fields); end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trane
|
|
4
|
+
RepresentationDefinition = Data.define(:name, :fields) do
|
|
5
|
+
def initialize(name:, fields: [])
|
|
6
|
+
if name.nil? || name.to_s.empty?
|
|
7
|
+
raise ArgumentError, "RepresentationDefinition name cannot be nil or empty"
|
|
8
|
+
end
|
|
9
|
+
super(name: name.to_sym, fields: fields.freeze)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Builder for `Trane.representation :name do ... end`
|
|
14
|
+
class RepresentationBuilder < FieldBuilder
|
|
15
|
+
def initialize(name)
|
|
16
|
+
super()
|
|
17
|
+
@name = name.to_sym
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build
|
|
21
|
+
RepresentationDefinition.new(name: @name, fields: @fields)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|