hubbado-sequence 0.3.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.
data/README.md ADDED
@@ -0,0 +1,562 @@
1
+ # hubbado-sequence
2
+
3
+ A small framework for orchestrating units of business behaviour. The eventual
4
+ replacement for Trailblazer operations at Hubbado, designed to coexist with
5
+ them during migration.
6
+
7
+ A sequencer takes input, runs a sequence of steps, and returns a `Result`
8
+ indicating success or failure plus the working context that was built up
9
+ during execution.
10
+
11
+ The full design rationale lives in [`docs/design.md`](docs/design.md). This
12
+ README is a quick tour.
13
+
14
+ ## Installation
15
+
16
+ Add to your Gemfile:
17
+
18
+ ```ruby
19
+ gem "hubbado-sequence"
20
+ ```
21
+
22
+ Then run `bundle install`.
23
+
24
+ ## Requirements
25
+
26
+ - Ruby >= 3.3
27
+ - [evt-dependency](https://github.com/eventide-project/dependency) — powers
28
+ the injectable macro / nested-sequencer pattern (declared as a runtime
29
+ dependency of the gem).
30
+
31
+ Optional, depending on which macros you use:
32
+
33
+ - [ActiveRecord](https://github.com/rails/rails) for `Model::Find`,
34
+ `Model::Build`, and `Pipeline#transaction`.
35
+ - [Reform](https://github.com/trailblazer/reform) for `Contract::Build`,
36
+ `Contract::Deserialize`, `Contract::Validate`, and `Contract::Persist`.
37
+ - [hubbado-policy](https://github.com/hubbado/hubbado-policy) for
38
+ `Policy::Check`.
39
+
40
+ ## Philosophy
41
+
42
+ Sequencers sit at the controller boundary. They receive input from a Rails
43
+ action, orchestrate the work, and hand a `Result` back. The sequencer's job
44
+ is **orchestration only** — it should not contain business logic itself. Real
45
+ behaviour lives in the models, contracts, policies, and domain objects it
46
+ calls.
47
+
48
+ Nesting is intentionally shallow. The only nesting we use in practice is a
49
+ `Present` sequencer inside an `Update` sequencer: Present loads the record,
50
+ builds the contract, and checks the policy; Update calls Present and then
51
+ validates and persists. Chains longer than one level are rare enough to be a
52
+ signal that something should be a plain Ruby object instead.
53
+
54
+ The framework uses [evt-dependency](https://github.com/eventide-project/dependency),
55
+ which means every macro and every nested sequencer is an injectable
56
+ dependency. Calling `.new` on a sequencer installs substitutes for all of
57
+ them, so unit tests exercise the sequencer's orchestration logic — what runs,
58
+ in what order, what short-circuits — without hitting the database, the policy
59
+ gem, or Reform. The substitutes default to pass-through `ok`, so a test only
60
+ configures the outcomes that matter for the scenario it's verifying.
61
+
62
+ Integration coverage (using `.build` to wire real collaborators) is reserved
63
+ for the controller boundary — one happy-path integration test per sequencer
64
+ is usually enough to confirm the wiring is correct.
65
+
66
+ ## Quick start
67
+
68
+ ```ruby
69
+ class Seqs::UpdateUser
70
+ include Hubbado::Sequence::Sequencer
71
+
72
+ dependency :find, Macros::Model::Find
73
+ dependency :build_contract, Macros::Contract::Build
74
+ dependency :check_policy, Macros::Policy::Check
75
+ dependency :validate, Macros::Contract::Validate
76
+ dependency :persist, Macros::Contract::Persist
77
+
78
+ def self.build
79
+ new.tap do |instance|
80
+ Macros::Model::Find.configure(instance)
81
+ Macros::Contract::Build.configure(instance)
82
+ Macros::Policy::Check.configure(instance)
83
+ Macros::Contract::Validate.configure(instance)
84
+ Macros::Contract::Persist.configure(instance)
85
+ end
86
+ end
87
+
88
+ def call(ctx)
89
+ pipeline(ctx) do |p|
90
+ p.invoke(:find, User, as: :user)
91
+ p.invoke(:build_contract, Contracts::UpdateUser, :user)
92
+ p.invoke(:check_policy, Policies::User, :user, :update)
93
+
94
+ p.transaction do |t|
95
+ t.invoke(:validate, from: %i[params user])
96
+ t.invoke(:persist)
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+ # In a controller:
103
+ class UsersController < ApplicationController
104
+ include Hubbado::Sequence::RunSequence
105
+
106
+ def update
107
+ run_sequence Seqs::UpdateUser, params: params, current_user: current_user do |result|
108
+ result.success { |ctx| redirect_to ctx[:user] }
109
+ result.policy_failed { |ctx| redirect_to root_path, alert: result.message }
110
+ result.not_found { |ctx| render_404 }
111
+ result.validation_failed { |ctx| render :edit, locals: { contract: ctx[:contract] } }
112
+ end
113
+ end
114
+ end
115
+ ```
116
+
117
+ ## The three step shapes
118
+
119
+ ```ruby
120
+ pipeline(ctx) do |p|
121
+ p.invoke(:find, :user) # declared dependency (macro or sequencer)
122
+ p.step(:scrub_params) # local method `def scrub_params(ctx)`
123
+ p.step(:audit) { |c| AuditLog.append(c[:user]) } # inline block
124
+ end
125
+ ```
126
+
127
+ - `p.invoke(:foo, *args, **kwargs)` — a `dependency :foo, …` declared on the
128
+ sequencer (a macro or a nested sequencer). Calls
129
+ `dispatcher.foo.(ctx, *args, **kwargs)`.
130
+ - `p.step(:foo)` — a local instance method. Auto-dispatches to
131
+ `self.foo(ctx)`.
132
+ - `p.step(:foo) { |ctx| … }` — explicit inline block.
133
+
134
+ The `pipeline(ctx)` helper (lowercase `p`) is what enables blockless
135
+ `p.step(:foo)` auto-dispatch — it builds a Pipeline that knows which
136
+ sequencer to dispatch back to. `Pipeline.(ctx)` (capital `P`) is the bare
137
+ constructor with no dispatcher and requires every `step` to have a block.
138
+ Use `pipeline(ctx)` inside a sequencer; `Pipeline.(ctx)` is mainly useful
139
+ for framework tests.
140
+
141
+ ## Built-in macros
142
+
143
+ Each macro is a dependency declared on a sequencer with `dependency :name, Macros::...`
144
+ and wired via `.configure(instance)` in `.build`.
145
+
146
+ The model macros are designed to work with ActiveRecord models.
147
+
148
+ ### Model::Find
149
+
150
+ Fetches a record using `model.find_by(id:)` and writes it to `ctx[as]`.
151
+
152
+ ```ruby
153
+ p.invoke(:find, User, as: :user)
154
+ p.invoke(:find, User, as: :user, id_key: :user_id) # single key
155
+ p.invoke(:find, User, as: :user, id_key: %i[params id]) # nested path (default)
156
+ ```
157
+
158
+ | | |
159
+ |---|---|
160
+ | **Reads** | `ctx` at `id_key` (default: `%i[params id]`) |
161
+ | **Writes** | `ctx[as]` — the found record |
162
+ | **Fails** | `:not_found` when `find_by` returns nil |
163
+
164
+ ### Model::Build
165
+
166
+ Instantiates a new record and writes it to `ctx[as]`.
167
+
168
+ ```ruby
169
+ p.invoke(:build_record, User, as: :user)
170
+ p.invoke(:build_record, User, as: :user, attributes: { role: :admin })
171
+ ```
172
+
173
+ | | |
174
+ |---|---|
175
+ | **Reads** | nothing |
176
+ | **Writes** | `ctx[as]` — the new instance |
177
+ | **Fails** | never |
178
+
179
+ The contract macros are designed to work with [Reform](https://github.com/trailblazer/reform) form objects.
180
+
181
+ ### Contract::Build
182
+
183
+ Wraps a model in a contract and writes it to `ctx[:contract]`.
184
+
185
+ ```ruby
186
+ p.invoke(:build_contract, Contracts::UpdateUser, :user) # model from ctx[:user]
187
+ p.invoke(:build_contract, Contracts::CreateUser) # no model
188
+ ```
189
+
190
+ | | |
191
+ |---|---|
192
+ | **Reads** | `ctx[attr_name]` for the model (optional) |
193
+ | **Writes** | `ctx[:contract]` |
194
+ | **Fails** | never |
195
+
196
+ ### Contract::Deserialize
197
+
198
+ Deserializes params into the contract via `contract.deserialize(params)`.
199
+
200
+ ```ruby
201
+ p.invoke(:deserialize_to_contract, from: %i[params user])
202
+ p.invoke(:deserialize_to_contract, from: :raw_params)
203
+ ```
204
+
205
+ | | |
206
+ |---|---|
207
+ | **Reads** | `ctx[:contract]`, `ctx` at `from:` |
208
+ | **Writes** | nothing (mutates the contract in place) |
209
+ | **Fails** | never (no-op when the `from:` path is absent) |
210
+
211
+ ### Contract::Validate
212
+
213
+ Validates the contract via `contract.validate(params)` and checks `errors`.
214
+
215
+ ```ruby
216
+ p.invoke(:validate, from: %i[params user])
217
+ p.invoke(:validate) # contract already deserialized; passes empty params
218
+ ```
219
+
220
+ | | |
221
+ |---|---|
222
+ | **Reads** | `ctx[:contract]`, `ctx` at `from:` (when given) |
223
+ | **Writes** | nothing (populates `contract.errors` on invalid) |
224
+ | **Fails** | `:validation_failed` when `contract.errors` is non-empty |
225
+
226
+ ### Contract::Persist
227
+
228
+ Saves the contract via `contract.save`.
229
+
230
+ ```ruby
231
+ p.invoke(:persist)
232
+ ```
233
+
234
+ | | |
235
+ |---|---|
236
+ | **Reads** | `ctx[:contract]` |
237
+ | **Writes** | nothing |
238
+ | **Fails** | `:persist_failed` when `save` returns false |
239
+
240
+ ### Policy::Check
241
+
242
+ Builds a policy and calls the named action to authorise the operation.
243
+
244
+ ```ruby
245
+ p.invoke(:check_policy, Policies::User, :user, :update)
246
+ ```
247
+
248
+ Designed to work with the [hubbado-policy](https://github.com/hubbado/hubbado-policy) gem.
249
+ The policy class must respond to `.build(current_user, record)`; the instance must
250
+ respond to the action method and return an object with `permitted?`.
251
+
252
+ | | |
253
+ |---|---|
254
+ | **Reads** | `ctx[:current_user]`, `ctx[record_key]` |
255
+ | **Writes** | nothing |
256
+ | **Fails** | `:forbidden` when `permitted?` is false; `error[:data]` carries `{ policy:, policy_result: }` |
257
+
258
+ ## Transactions
259
+
260
+ `Pipeline#transaction` wraps inner steps in `ActiveRecord::Base.transaction`.
261
+ A failed inner step raises `ActiveRecord::Rollback` and the failed `Result`
262
+ still propagates outward.
263
+
264
+ ```ruby
265
+ def call(ctx)
266
+ pipeline(ctx) do |p|
267
+ p.invoke(:find, User, as: :user)
268
+ p.invoke(:build_contract, Contracts::UpdateUser, :user)
269
+ p.invoke(:check_policy, Policies::User, :user, :update)
270
+
271
+ p.transaction do |t|
272
+ t.invoke(:validate, from: %i[params user])
273
+ t.invoke(:persist)
274
+ end
275
+
276
+ p.step(:notify) { |c| UserMailer.updated(c[:user]).deliver_later }
277
+ end
278
+ end
279
+ ```
280
+
281
+ Steps before the transaction run outside it (read-only lookups, policy
282
+ checks). Steps after run after commit (notifications, emails — things that
283
+ shouldn't run if the DB write didn't stick).
284
+
285
+ When ActiveRecord isn't loaded, `transaction` runs the inner block inline
286
+ as part of the same pipeline.
287
+
288
+ ## Nested sequencers (Present + Update)
289
+
290
+ The "find the record, build the contract, check the policy" shape is shared
291
+ between an edit form and an update action — both need exactly that, and the
292
+ update then validates and persists. Extract the shared part as a Present
293
+ sequencer and nest it as a dependency:
294
+
295
+ ```ruby
296
+ class Seqs::PresentUser
297
+ include Hubbado::Sequence::Sequencer
298
+
299
+ configure :present # so a parent can use `Seqs::PresentUser.configure(instance)`
300
+
301
+ dependency :find, Macros::Model::Find
302
+ dependency :build_contract, Macros::Contract::Build
303
+ dependency :check_policy, Macros::Policy::Check
304
+
305
+ def self.build
306
+ new.tap do |instance|
307
+ Macros::Model::Find.configure(instance)
308
+ Macros::Contract::Build.configure(instance)
309
+ Macros::Policy::Check.configure(instance)
310
+ end
311
+ end
312
+
313
+ def call(ctx)
314
+ pipeline(ctx) do |p|
315
+ p.invoke(:find, User, as: :user)
316
+ p.invoke(:build_contract, Contracts::UpdateUser, :user)
317
+ p.invoke(:check_policy, Policies::User, :user, :update)
318
+ end
319
+ end
320
+ end
321
+
322
+ class Seqs::UpdateUser
323
+ include Hubbado::Sequence::Sequencer
324
+
325
+ dependency :present, Seqs::PresentUser
326
+ dependency :validate, Macros::Contract::Validate
327
+ dependency :persist, Macros::Contract::Persist
328
+
329
+ def self.build
330
+ new.tap do |instance|
331
+ Seqs::PresentUser.configure(instance)
332
+ Macros::Contract::Validate.configure(instance)
333
+ Macros::Contract::Persist.configure(instance)
334
+ end
335
+ end
336
+
337
+ def call(ctx)
338
+ pipeline(ctx) do |p|
339
+ p.invoke(:present)
340
+
341
+ p.transaction do |t|
342
+ t.invoke(:validate, from: %i[params user])
343
+ t.invoke(:persist)
344
+ end
345
+ end
346
+ end
347
+ end
348
+ ```
349
+
350
+ The edit action runs Present and renders the form; the update action runs
351
+ Update and either redirects or re-renders:
352
+
353
+ ```ruby
354
+ class UsersController < ApplicationController
355
+ include Hubbado::Sequence::RunSequence
356
+
357
+ def edit
358
+ run_sequence Seqs::PresentUser, params: params, current_user: current_user do |result|
359
+ result.success { |ctx| render :edit, locals: { contract: ctx[:contract] } }
360
+ result.policy_failed { |_| redirect_to root_path, alert: result.message }
361
+ result.not_found { |_| render_404 }
362
+ end
363
+ end
364
+
365
+ def update
366
+ run_sequence Seqs::UpdateUser, params: params, current_user: current_user do |result|
367
+ result.success { |ctx| redirect_to ctx[:user] }
368
+ result.policy_failed { |_| redirect_to root_path, alert: result.message }
369
+ result.not_found { |_| render_404 }
370
+ result.validation_failed { |ctx| render :edit, locals: { contract: ctx[:contract] } }
371
+ end
372
+ end
373
+ end
374
+ ```
375
+
376
+ Inner writes (`ctx[:user]`, `ctx[:contract]`) are visible to outer steps —
377
+ Present and Update share the same `Ctx`, so `:validate` and `:persist` see
378
+ exactly what Present built. The outer trail records `:present` as a single
379
+ step; Present's inner steps stay opaque to the parent.
380
+
381
+ ## Result, success, failure
382
+
383
+ A step is **successful unless it explicitly returns a failed `Result`**. Any
384
+ other return value (`nil`, `false`, a model, `Result.ok(...)`) is taken as
385
+ success and the pipeline continues with the same `ctx`. Only
386
+ `Result.fail(...)` or the `failure(ctx, code: ...)` helper short-circuits.
387
+
388
+ ```ruby
389
+ def call(ctx)
390
+ pipeline(ctx) do |p|
391
+ p.step(:must_be_premium)
392
+ p.invoke(:persist)
393
+ end
394
+ end
395
+
396
+ private
397
+
398
+ def must_be_premium(ctx)
399
+ return failure(ctx, code: :forbidden) unless ctx[:user].premium?
400
+ # implicit ok if we get here
401
+ end
402
+ ```
403
+
404
+ `failure(ctx, ...)` is a sequencer helper that builds a failed `Result`
405
+ with the sequencer's auto-derived i18n scope already applied. It takes the
406
+ same error attrs as the underlying error hash (`code:`, `i18n_key:`,
407
+ `i18n_args:`, `data:`, `message:`).
408
+
409
+ ## Testing
410
+
411
+ `described_class.new` returns a sequencer with all dependencies installed as
412
+ substitutes. Tests configure the substitutes for the scenario at hand.
413
+ `described_class.build` runs the production wiring (the real macros).
414
+ Substitutes default to pass-through `Result.ok(ctx)` so a test only
415
+ configures the ones whose return matters.
416
+
417
+ ### Substituting macros directly
418
+
419
+ ```ruby
420
+ RSpec.describe Seqs::PresentUser do
421
+ it "loads the user, builds the contract, and passes the policy" do
422
+ seq = described_class.new
423
+ user = User.new(id: 1, email: "old@example.com")
424
+ contract = Contracts::UpdateUser.new(user)
425
+ seq.find.succeed_with(user)
426
+ seq.build_contract.succeed_with(contract)
427
+
428
+ result = seq.(Hubbado::Sequence::Ctx.build(
429
+ params: { id: 1 },
430
+ current_user: User.new
431
+ ))
432
+
433
+ expect(result).to be_ok
434
+ expect(seq.find.fetched?(as: :user)).to be true
435
+ expect(seq.build_contract.built?).to be true
436
+ expect(seq.check_policy.checked?).to be true
437
+ end
438
+
439
+ it "fails with :not_found when the user doesn't exist" do
440
+ seq = described_class.new
441
+ seq.find.fail_with(code: :not_found)
442
+
443
+ result = seq.(Hubbado::Sequence::Ctx.build(
444
+ params: { id: 999 },
445
+ current_user: User.new
446
+ ))
447
+
448
+ expect(result.error[:code]).to eq(:not_found)
449
+ expect(seq.build_contract.built?).to be false
450
+ expect(seq.check_policy.checked?).to be false
451
+ end
452
+ end
453
+ ```
454
+
455
+ ### Substituting a nested sequencer
456
+
457
+ Every sequencer ships a default `Substitute` module (installed by
458
+ `include Hubbado::Sequence::Sequencer`) with `succeed_with(**ctx_writes)` /
459
+ `fail_with(**error)` / `called?(**partial_kwargs)`. The parent's tests can
460
+ short-circuit a nested sequencer without reaching into its inner pieces:
461
+
462
+ ```ruby
463
+ RSpec.describe Seqs::UpdateUser do
464
+ it "updates the user when present succeeds" do
465
+ seq = described_class.new
466
+
467
+ user = User.new(id: 1, email: "old@example.com")
468
+ contract = Contracts::UpdateUser.new(user)
469
+ seq.present.succeed_with(user: user, contract: contract)
470
+
471
+ result = seq.(Hubbado::Sequence::Ctx.build(
472
+ params: { user: { email: "new@example.com" } },
473
+ current_user: User.new
474
+ ))
475
+
476
+ expect(result).to be_ok
477
+ expect(seq.present.called?).to be true
478
+ expect(seq.persist.persisted?).to be true
479
+ end
480
+
481
+ it "stops when present denies access" do
482
+ seq = described_class.new
483
+ seq.present.fail_with(code: :forbidden)
484
+
485
+ result = seq.(Hubbado::Sequence::Ctx.build(
486
+ params: { user: {} },
487
+ current_user: User.new
488
+ ))
489
+
490
+ expect(result.failure?).to be true
491
+ expect(result.error[:code]).to eq(:forbidden)
492
+ expect(seq.validate.validated?).to be false
493
+ expect(seq.persist.persisted?).to be false
494
+ end
495
+
496
+ it "stops when present cannot find the record" do
497
+ seq = described_class.new
498
+ seq.present.fail_with(code: :not_found)
499
+
500
+ result = seq.(Hubbado::Sequence::Ctx.build(
501
+ params: { id: 999, user: {} },
502
+ current_user: User.new
503
+ ))
504
+
505
+ expect(result.error[:code]).to eq(:not_found)
506
+ expect(seq.validate.validated?).to be false
507
+ expect(seq.persist.persisted?).to be false
508
+ end
509
+ end
510
+ ```
511
+
512
+ `succeed_with(**ctx_writes)` writes the given keys into `ctx` and returns
513
+ `Result.ok(ctx)`, so the outer steps see what the real Present would have
514
+ left behind. `fail_with(**error)` returns a failed `Result` with the given
515
+ error, short-circuiting the outer pipeline. The Update spec doesn't need
516
+ to exercise Find / Build / Policy::Check directly — those live in
517
+ PresentUser's spec, where they belong.
518
+
519
+ ## Observability
520
+
521
+ Every `Result` carries a **trail** — the list of step names that completed
522
+ successfully, in order. On failure, the failing step is *not* in the trail;
523
+ it's tagged on `error[:step]` instead.
524
+
525
+ ```ruby
526
+ result.trail # => [:find, :build_contract, :check_policy, :validate, :persist] # success
527
+ result.trail # => [:find, :build_contract] # failed at :check_policy
528
+ result.error[:step] # => :check_policy
529
+ ```
530
+
531
+ When invoked via `run_sequence`, the dispatcher logs a single line per
532
+ invocation summarising the trail and (on failure) where it stopped:
533
+
534
+ ```
535
+ Sequencer Seqs::UpdateUser succeeded: find → build_contract → check_policy → validate → persist
536
+ Sequencer Seqs::UpdateUser failed at :check_policy (forbidden): find → build_contract
537
+ ```
538
+
539
+ Nested sequencer trails are opaque to the parent: a parent's trail shows
540
+ `:present` as a single step, not the sub-steps inside Present.
541
+ `error[:step]` carries the inner step name when a nested sequencer fails.
542
+
543
+ ## Standard error codes
544
+
545
+ - `:not_found` — `Model::Find` couldn't find the record.
546
+ - `:forbidden` — policy denied.
547
+ - `:validation_failed` — contract invalid; see `ctx[:contract].errors`.
548
+ - `:persist_failed` — save failed for non-validation reasons.
549
+ - `:conflict` — uniqueness or optimistic locking.
550
+
551
+ Sequencers can mint their own codes for domain-specific failures
552
+ (`:not_shippable`, `:already_cancelled`).
553
+
554
+ ## Documentation
555
+
556
+ - [`docs/design.md`](docs/design.md) — full design and rationale (decisions
557
+ considered and rejected, "Resolved Through Iteration" log of reversals,
558
+ open questions).
559
+
560
+ ## License
561
+
562
+ Internal Hubbado gem.
@@ -0,0 +1,8 @@
1
+ en:
2
+ sequence:
3
+ errors:
4
+ not_found: "Not found"
5
+ forbidden: "Not authorized"
6
+ validation_failed: "Validation failed"
7
+ persist_failed: "Could not be saved"
8
+ conflict: "Conflicts with existing data"
@@ -0,0 +1,41 @@
1
+ # -*- encoding: utf-8 -*-
2
+ Gem::Specification.new do |s|
3
+ s.name = "hubbado-sequence"
4
+ s.version = "0.3.0"
5
+ s.summary = "A small framework for orchestrating units of business behaviour"
6
+ s.description = "Sequencer takes input, runs a sequence of steps, and returns a Result indicating success or failure plus the working context that was built up during execution."
7
+
8
+ s.authors = ["Hubbado Devs"]
9
+ s.email = ["devs@hubbado.com"]
10
+ s.homepage = "https://github.com/hubbado/hubbado-sequence"
11
+ s.license = "MIT"
12
+
13
+ s.metadata["homepage_uri"] = s.homepage
14
+ s.metadata["source_code_uri"] = s.homepage
15
+ s.metadata["changelog_uri"] = "#{s.homepage}/blob/master/CHANGELOG.md"
16
+
17
+ s.require_paths = ["lib"]
18
+ s.files = Dir.glob(%w[
19
+ lib/**/*.rb
20
+ config/**/*.yml
21
+ *.gemspec
22
+ LICENSE*
23
+ README*
24
+ CHANGELOG*
25
+ ])
26
+ s.platform = Gem::Platform::RUBY
27
+ s.required_ruby_version = ">= 3.3"
28
+
29
+ s.add_runtime_dependency "evt-casing"
30
+ s.add_runtime_dependency "evt-configure"
31
+ s.add_runtime_dependency "evt-dependency"
32
+ s.add_runtime_dependency "evt-record_invocation"
33
+ s.add_runtime_dependency "evt-template_method"
34
+ s.add_runtime_dependency "hubbado-log"
35
+ s.add_runtime_dependency "i18n"
36
+
37
+ s.add_development_dependency "debug"
38
+ s.add_development_dependency "hubbado-style"
39
+ s.add_development_dependency "rake"
40
+ s.add_development_dependency "test_bench"
41
+ end
@@ -0,0 +1,45 @@
1
+ module Hubbado
2
+ module Sequence
3
+ module Controls
4
+ module Contract
5
+ def self.example(model: nil, valid: true, save_result: true)
6
+ example_class(valid: valid, save_result: save_result).new(model)
7
+ end
8
+
9
+ # Returns a contract class that can be passed to Contract::Build as
10
+ # `contract_class:`. The class wraps whatever model is passed to .new.
11
+ def self.example_class(valid: true, save_result: true)
12
+ Class.new do
13
+ attr_reader :model, :validated_with, :deserialized_with, :saved
14
+ attr_accessor :errors
15
+
16
+ define_singleton_method(:default_valid) { valid }
17
+ define_singleton_method(:default_save_result) { save_result }
18
+
19
+ def initialize(model = nil)
20
+ @model = model
21
+ @valid = self.class.default_valid
22
+ @save_result = self.class.default_save_result
23
+ @errors = @valid ? [] : [:something_invalid]
24
+ end
25
+
26
+ def validate(params)
27
+ @validated_with = params
28
+ @valid
29
+ end
30
+
31
+ def deserialize(params)
32
+ @deserialized_with = params
33
+ self
34
+ end
35
+
36
+ def save
37
+ @saved = true
38
+ @save_result
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,33 @@
1
+ module Hubbado
2
+ module Sequence
3
+ module Controls
4
+ module Model
5
+ def self.example_class
6
+ Class.new do
7
+ def self.records
8
+ @records ||= {}
9
+ end
10
+
11
+ def self.put(id, value)
12
+ records[id] = value
13
+ end
14
+
15
+ def self.find_by(id:)
16
+ records[id]
17
+ end
18
+
19
+ def self.reset
20
+ @records = {}
21
+ end
22
+
23
+ attr_reader :init_attributes
24
+
25
+ def initialize(attributes = {})
26
+ @init_attributes = attributes
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end