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,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