overseer-testing-control-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a8a82dda4e7a0fd6d97c395b6639d49da084140ef72f072ed1c713189bc02207
4
+ data.tar.gz: 5e3e0951f138eee9ab614e1656abae3e6314594ffd01790264dd581c1662e948
5
+ SHA512:
6
+ metadata.gz: 3a39c6a9bb3e5044c286ce1266e671bbf84d6c303bc812162a38fe1dcad45182e015e5b56ee033a9a9555ab854e3ec3251b4d8ed677a4b96612d53b487da7425
7
+ data.tar.gz: 482c65e907157763aafba1763a741b5d3d7f374af3f06186b890376c8f2f1c121ebec9a10cdaaa93d14f965d75eab4e111d843fde0c1da2d80aadfc7d20d4003
data/MIT-LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright 2026 Olistik
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # Overseer testing-control Rails helper
2
+
3
+ An optional Rails adapter for [Overseer testing protocol](https://source.olisti.co/overseer/testing-protocol)
4
+ v3. The protocol owns schemas, wire conventions and HTTP conformance. This gem
5
+ owns Rails routing, safe JSON responses, correlation and bounded memory stores.
6
+ It works with frontend tests, curl and CI without Overseer Studio.
7
+
8
+ For a checkout before a published release, the repository includes an exact
9
+ protocol package snapshot under `vendor/`. Applications should pin reviewed
10
+ package snapshots or released versions in their test-only bundle:
11
+
12
+ ```ruby
13
+ group :test do
14
+ gem 'overseer-testing-protocol', path: 'vendor/gems/overseer-testing-protocol', require: false
15
+ gem 'overseer-testing-control-rails', path: 'vendor/gems/overseer-testing-control-rails',
16
+ require: 'overseer/testing_control/rails'
17
+ end
18
+ ```
19
+
20
+ Mount `Overseer::TestingControl::Rails::Engine` at `/v1/testing` only in test.
21
+ Configure it in a test-only initializer:
22
+
23
+ ```ruby
24
+ Overseer::TestingControl::Rails.configure do |config|
25
+ config.identity(application: 'my-api', source: -> { ENV.fetch('TEST_SOURCE') },
26
+ environment: -> { ENV.fetch('TEST_RUNTIME_ID') })
27
+ config.reset(strategy: 'runtime-restart')
28
+ config.register_state(id: 'open-item', version: '1', title: 'Open synthetic item',
29
+ idempotency: 'required', input_schema: OPEN_ITEM_INPUT,
30
+ output_schema: OPEN_ITEM_OUTPUT, handler: Testing::OpenItem.method(:call))
31
+ end
32
+ ```
33
+
34
+ The adapter fails closed unless Rails is in `test`,
35
+ `OVERSEER_TESTING_CONTROL_ENABLED=true`, and configured identity providers are
36
+ valid. The host must additionally verify its isolation, database identity,
37
+ synthetic data and credential policy. The helper cannot establish network
38
+ containment or safely identify a database on its own.
39
+
40
+ Register probes with input/output schemas and a read-only `handler` callable
41
+ receiving `(input, context)`. Register sinks with effect kinds, outcomes,
42
+ `query_input_schema`, `record_schema` and a `query_matcher` receiving
43
+ `(payload, input)`. A matcher must return true for each record to retain.
44
+ Non-empty query input without a matcher is rejected rather than ignored.
45
+ Matchers and probe handlers must have no side effects.
46
+
47
+ At a product-owned fake boundary, call
48
+ `Overseer::TestingControl::Rails.capture_effect(capability:, version:,
49
+ effect_kind:, summary:, payload:, outcome:)`. Supply only sanitized fields
50
+ allowed by the registered schema. The helper records the current public
51
+ request's run and correlation; step IDs are optional. Records are partitioned
52
+ by run and queries do not consume them. Storage is bounded and fails on
53
+ exhaustion. It does not silently discard records.
54
+
55
+ Internal spies are product-owned bounded event stores exposed through probes.
56
+ There is no arbitrary method interception, model inspection or `/spies` route.
57
+ Product semantics and safe fields remain in the application.
58
+
59
+ This implementation supports **one process**, with synchronous request effects.
60
+ Its middleware serializes active public and control requests so reset cannot
61
+ race them. Independent test suites need independent runtimes. Worker processes,
62
+ streaming effects and automatic Active Job/Sidekiq correlation propagation are
63
+ not implemented. Do not advertise worker completion using a pre-enqueue record.
64
+
65
+ For an in-process reset, `config.reset(strategy: 'in-process', handler: ...)`
66
+ requires a handler returning exactly `true` after all product state is reset.
67
+ False, nil, a failure object or an exception cannot report success; helper stores
68
+ are retained on failure. A successful reset clears observations and idempotency.
69
+ The product must own file, job, cache, database and external fake cleanup.
70
+
71
+ ```sh
72
+ bundle install
73
+ bundle exec rake test
74
+ gem build overseer-testing-control-rails.gemspec
75
+ ```
76
+
77
+ Run the protocol package's HTTP conformance command against a disposable host
78
+ application to check the complete adapter. The package has no Studio,
79
+ container orchestration or product persistence dependency.
80
+
81
+ Made with ❤️ by [olistik](https://olisti.co)
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Overseer
4
+ module TestingControl
5
+ module Rails
6
+ class ProtocolFailure < StandardError
7
+ attr_reader :status, :code, :title, :detail, :pointer, :retryable
8
+
9
+ def initialize(status:, code:, title:, detail:, pointer: nil, retryable: false)
10
+ @status = status
11
+ @code = code
12
+ @title = title
13
+ @detail = detail
14
+ @pointer = pointer
15
+ @retryable = retryable
16
+ super(detail)
17
+ end
18
+ end
19
+
20
+ class ApplicationController < ::ActionController::API
21
+ before_action :require_activation!
22
+ before_action :prepare_identity!
23
+
24
+ rescue_from StandardError, with: :render_internal_error
25
+ rescue_from ConfigurationError, with: :render_configuration_error
26
+ rescue_from StoreLimitExceeded, with: :render_store_limit
27
+ rescue_from IdempotencyConflict, with: :render_idempotency_conflict
28
+ rescue_from JSON::ParserError, with: :render_invalid_json
29
+ rescue_from ProtocolFailure, with: :render_protocol_failure
30
+
31
+ private
32
+
33
+ def configuration
34
+ Overseer::TestingControl::Rails.configuration.finalize!
35
+ end
36
+
37
+ def require_activation!
38
+ head :not_found unless Overseer::TestingControl::Rails.active?
39
+ end
40
+
41
+ def prepare_identity!
42
+ version = request.headers[ProtocolV3::HEADERS.fetch(:version)]
43
+ if version.nil?
44
+ raise ProtocolFailure.new(
45
+ status: :bad_request,
46
+ code: 'invalid-request',
47
+ title: 'Protocol version required',
48
+ detail: 'The testing-control protocol version header is required.'
49
+ )
50
+ end
51
+ unless version == ProtocolV3::VERSION
52
+ raise ProtocolFailure.new(
53
+ status: :conflict,
54
+ code: 'protocol-version-unsupported',
55
+ title: 'Unsupported protocol version',
56
+ detail: 'The request must use testing-control protocol version 3.'
57
+ )
58
+ end
59
+
60
+ run_id = validate_header!(:run_id, required: true)
61
+ correlation_id = validate_header!(:correlation_id, required: true)
62
+ step_id = validate_header!(:step_id, required: false)
63
+ validate_header!(:idempotency_key, required: false)
64
+ @identity = { run_id:, correlation_id:, step_id: }
65
+ response.set_header(ProtocolV3::HEADERS.fetch(:version), ProtocolV3::VERSION)
66
+ response.set_header(ProtocolV3::HEADERS.fetch(:correlation_id), correlation_id)
67
+ end
68
+
69
+ def validate_header!(name, required:)
70
+ value = request.headers[ProtocolV3::HEADERS.fetch(name)]
71
+ return if !required && value.nil?
72
+
73
+ ProtocolV3.identifier!(value, label: ProtocolV3::HEADERS.fetch(name))
74
+ rescue ProtocolV3::ValidationError
75
+ raise ProtocolFailure.new(
76
+ status: :bad_request,
77
+ code: 'invalid-request',
78
+ title: 'Invalid request identity',
79
+ detail: 'A required testing-control identity header is missing or invalid.'
80
+ )
81
+ end
82
+
83
+ def request_document(kind)
84
+ unless request.media_type == 'application/json'
85
+ raise ProtocolFailure.new(
86
+ status: :bad_request,
87
+ code: 'invalid-request',
88
+ title: 'JSON request required',
89
+ detail: 'The testing-control request must use application/json.'
90
+ )
91
+ end
92
+ raw = bounded_body
93
+ if raw.bytesize > configuration.limits.fetch('maxRequestBytes')
94
+ raise ProtocolFailure.new(
95
+ status: :content_too_large,
96
+ code: 'payload-too-large',
97
+ title: 'Payload too large',
98
+ detail: 'The testing-control request exceeds the advertised byte limit.'
99
+ )
100
+ end
101
+
102
+ document = JSON.parse(raw)
103
+ validate_request_envelope!(kind, document)
104
+ validate_request_meta!(document.fetch('meta'))
105
+ document
106
+ end
107
+
108
+ def bounded_body
109
+ # Rack may already have buffered the body; otherwise read only limit + 1.
110
+ request.get_header('RAW_POST_DATA') ||
111
+ request.body&.read(configuration.limits.fetch('maxRequestBytes') + 1).to_s
112
+ end
113
+
114
+ def require_empty_body!
115
+ return if bounded_body.empty?
116
+
117
+ raise ProtocolFailure.new(
118
+ status: :bad_request,
119
+ code: 'invalid-request',
120
+ title: 'Request body is not allowed',
121
+ detail: 'This testing-control operation does not accept a request body.'
122
+ )
123
+ end
124
+
125
+ def validate_request_meta!(meta)
126
+ expected = ProtocolV3.meta(**@identity)
127
+ return if meta == expected
128
+
129
+ raise ProtocolFailure.new(
130
+ status: :bad_request,
131
+ code: 'invalid-request',
132
+ title: 'Request identity mismatch',
133
+ detail: 'The protocol envelope metadata must match the request headers.',
134
+ pointer: '/meta'
135
+ )
136
+ end
137
+
138
+ def handler_context
139
+ Configuration::HandlerContext.new(
140
+ **@identity,
141
+ idempotency_key: request.headers[ProtocolV3::HEADERS.fetch(:idempotency_key)]
142
+ )
143
+ end
144
+
145
+ def validate_capability_input!(schema, value, label:)
146
+ ProtocolV3.validate_value!(schema, value, label:)
147
+ rescue ProtocolV3::ValidationError => e
148
+ raise ProtocolFailure.new(
149
+ status: :unprocessable_entity,
150
+ code: 'schema-validation-failed',
151
+ title: 'Schema validation failed',
152
+ detail: e.message.byteslice(0, 1000)
153
+ )
154
+ end
155
+
156
+ def validate_capability_output!(schema, value, label:)
157
+ ProtocolV3.validate_value!(schema, value, label:)
158
+ rescue ProtocolV3::ValidationError
159
+ raise ProtocolFailure.new(
160
+ status: :internal_server_error,
161
+ code: 'internal-error',
162
+ title: 'Testing-control handler failed',
163
+ detail: 'A product handler returned an invalid bounded result.'
164
+ )
165
+ end
166
+
167
+ def render_data(kind, data, status: :ok)
168
+ document = ProtocolV3.envelope(data:, **@identity)
169
+ ProtocolV3.validate_document!(kind, document)
170
+ render_json(document, status:)
171
+ end
172
+
173
+ def render_json(document, status:)
174
+ encoded = JSON.generate(document)
175
+ if encoded.bytesize > configuration.limits.fetch('maxResponseBytes')
176
+ raise ProtocolFailure.new(
177
+ status: :unprocessable_entity,
178
+ code: 'limit-exceeded',
179
+ title: 'Response limit exceeded',
180
+ detail: 'The bounded testing-control response could not be produced.'
181
+ )
182
+ end
183
+ render body: encoded, content_type: 'application/json', status:
184
+ end
185
+
186
+ def capability_reference(data)
187
+ reference = data.fetch('capability')
188
+ [reference.fetch('id'), reference.fetch('version')]
189
+ end
190
+
191
+ def render_protocol_failure(error)
192
+ identity = safe_identity
193
+ document = ProtocolV3.error_envelope(
194
+ errors: [
195
+ {
196
+ 'code' => error.code,
197
+ 'title' => error.title,
198
+ 'detail' => error.detail,
199
+ **(error.pointer ? { 'pointer' => error.pointer } : {}),
200
+ 'retryable' => error.retryable
201
+ }
202
+ ],
203
+ **identity
204
+ )
205
+ response.set_header(ProtocolV3::HEADERS.fetch(:version), ProtocolV3::VERSION)
206
+ response.set_header(ProtocolV3::HEADERS.fetch(:correlation_id), identity.fetch(:correlation_id))
207
+ ProtocolV3.validate_document!(:error, document)
208
+ render body: JSON.generate(document), content_type: 'application/json', status: error.status
209
+ end
210
+
211
+ def safe_identity
212
+ return @identity if @identity
213
+
214
+ run_id = request.headers[ProtocolV3::HEADERS.fetch(:run_id)]
215
+ correlation_id = request.headers[ProtocolV3::HEADERS.fetch(:correlation_id)]
216
+ step_id = request.headers[ProtocolV3::HEADERS.fetch(:step_id)]
217
+ {
218
+ run_id: valid_identifier?(run_id) ? run_id : 'invalid-run',
219
+ correlation_id: valid_identifier?(correlation_id) ? correlation_id : 'invalid-correlation',
220
+ step_id: valid_identifier?(step_id) ? step_id : nil
221
+ }
222
+ end
223
+
224
+ def valid_identifier?(value)
225
+ value.is_a?(String) && value.match?(ProtocolV3::IDENTIFIER_PATTERN)
226
+ end
227
+
228
+ def render_invalid_json(_error)
229
+ render_protocol_failure(
230
+ ProtocolFailure.new(
231
+ status: :bad_request,
232
+ code: 'invalid-request',
233
+ title: 'Invalid JSON request',
234
+ detail: 'The testing-control request body is not valid JSON.'
235
+ )
236
+ )
237
+ end
238
+
239
+ def render_idempotency_conflict(_error)
240
+ render_protocol_failure(
241
+ ProtocolFailure.new(
242
+ status: :bad_request,
243
+ code: 'invalid-request',
244
+ title: 'Idempotency conflict',
245
+ detail: 'The idempotency key was reused with different input.'
246
+ )
247
+ )
248
+ end
249
+
250
+ def render_store_limit(_error)
251
+ render_protocol_failure(
252
+ ProtocolFailure.new(
253
+ status: :unprocessable_entity,
254
+ code: 'limit-exceeded',
255
+ title: 'Bounded storage limit reached',
256
+ detail: 'The adapter storage limit was reached; reset or restart the isolated runtime.',
257
+ retryable: true
258
+ )
259
+ )
260
+ end
261
+
262
+ def render_configuration_error(_error)
263
+ render_protocol_failure(
264
+ ProtocolFailure.new(
265
+ status: :service_unavailable,
266
+ code: 'runtime-unavailable',
267
+ title: 'Testing runtime unavailable',
268
+ detail: 'The testing-control adapter configuration is unavailable.'
269
+ )
270
+ )
271
+ end
272
+
273
+ def render_internal_error(error)
274
+ ::Rails.logger.error("Overseer Rails testing-control adapter request failed (#{error.class.name})")
275
+ render_protocol_failure(
276
+ ProtocolFailure.new(
277
+ status: :internal_server_error,
278
+ code: 'internal-error',
279
+ title: 'Testing-control request failed',
280
+ detail: 'The isolated testing-control request could not be completed.'
281
+ )
282
+ )
283
+ end
284
+
285
+ def validate_request_envelope!(kind, document)
286
+ ProtocolV3.validate_document!(kind, document)
287
+ rescue ProtocolV3::ValidationError
288
+ raise ProtocolFailure.new(
289
+ status: :bad_request,
290
+ code: 'invalid-request',
291
+ title: 'Invalid request envelope',
292
+ detail: 'The testing-control request does not match the closed protocol v3 envelope.'
293
+ )
294
+ end
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Overseer
4
+ module TestingControl
5
+ module Rails
6
+ class CapabilitiesController < ApplicationController
7
+ def show
8
+ require_empty_body!
9
+ render_data(:capabilities, configuration.capabilities_data)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Overseer
4
+ module TestingControl
5
+ module Rails
6
+ class ProbesController < ApplicationController
7
+ def create
8
+ document = request_document(:probe_request)
9
+ data = document.fetch('data')
10
+ id, version = capability_reference(data)
11
+ probe = find_probe(id, version)
12
+ input = data.fetch('input')
13
+ validate_capability_input!(probe.input_schema, input, label: "probe #{id}@#{version} input")
14
+ projection = ProtocolV3.stringify(probe.handler.call(input, handler_context))
15
+ validate_capability_output!(probe.output_schema, projection, label: "probe #{id}@#{version} output")
16
+ render_data(
17
+ :probe_response,
18
+ {
19
+ 'capability' => { 'id' => id, 'version' => version },
20
+ 'observedAt' => ProtocolV3.timestamp,
21
+ 'projection' => projection
22
+ }
23
+ )
24
+ end
25
+
26
+ private
27
+
28
+ def find_probe(id, version)
29
+ configuration.probe!(id, version)
30
+ rescue ProtocolV3::ValidationError
31
+ raise ProtocolFailure.new(
32
+ status: :not_found,
33
+ code: 'capability-not-found',
34
+ title: 'Probe capability not found',
35
+ detail: 'The requested probe capability is not registered.'
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Overseer
4
+ module TestingControl
5
+ module Rails
6
+ class ResetsController < ApplicationController
7
+ def update
8
+ require_empty_body!
9
+ if configuration.reset_strategy == 'runtime-restart'
10
+ raise ProtocolFailure.new(
11
+ status: :conflict,
12
+ code: 'reset-requires-restart',
13
+ title: 'Runtime restart required',
14
+ detail: 'The test runner must recreate this isolated runtime to reset it.',
15
+ retryable: true
16
+ )
17
+ end
18
+
19
+ unless configuration.reset_handler.call == true
20
+ raise ConfigurationError, 'The product reset handler did not confirm a successful reset'
21
+ end
22
+
23
+ configuration.clear_stores!
24
+ render_data(
25
+ :reset,
26
+ {
27
+ 'resetAt' => ProtocolV3.timestamp,
28
+ 'strategy' => 'in-process',
29
+ 'sinksCleared' => true
30
+ }
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Overseer
4
+ module TestingControl
5
+ module Rails
6
+ class SinksController < ApplicationController
7
+ def query
8
+ document = request_document(:sink_query_request)
9
+ data = document.fetch('data')
10
+ id, version = capability_reference(data)
11
+ sink = find_sink(id, version)
12
+ validate_capability_input!(
13
+ sink.query_input_schema,
14
+ data.fetch('input'),
15
+ label: "sink #{id}@#{version} query input"
16
+ )
17
+ limit = data.fetch('limit', configuration.limits.fetch('maxSinkRecordsPerQuery'))
18
+ if limit > configuration.limits.fetch('maxSinkRecordsPerQuery')
19
+ raise ProtocolFailure.new(
20
+ status: :unprocessable_entity,
21
+ code: 'limit-exceeded',
22
+ title: 'Sink query limit exceeded',
23
+ detail: 'The sink query limit exceeds the advertised maximum.'
24
+ )
25
+ end
26
+ records, cursor = query_store(
27
+ capability: id,
28
+ version:,
29
+ run_id: @identity.fetch(:run_id),
30
+ input: data.fetch('input'),
31
+ filter: data.fetch('filter'),
32
+ cursor: data['cursor'],
33
+ limit:
34
+ )
35
+ render_data(
36
+ :sink_query_response,
37
+ {
38
+ 'capability' => { 'id' => id, 'version' => version },
39
+ 'records' => records,
40
+ **(cursor ? { 'nextCursor' => cursor } : {})
41
+ }
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ def find_sink(id, version)
48
+ configuration.sink!(id, version)
49
+ rescue ProtocolV3::ValidationError
50
+ raise ProtocolFailure.new(
51
+ status: :not_found,
52
+ code: 'capability-not-found',
53
+ title: 'Sink capability not found',
54
+ detail: 'The requested sink capability is not registered.'
55
+ )
56
+ end
57
+
58
+ def query_store(**)
59
+ configuration.sink_store.query(**)
60
+ rescue ProtocolV3::ValidationError
61
+ raise ProtocolFailure.new(
62
+ status: :bad_request,
63
+ code: 'invalid-request',
64
+ title: 'Invalid sink query',
65
+ detail: 'The fake-sink cursor or filter is invalid.'
66
+ )
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Overseer
6
+ module TestingControl
7
+ module Rails
8
+ class StatesController < ApplicationController
9
+ def create
10
+ document = request_document(:state_request)
11
+ data = document.fetch('data')
12
+ id, version = capability_reference(data)
13
+ state = find_state(id, version)
14
+ input = data.fetch('input')
15
+ validate_capability_input!(state.input_schema, input, label: "state #{id}@#{version} input")
16
+ idempotency_key = validated_idempotency_key(state)
17
+ result = execute_state(state, input, idempotency_key)
18
+ render_data(
19
+ :state_response,
20
+ {
21
+ 'capability' => { 'id' => id, 'version' => version },
22
+ 'identifiers' => result.fetch('identifiers'),
23
+ 'output' => result.fetch('output')
24
+ }
25
+ )
26
+ end
27
+
28
+ private
29
+
30
+ def find_state(id, version)
31
+ configuration.state!(id, version)
32
+ rescue ProtocolV3::ValidationError
33
+ raise ProtocolFailure.new(
34
+ status: :not_found,
35
+ code: 'capability-not-found',
36
+ title: 'State capability not found',
37
+ detail: 'The requested state capability is not registered.'
38
+ )
39
+ end
40
+
41
+ def validated_idempotency_key(state)
42
+ key = request.headers[ProtocolV3::HEADERS.fetch(:idempotency_key)]
43
+ if state.idempotency == 'required' && key.nil?
44
+ raise ProtocolFailure.new(
45
+ status: :bad_request,
46
+ code: 'invalid-request',
47
+ title: 'Idempotency key required',
48
+ detail: 'The requested state requires an Overseer idempotency key.'
49
+ )
50
+ end
51
+ return if key.nil?
52
+
53
+ ProtocolV3.identifier!(key, label: 'idempotency key')
54
+ rescue ProtocolV3::ValidationError
55
+ raise ProtocolFailure.new(
56
+ status: :bad_request,
57
+ code: 'invalid-request',
58
+ title: 'Invalid idempotency key',
59
+ detail: 'The Overseer idempotency key must be a valid opaque identifier.'
60
+ )
61
+ end
62
+
63
+ def execute_state(state, input, idempotency_key)
64
+ operation = lambda do
65
+ raw = state.handler.call(input, handler_context)
66
+ result = ProtocolV3.stringify(raw)
67
+ validate_state_result!(state, result)
68
+ result
69
+ end
70
+ return operation.call if state.idempotency == 'none' || idempotency_key.nil?
71
+
72
+ configuration.idempotency_store.fetch_or_store(
73
+ key: [@identity.fetch(:run_id), state.id, state.version, idempotency_key].freeze,
74
+ digest: Digest::SHA256.hexdigest(::Overseer::TestingControl::Discovery.canonical_json(input)),
75
+ &operation
76
+ )
77
+ end
78
+
79
+ def validate_state_result!(state, result)
80
+ unless result.is_a?(Hash) && result['identifiers'].is_a?(Hash) && !result['identifiers'].empty? &&
81
+ result['output'].is_a?(Hash)
82
+ raise ProtocolV3::ValidationError, 'state handler must return identifiers and output objects'
83
+ end
84
+
85
+ validate_capability_output!(state.output_schema, result.fetch('output'), label: 'state output')
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ Overseer::TestingControl::Rails::Engine.routes.draw do
4
+ get '/capabilities', to: 'capabilities#show'
5
+ put '/reset', to: 'resets#update'
6
+ post '/states', to: 'states#create'
7
+ post '/probes', to: 'probes#create'
8
+ post '/sinks/query', to: 'sinks#query'
9
+ end