igniter-embed 0.5.2
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/README.md +360 -0
- data/lib/igniter/embed/config.rb +155 -0
- data/lib/igniter/embed/container.rb +165 -0
- data/lib/igniter/embed/contract_handle.rb +23 -0
- data/lib/igniter/embed/contract_naming.rb +38 -0
- data/lib/igniter/embed/contractable/acceptance.rb +52 -0
- data/lib/igniter/embed/contractable/adapters.rb +33 -0
- data/lib/igniter/embed/contractable/config.rb +234 -0
- data/lib/igniter/embed/contractable/runner.rb +393 -0
- data/lib/igniter/embed/contractable/sugar_builder.rb +145 -0
- data/lib/igniter/embed/contractable.rb +29 -0
- data/lib/igniter/embed/contracts_builder.rb +54 -0
- data/lib/igniter/embed/errors.rb +14 -0
- data/lib/igniter/embed/execution_envelope.rb +50 -0
- data/lib/igniter/embed/host_builder.rb +39 -0
- data/lib/igniter/embed/rails.rb +21 -0
- data/lib/igniter/embed/registry.rb +78 -0
- data/lib/igniter/embed/sugar_expansion.rb +152 -0
- data/lib/igniter/embed.rb +38 -0
- data/lib/igniter-embed.rb +3 -0
- metadata +89 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5b4a43a4e170a429c5aa9b8380a47b0dd9f254109eadc7268f67e205fe74d6b2
|
|
4
|
+
data.tar.gz: 1b40c7e366edd2704bec098aa86c463272b614f134a8c0ab3ab6c80cb994109f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 598d577bbdeb6c7ddf50c73c90a6d24bbb8ab9414a8bab78556b0aa61754a2c3a820b627484d4de38749c965f316f106371cb1385ceac03436da2d3f2cbc47fe
|
|
7
|
+
data.tar.gz: 400425f586822adcc7847ad6fe0b3c4dc92ceeff28eb9250432a85eef90b882cf981e1c400e27b4f6fa342f96c95e52dee5ba63525ca28aa5c642a612218bef7
|
data/README.md
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
# Igniter Embed
|
|
2
|
+
|
|
3
|
+
`igniter-embed` is the host-local layer for applications that want to register,
|
|
4
|
+
cache, and execute Igniter contracts without adopting the full application
|
|
5
|
+
runtime.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
contracts = Igniter::Embed.configure(:sparkcrm) do |config|
|
|
9
|
+
config.cache = true
|
|
10
|
+
config.pack Igniter::Contracts::ProjectPack
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
contracts.register(:tax_quote) do
|
|
14
|
+
input :amount
|
|
15
|
+
compute :tax, depends_on: [:amount] do |amount:|
|
|
16
|
+
amount * 0.2
|
|
17
|
+
end
|
|
18
|
+
output :tax
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
result = contracts.call(:tax_quote, amount: 100)
|
|
22
|
+
result.success?
|
|
23
|
+
result.output(:tax)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
For human-facing app initializers, `host` is sugar over the same host-local
|
|
27
|
+
configuration:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
contracts = Igniter::Embed.host(:shop) do
|
|
31
|
+
owner Shop
|
|
32
|
+
path "app/contracts"
|
|
33
|
+
cache !Rails.env.development?
|
|
34
|
+
|
|
35
|
+
contracts do
|
|
36
|
+
add :price_quote, PriceContract
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For app-local contract classes, prefer host-level registration:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
class PriceContract < Igniter::Contract
|
|
45
|
+
define do
|
|
46
|
+
input :amount
|
|
47
|
+
compute :total, depends_on: [:amount] do |amount:|
|
|
48
|
+
amount * 1.2
|
|
49
|
+
end
|
|
50
|
+
output :total
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
contracts = Igniter::Embed.configure(:shop) do |config|
|
|
55
|
+
config.root "app/contracts"
|
|
56
|
+
config.contract PriceContract, as: :price_quote
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
contracts.call(:price_quote, amount: 100).output(:total)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Named contract classes can also be registered directly:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
contracts.register(PriceContract)
|
|
66
|
+
contracts.call(:price, amount: 100)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`config.root` is the host-local directory where contract files live. It is
|
|
70
|
+
metadata for explicit registration unless discovery is enabled.
|
|
71
|
+
|
|
72
|
+
Discovery is opt-in:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
contracts = Igniter::Embed.configure(:shop) do |config|
|
|
76
|
+
config.root "app/contracts"
|
|
77
|
+
config.discover!
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
By default discovery requires `**/*_contract.rb` under `config.root` and
|
|
82
|
+
registers newly loaded, named `Class < Igniter::Contract` definitions by
|
|
83
|
+
inferred name. Anonymous contract classes are ignored by discovery and must be
|
|
84
|
+
registered explicitly with `as:` if you want to call them through the host.
|
|
85
|
+
|
|
86
|
+
Prefer explicit `config.contract` for application boot paths where stable
|
|
87
|
+
naming matters. If explicit registration and discovery produce the same name,
|
|
88
|
+
the explicit registration wins. If two discovered classes infer the same name,
|
|
89
|
+
discovery raises `Igniter::Embed::DiscoveryError` and asks you to register them
|
|
90
|
+
explicitly.
|
|
91
|
+
|
|
92
|
+
Rails integration is optional:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
require "igniter/embed/rails"
|
|
96
|
+
|
|
97
|
+
Igniter::Embed::Rails.install(
|
|
98
|
+
contracts,
|
|
99
|
+
reloader: Rails.application.reloader,
|
|
100
|
+
cache: !Rails.env.development?
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The Rails adapter only connects host reload callbacks to `container.reload!`.
|
|
105
|
+
The base package remains Rails-free.
|
|
106
|
+
|
|
107
|
+
## Contractable Shadowing
|
|
108
|
+
|
|
109
|
+
`igniter-contracts` owns the core `Contractable` service protocol used by
|
|
110
|
+
`compute using:`. The `igniter-embed` `contractable` API below is a host
|
|
111
|
+
wrapper for migration, shadowing, discovery, and production observation.
|
|
112
|
+
|
|
113
|
+
`contractable` wraps host services without changing their public API. The
|
|
114
|
+
primary callable runs synchronously and its raw result is returned; an optional
|
|
115
|
+
candidate can run through a shadow adapter, normalize outputs, compare through
|
|
116
|
+
`DifferentialPack`, and record an observation through an app-supplied store.
|
|
117
|
+
When `async` is true, the default adapter uses a local Ruby thread so candidate
|
|
118
|
+
work does not block the primary response. It is not a durable production job
|
|
119
|
+
queue; provide an app adapter for ActiveJob, Sidekiq, or another backend when
|
|
120
|
+
durability matters.
|
|
121
|
+
|
|
122
|
+
When a primary or candidate is a core `Igniter::Contracts::Contractable`
|
|
123
|
+
service, embed invokes it through the core protocol and adopts its declared
|
|
124
|
+
`role`, `stage`, and metadata as wrapper defaults unless the wrapper explicitly
|
|
125
|
+
overrides them.
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
QuoteShadow = Igniter::Embed.contractable(:quote) do |config|
|
|
129
|
+
config.role :migration_candidate
|
|
130
|
+
config.stage :shadowed
|
|
131
|
+
config.primary LegacyQuote
|
|
132
|
+
config.candidate ContractQuote
|
|
133
|
+
config.normalize_primary QuoteNormalizer
|
|
134
|
+
config.normalize_candidate QuoteNormalizer
|
|
135
|
+
config.accept :shape, outputs: { total: Numeric, status: String }
|
|
136
|
+
config.store QuoteObservationStore
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
result = QuoteShadow.call(amount: 100)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The same shape can be declared through host sugar. This keeps registration,
|
|
143
|
+
shadow migration intent, adapters, and event hooks in one inspectable
|
|
144
|
+
initializer:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
contracts = Igniter::Embed.host(:billing) do
|
|
148
|
+
contracts do
|
|
149
|
+
add :price_quote, Billing::PriceContract do
|
|
150
|
+
migrate Billing::LegacyQuote, to: Billing::ContractQuote
|
|
151
|
+
shadow async: false, sample: 1.0
|
|
152
|
+
|
|
153
|
+
use :normalizer, Billing::QuoteNormalizer
|
|
154
|
+
use :redaction, only: %i[amount customer_id]
|
|
155
|
+
use :acceptance, policy: :shape, outputs: { total: Numeric }
|
|
156
|
+
use :store, Billing::ObservationStore
|
|
157
|
+
|
|
158
|
+
on :divergence do |event|
|
|
159
|
+
Billing.logger.warn(event)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
runner = contracts.contractable(:price_quote)
|
|
166
|
+
runner.call(amount: 100, customer_id: "cust_1", token: "secret")
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Generated contractable runners are host-local:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
contracts.contractable_names
|
|
173
|
+
contracts.fetch_contractable(:price_quote)
|
|
174
|
+
contracts.sugar_expansion.to_h
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`on :failure` is an alias family for typed failure events:
|
|
178
|
+
`:primary_error`, `:candidate_error`, `:acceptance_failure`, and
|
|
179
|
+
`:store_error`. Divergence is intentionally separate and should be subscribed
|
|
180
|
+
to with `on :divergence`.
|
|
181
|
+
|
|
182
|
+
Capability attachment sugar exists for host-owned targets. It does not install
|
|
183
|
+
implicit built-ins:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
contracts = Igniter::Embed.host(:billing) do
|
|
187
|
+
contracts do
|
|
188
|
+
add :price_quote, Billing::PriceContract do
|
|
189
|
+
migrate Billing::LegacyQuote, to: Billing::ContractQuote
|
|
190
|
+
use :normalizer, Billing::QuoteNormalizer
|
|
191
|
+
|
|
192
|
+
use :logging, contract: Billing::LogObservationContract
|
|
193
|
+
use :reporting, ->(event) { Billing.reporter.record(event) }
|
|
194
|
+
use :metrics, target: Billing::MetricsSink
|
|
195
|
+
use :validation, callable: Billing::ObservationValidator
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Each explicit target appears in `sugar_expansion` as either `kind: :contract`
|
|
202
|
+
or `kind: :callable_adapter`.
|
|
203
|
+
|
|
204
|
+
Primary-only observed services use the same surface:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
ObservedQuote = Igniter::Embed.contractable(:quote) do |config|
|
|
208
|
+
config.role :observed_service
|
|
209
|
+
config.primary LegacyQuote
|
|
210
|
+
config.normalize_primary QuoteNormalizer
|
|
211
|
+
config.store QuoteObservationStore
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
For an observed service, the normalizer should return a redacted aggregate
|
|
216
|
+
summary. The primary callable remains authoritative and its raw result is still
|
|
217
|
+
returned to the host app.
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
AvailabilityObserver = Igniter::Embed.contractable(:availability) do |config|
|
|
221
|
+
config.role :observed_service
|
|
222
|
+
config.stage :captured
|
|
223
|
+
config.primary AvailabilityService
|
|
224
|
+
config.normalize_primary AvailabilitySummaryNormalizer
|
|
225
|
+
config.redact_inputs ->(**inputs) { inputs.slice(:request_ref, :window_ref) }
|
|
226
|
+
config.store AvailabilityObservationStore
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
class AvailabilitySummaryNormalizer
|
|
230
|
+
def self.call(_result)
|
|
231
|
+
{
|
|
232
|
+
status: :ok,
|
|
233
|
+
outputs: {
|
|
234
|
+
status: "success",
|
|
235
|
+
receipt_kind: "availability_slot_map_summary",
|
|
236
|
+
redaction_policy: "availability_slot_map_summary_v1",
|
|
237
|
+
availability_bucket: "available",
|
|
238
|
+
dominant_unavailable_state: "day_off",
|
|
239
|
+
available_ratio: 0.75,
|
|
240
|
+
total_slots: 4,
|
|
241
|
+
available_slots: 3,
|
|
242
|
+
scheduled_slots: 0,
|
|
243
|
+
off_schedule_slots: 0,
|
|
244
|
+
day_off_slots: 1,
|
|
245
|
+
past_slots: 0
|
|
246
|
+
},
|
|
247
|
+
metadata: { normalizer: :availability_summary_v1 }
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The aggregate payload above is a sanitized normalizer example. It becomes part
|
|
254
|
+
of `receipt[:primary][:outputs]`; it is not the top-level Embed receipt
|
|
255
|
+
envelope. In particular, `"availability_slot_map_summary"` is fixture/example
|
|
256
|
+
vocabulary for the aggregate output shape, not an `igniter-embed` receipt kind.
|
|
257
|
+
The Embed observation receipt that contains it still uses
|
|
258
|
+
`receipt_kind: :contractable_observation`, and event receipts still use
|
|
259
|
+
`receipt_kind: :contractable_event`.
|
|
260
|
+
|
|
261
|
+
Keep this shape host-local:
|
|
262
|
+
|
|
263
|
+
- choose the observed target, rollout flag, and sample rate in the app;
|
|
264
|
+
- keep the redaction allow-list app-owned;
|
|
265
|
+
- persist receipts through an app-owned store adapter;
|
|
266
|
+
- treat Ledger sinks as optional adapters, not as the source of truth;
|
|
267
|
+
- do not infer release readiness or a public schema from synthetic aggregate
|
|
268
|
+
examples.
|
|
269
|
+
|
|
270
|
+
## Observation Receipts
|
|
271
|
+
|
|
272
|
+
Each contractable call produces a canonical observation receipt. The receipt
|
|
273
|
+
includes a stable `observation_id`, `schema_version`, `receipt_kind`, and a
|
|
274
|
+
`status` that summarises the outcome:
|
|
275
|
+
|
|
276
|
+
```text
|
|
277
|
+
:ok — primary and candidate matched and were accepted
|
|
278
|
+
:diverged — outputs diverged but acceptance policy passed
|
|
279
|
+
:candidate_error — candidate raised an exception
|
|
280
|
+
:acceptance_failed — candidate succeeded but acceptance policy failed
|
|
281
|
+
:store_error — store adapter raised after primary returned
|
|
282
|
+
:unsampled — call was outside the configured sample rate
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
A Spark-style store adapter wires receipts into a durable sink:
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
class SparkObservationStore
|
|
289
|
+
def record_observation(receipt)
|
|
290
|
+
# receipt[:observation_id] — stable id for linking to logs/admin
|
|
291
|
+
# receipt[:status] — :ok | :diverged | :candidate_error | …
|
|
292
|
+
# receipt[:redaction] — policy applied to inputs
|
|
293
|
+
ObservationRecord.create!(receipt.slice(:observation_id, :status, :name, :role, :stage).merge(payload: receipt))
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def record_event(receipt)
|
|
297
|
+
# receipt[:receipt_kind] == :contractable_event
|
|
298
|
+
# receipt[:event_id] — unique per event
|
|
299
|
+
# receipt[:observation_id] — links back to the observation
|
|
300
|
+
# receipt[:severity] — :info | :warning | :error
|
|
301
|
+
return unless receipt[:severity] == :error || receipt[:event] == :divergence
|
|
302
|
+
|
|
303
|
+
ObservationEvent.create!(receipt.slice(:event_id, :observation_id, :event, :severity, :summary))
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Register the store in a host:
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
runner = Igniter::Embed.contractable(:marketing_executor) do
|
|
312
|
+
migrate Api::Marketing::ExecutorService::Legacy,
|
|
313
|
+
to: Api::Marketing::ExecutorService::Contract
|
|
314
|
+
shadow async: true, sample: 0.1
|
|
315
|
+
use :normalizer, Api::Marketing::ExecutorNormalizer
|
|
316
|
+
use :redaction, only: %i[provider_payload technician_id customer_id]
|
|
317
|
+
use :acceptance, policy: :shape, outputs: { status: String, result: Hash }
|
|
318
|
+
use :store, SparkObservationStore.new
|
|
319
|
+
|
|
320
|
+
on :divergence do |event|
|
|
321
|
+
Rails.logger.warn("[igniter] divergence obs=#{event.dig(:receipt, :observation_id)}")
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
A divergence event payload includes a compact receipt:
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
{
|
|
330
|
+
event: :divergence,
|
|
331
|
+
receipt: {
|
|
332
|
+
schema_version: 1,
|
|
333
|
+
receipt_kind: :contractable_event,
|
|
334
|
+
event_id: "evt_...",
|
|
335
|
+
observation_id: "obs_...",
|
|
336
|
+
severity: :warning,
|
|
337
|
+
summary: "outputs diverged from primary",
|
|
338
|
+
observation_ref: { observation_id: "obs_...", match: false, accepted: false }
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Async adapters receive a handoff descriptor for durable job wiring:
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
class SidekiqObservationAdapter
|
|
347
|
+
def enqueue(name:, inputs:, metadata:, handoff: nil, &block)
|
|
348
|
+
if handoff
|
|
349
|
+
ObservationJob.perform_later(
|
|
350
|
+
observation_id: handoff[:observation_id],
|
|
351
|
+
name: handoff[:name],
|
|
352
|
+
queued_at: handoff[:queued_at]
|
|
353
|
+
)
|
|
354
|
+
else
|
|
355
|
+
# fallback: run inline
|
|
356
|
+
block.call
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
```
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Embed
|
|
5
|
+
class Config
|
|
6
|
+
ContractRegistration = Struct.new(:definition, :name, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
attr_reader :name, :packs, :contract_registrations,
|
|
9
|
+
:contractable_configs, :discovery_pattern
|
|
10
|
+
attr_accessor :cache, :capture_exceptions, :executor_name
|
|
11
|
+
|
|
12
|
+
UNSET = Object.new.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(name:)
|
|
15
|
+
@name = name.to_sym
|
|
16
|
+
@cache = true
|
|
17
|
+
@root = nil
|
|
18
|
+
@owner = nil
|
|
19
|
+
@packs = []
|
|
20
|
+
@contract_registrations = []
|
|
21
|
+
@contractable_configs = []
|
|
22
|
+
@discovery_enabled = false
|
|
23
|
+
@discovery_pattern = "**/*_contract.rb"
|
|
24
|
+
@capture_exceptions = false
|
|
25
|
+
@executor_name = :inline
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def pack(pack)
|
|
29
|
+
packs << pack
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def owner(value = UNSET)
|
|
34
|
+
return @owner if value.equal?(UNSET)
|
|
35
|
+
|
|
36
|
+
@owner = value
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def owner=(value)
|
|
41
|
+
owner(value)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def contract(definition, as: nil)
|
|
45
|
+
contract_registrations << ContractRegistration.new(definition: definition, name: as)
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def contractable(config)
|
|
50
|
+
raise DuplicateContractError, "contractable #{config.name} is already configured" if contractable_registered?(config.name)
|
|
51
|
+
|
|
52
|
+
contractable_configs << config
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def contractable_config(name)
|
|
57
|
+
key = name.to_sym
|
|
58
|
+
contractable_configs.find { |contractable_config| contractable_config.name == key }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def contracts(&block)
|
|
62
|
+
builder = ContractsBuilder.new(config: self)
|
|
63
|
+
evaluate_builder_block(builder, &block) if block
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def root(path = nil)
|
|
68
|
+
return @root if path.nil?
|
|
69
|
+
|
|
70
|
+
@root = File.expand_path(path.to_s)
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def root=(path)
|
|
75
|
+
root(path)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def path(value = nil)
|
|
79
|
+
return root if value.nil?
|
|
80
|
+
|
|
81
|
+
root(resolve_path(value))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def path=(value)
|
|
85
|
+
path(value)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def sugar_expansion
|
|
89
|
+
SugarExpansion.new(config: self)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def discover!(pattern: "**/*_contract.rb")
|
|
93
|
+
@discovery_enabled = true
|
|
94
|
+
@discovery_pattern = pattern
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def cache?
|
|
99
|
+
!!cache
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def discovery_enabled?
|
|
103
|
+
!!@discovery_enabled
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def capture_exceptions?
|
|
107
|
+
!!capture_exceptions
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def contractable_registered?(name)
|
|
113
|
+
contractable_configs.any? { |contractable_config| contractable_config.name == name }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def resolve_path(value)
|
|
117
|
+
if value.is_a?(Array)
|
|
118
|
+
raise SugarError, "path requires exactly one path in this implementation slice" unless value.length == 1
|
|
119
|
+
|
|
120
|
+
value = value.first
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
raise SugarError, "path entries must be strings or path-like objects" unless path_like?(value)
|
|
124
|
+
|
|
125
|
+
path_value = value.respond_to?(:to_path) ? value.to_path : value.to_s
|
|
126
|
+
return path_value if absolute_path?(path_value)
|
|
127
|
+
|
|
128
|
+
owner_root = resolved_owner_root
|
|
129
|
+
owner_root ? File.join(owner_root, path_value) : path_value
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def absolute_path?(path)
|
|
133
|
+
File.absolute_path(path) == path
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def resolved_owner_root
|
|
137
|
+
return nil unless @owner.respond_to?(:root)
|
|
138
|
+
|
|
139
|
+
@owner.root.to_s
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def path_like?(value)
|
|
143
|
+
value.is_a?(String) || value.respond_to?(:to_path)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def evaluate_builder_block(builder, &block)
|
|
147
|
+
if block.arity.zero?
|
|
148
|
+
builder.instance_eval(&block)
|
|
149
|
+
else
|
|
150
|
+
block.call(builder)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Embed
|
|
5
|
+
class Container
|
|
6
|
+
attr_reader :config, :registry
|
|
7
|
+
|
|
8
|
+
def initialize(config:)
|
|
9
|
+
@config = config
|
|
10
|
+
@registry = Registry.new
|
|
11
|
+
@compiled_contracts = {}
|
|
12
|
+
@contractable_runners = {}
|
|
13
|
+
register_configured_contracts
|
|
14
|
+
discover_configured_contracts
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def profile
|
|
18
|
+
@profile ||= Igniter::Contracts.build_profile(config.packs)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def register(name_or_definition, definition = nil, as: nil, &block)
|
|
22
|
+
name, contract_definition = normalize_registration(name_or_definition, definition, as: as, block: block)
|
|
23
|
+
raise ArgumentError, "contract definition is required" unless contract_definition
|
|
24
|
+
|
|
25
|
+
registry.register(name, contract_definition)
|
|
26
|
+
compiled_contracts.delete(name)
|
|
27
|
+
ContractHandle.new(name: name, container: self)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fetch(name)
|
|
31
|
+
registry.fetch(name)
|
|
32
|
+
ContractHandle.new(name: name, container: self)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def compile(name = nil, &block)
|
|
36
|
+
return compile_block(&block) if block
|
|
37
|
+
|
|
38
|
+
compile_registered(name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call(name, inputs = {}, **keyword_inputs)
|
|
42
|
+
normalized_inputs = inputs.merge(keyword_inputs)
|
|
43
|
+
compiled_graph = compile_registered(name)
|
|
44
|
+
result = Igniter::Contracts.execute_with(
|
|
45
|
+
config.executor_name,
|
|
46
|
+
compiled_graph,
|
|
47
|
+
inputs: normalized_inputs,
|
|
48
|
+
profile: profile
|
|
49
|
+
)
|
|
50
|
+
ExecutionEnvelope.new(name: name, inputs: normalized_inputs, result: result)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
raise unless config.capture_exceptions?
|
|
53
|
+
|
|
54
|
+
ExecutionEnvelope.new(
|
|
55
|
+
name: name,
|
|
56
|
+
inputs: normalized_inputs || {},
|
|
57
|
+
errors: [e],
|
|
58
|
+
metadata: { captured_exception: true }
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def contractable(name)
|
|
63
|
+
key = name.to_sym
|
|
64
|
+
return contractable_runners.fetch(key) if contractable_runners.key?(key)
|
|
65
|
+
|
|
66
|
+
contractable_config = config.contractable_config(key)
|
|
67
|
+
raise UnknownContractableError, "unknown contractable #{key}" unless contractable_config
|
|
68
|
+
|
|
69
|
+
contractable_runners[key] = Contractable::Runner.new(config: contractable_config)
|
|
70
|
+
end
|
|
71
|
+
alias fetch_contractable contractable
|
|
72
|
+
|
|
73
|
+
def contractable_names
|
|
74
|
+
config.contractable_configs.map(&:name)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def clear_cache
|
|
78
|
+
compiled_contracts.clear
|
|
79
|
+
contractable_runners.clear
|
|
80
|
+
@profile = nil
|
|
81
|
+
self
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def reload!
|
|
85
|
+
clear_cache
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def sugar_expansion
|
|
89
|
+
config.sugar_expansion
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
attr_reader :compiled_contracts, :contractable_runners
|
|
95
|
+
|
|
96
|
+
def discover_configured_contracts
|
|
97
|
+
return unless config.discovery_enabled?
|
|
98
|
+
|
|
99
|
+
root = config.root
|
|
100
|
+
raise DiscoveryError, "config.root is required when discovery is enabled" unless root
|
|
101
|
+
raise DiscoveryError, "contract discovery root does not exist: #{root}" unless Dir.exist?(root)
|
|
102
|
+
|
|
103
|
+
before = contract_classes
|
|
104
|
+
Dir[File.join(root, config.discovery_pattern)].sort.each { |path| require path }
|
|
105
|
+
discovered_contract_classes = (contract_classes - before).select { |klass| discoverable_contract_class?(klass) }
|
|
106
|
+
discovered_by_name = discovered_contract_classes.group_by { |klass| ContractNaming.infer_contract_name(klass) }
|
|
107
|
+
duplicates = discovered_by_name.select { |_name, classes| classes.length > 1 }
|
|
108
|
+
unless duplicates.empty?
|
|
109
|
+
duplicate_names = duplicates.keys.sort.map { |name| ":#{name}" }.join(", ")
|
|
110
|
+
raise DiscoveryError,
|
|
111
|
+
"discovered duplicate contract names #{duplicate_names}; use explicit config.contract registrations"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
discovered_by_name.keys.sort.each do |name|
|
|
115
|
+
next if registry.key?(name)
|
|
116
|
+
|
|
117
|
+
register(discovered_by_name.fetch(name).first, as: name)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def contract_classes
|
|
122
|
+
ObjectSpace.each_object(Class).select { |klass| ContractNaming.contract_class?(klass) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def discoverable_contract_class?(contract_class)
|
|
126
|
+
!contract_class.name.nil?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def register_configured_contracts
|
|
130
|
+
config.contract_registrations.each do |registration|
|
|
131
|
+
register(registration.definition, as: registration.name)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def normalize_registration(name_or_definition, definition, as:, block:)
|
|
136
|
+
if ContractNaming.contract_class?(name_or_definition)
|
|
137
|
+
name = ContractNaming.normalize_contract_name(as || ContractNaming.infer_contract_name(name_or_definition))
|
|
138
|
+
return [name, name_or_definition]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
name = ContractNaming.normalize_contract_name(as || name_or_definition)
|
|
142
|
+
[name, definition || block]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def compile_block(&block)
|
|
146
|
+
Igniter::Contracts.compile(profile: profile, &block)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def compile_registered(name)
|
|
150
|
+
key = name.to_sym
|
|
151
|
+
return compiled_contracts.fetch(key) if config.cache? && compiled_contracts.key?(key)
|
|
152
|
+
|
|
153
|
+
compiled = compile_registration(registry.fetch(key))
|
|
154
|
+
compiled_contracts[key] = compiled if config.cache?
|
|
155
|
+
compiled
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def compile_registration(registration)
|
|
159
|
+
return compile_block(®istration.definition) if registration.block?
|
|
160
|
+
|
|
161
|
+
registration.definition.compile(profile: profile)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Embed
|
|
5
|
+
class ContractHandle
|
|
6
|
+
attr_reader :name, :container
|
|
7
|
+
|
|
8
|
+
def initialize(name:, container:)
|
|
9
|
+
@name = name.to_sym
|
|
10
|
+
@container = container
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def compile
|
|
15
|
+
container.compile(name)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(inputs = {}, **keyword_inputs)
|
|
19
|
+
container.call(name, inputs.merge(keyword_inputs))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|