bcdd-contract 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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +128 -0
  3. data/CHANGELOG.md +45 -0
  4. data/CODE_OF_CONDUCT.md +84 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +964 -0
  7. data/Rakefile +18 -0
  8. data/Steepfile +32 -0
  9. data/examples/README.md +11 -0
  10. data/examples/anti_corruption_layer/README.md +212 -0
  11. data/examples/anti_corruption_layer/Rakefile +30 -0
  12. data/examples/anti_corruption_layer/app/models/payment/charge_credit_card.rb +36 -0
  13. data/examples/anti_corruption_layer/config.rb +20 -0
  14. data/examples/anti_corruption_layer/lib/payment_gateways/adapters/circle_up.rb +19 -0
  15. data/examples/anti_corruption_layer/lib/payment_gateways/adapters/pay_friend.rb +19 -0
  16. data/examples/anti_corruption_layer/lib/payment_gateways/contract.rb +15 -0
  17. data/examples/anti_corruption_layer/lib/payment_gateways/response.rb +5 -0
  18. data/examples/anti_corruption_layer/lib/payment_gateways.rb +11 -0
  19. data/examples/anti_corruption_layer/vendor/circle_up/client.rb +11 -0
  20. data/examples/anti_corruption_layer/vendor/pay_friend/client.rb +11 -0
  21. data/examples/business_processes/README.md +245 -0
  22. data/examples/business_processes/Rakefile +50 -0
  23. data/examples/business_processes/config.rb +14 -0
  24. data/examples/business_processes/lib/division.rb +58 -0
  25. data/examples/design_by_contract/README.md +227 -0
  26. data/examples/design_by_contract/Rakefile +60 -0
  27. data/examples/design_by_contract/config.rb +13 -0
  28. data/examples/design_by_contract/lib/shopping_cart.rb +62 -0
  29. data/examples/ports_and_adapters/README.md +246 -0
  30. data/examples/ports_and_adapters/Rakefile +68 -0
  31. data/examples/ports_and_adapters/app/models/user/record/repository.rb +13 -0
  32. data/examples/ports_and_adapters/app/models/user/record.rb +7 -0
  33. data/examples/ports_and_adapters/config.rb +28 -0
  34. data/examples/ports_and_adapters/db/setup.rb +16 -0
  35. data/examples/ports_and_adapters/lib/user/creation.rb +19 -0
  36. data/examples/ports_and_adapters/lib/user/data.rb +5 -0
  37. data/examples/ports_and_adapters/lib/user/repository.rb +24 -0
  38. data/examples/ports_and_adapters/test/user_test/repository.rb +21 -0
  39. data/lib/bcdd/contract/assertions.rb +21 -0
  40. data/lib/bcdd/contract/config.rb +25 -0
  41. data/lib/bcdd/contract/core/checker.rb +37 -0
  42. data/lib/bcdd/contract/core/checking.rb +38 -0
  43. data/lib/bcdd/contract/core/factory.rb +32 -0
  44. data/lib/bcdd/contract/core/proxy.rb +19 -0
  45. data/lib/bcdd/contract/core.rb +12 -0
  46. data/lib/bcdd/contract/interface.rb +25 -0
  47. data/lib/bcdd/contract/list.rb +45 -0
  48. data/lib/bcdd/contract/map/pairs.rb +47 -0
  49. data/lib/bcdd/contract/map/schema.rb +50 -0
  50. data/lib/bcdd/contract/map.rb +10 -0
  51. data/lib/bcdd/contract/proxy.rb +40 -0
  52. data/lib/bcdd/contract/registry.rb +67 -0
  53. data/lib/bcdd/contract/unit/checker.rb +51 -0
  54. data/lib/bcdd/contract/unit/factory.rb +53 -0
  55. data/lib/bcdd/contract/unit.rb +40 -0
  56. data/lib/bcdd/contract/version.rb +7 -0
  57. data/lib/bcdd/contract.rb +118 -0
  58. data/lib/bcdd-contract.rb +3 -0
  59. data/sig/bcdd/contract/assertions.rbs +7 -0
  60. data/sig/bcdd/contract/config.rbs +15 -0
  61. data/sig/bcdd/contract/core.rbs +60 -0
  62. data/sig/bcdd/contract/interface.rbs +12 -0
  63. data/sig/bcdd/contract/list.rbs +21 -0
  64. data/sig/bcdd/contract/map.rbs +45 -0
  65. data/sig/bcdd/contract/proxy.rbs +8 -0
  66. data/sig/bcdd/contract/registry.rbs +25 -0
  67. data/sig/bcdd/contract/unit.rbs +39 -0
  68. data/sig/bcdd/contract.rbs +31 -0
  69. metadata +116 -0
data/Rakefile ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.warning = false
8
+
9
+ t.libs += %w[lib test]
10
+
11
+ t.test_files = FileList.new('test/**/*_test.rb')
12
+ end
13
+
14
+ require 'rubocop/rake_task'
15
+
16
+ RuboCop::RakeTask.new
17
+
18
+ task default: %i[test rubocop]
data/Steepfile ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ D = Steep::Diagnostic
4
+
5
+ target :lib do
6
+ signature 'sig'
7
+
8
+ check 'lib' # Directory name
9
+ # check 'Gemfile' # File name
10
+ # check 'app/models/**/*.rb' # Glob
11
+ # ignore 'lib/templates/*.rb'
12
+
13
+ library 'singleton' # Standard libraries
14
+ # library 'strong_json' # Gems
15
+
16
+ # configure_code_diagnostics(D::Ruby.default) # `default` diagnostics setting (applies by default)
17
+ # configure_code_diagnostics(D::Ruby.strict) # `strict` diagnostics setting
18
+ # configure_code_diagnostics(D::Ruby.lenient) # `lenient` diagnostics setting
19
+ # configure_code_diagnostics(D::Ruby.silent) # `silent` diagnostics setting
20
+ # configure_code_diagnostics do |hash| # You can setup everything yourself
21
+ # hash[D::Ruby::NoMethod] = :information
22
+ # end
23
+ configure_code_diagnostics(D::Ruby.default.merge(D::Ruby::UnknownConstant => :information))
24
+ end
25
+
26
+ # target :test do
27
+ # signature 'sig', 'sig-private'
28
+ #
29
+ # check 'test'
30
+ #
31
+ # # library 'pathname' # Standard libraries
32
+ # end
@@ -0,0 +1,11 @@
1
+ ## `BCDD::Contact` Examples
2
+
3
+ > **Attention:** Each example has its own **README** with more details.
4
+
5
+ 1. [Ports and Adapters](ports_and_adapters) - Implements the Ports and Adapters pattern. It uses **`BCDD::Contract::Interface`** to provide an interface from the application's core to other layers.
6
+
7
+ 2. [Anti-Corruption Layer](anti_corruption_layer) - Implements the Anti-Corruption Layer pattern. It uses the **`BCDD::Contract::Proxy`** to define an inteface for a set of adapters, which will be used to translate an external interface (`vendors`) to the application's core interface.
8
+
9
+ 3. [Business Processes](business_processes) - Implements a business process using the [`bcdd-result`](https://github.com/B-CDD/result) gem and uses the `bcdd-contract` to define its contract.
10
+
11
+ 4. [Design by Contract](design_by_contract) - Shows how the `bcdd-contract` can be used to establish pre-conditions, post-conditions, and invariants in a class.
@@ -0,0 +1,212 @@
1
+ - [🛡️ Anti-Corruption Layer Example](#️-anti-corruption-layer-example)
2
+ - [The ACL](#the-acl)
3
+ - [🤔 How does it work?](#-how-does-it-work)
4
+ - [📜 The Contract](#-the-contract)
5
+ - [🔄 The Adapters](#-the-adapters)
6
+ - [⚖️ What is the benefit of doing this?](#️-what-is-the-benefit-of-doing-this)
7
+ - [How much to do this (create ACL)?](#how-much-to-do-this-create-acl)
8
+ - [Is it worth the overhead of contract checking at runtime?](#is-it-worth-the-overhead-of-contract-checking-at-runtime)
9
+ - [🏃‍♂️ How to run the application?](#️-how-to-run-the-application)
10
+
11
+ ## 🛡️ Anti-Corruption Layer Example
12
+
13
+ The **Anti-Corruption Layer**, or ACL, is a pattern that isolates and protects a system from legacy or dependencies out of its control. It acts as a mediator, translating and adapting data between different components, ensuring they communicate without corrupting each other's data or logic.
14
+
15
+ To illustrate this pattern, let's see an example of an application that uses third-party API to charge a credit card.
16
+
17
+ Let's start seeing the code structure of this example:
18
+
19
+ ```
20
+ ├── Rakefile
21
+ ├── config.rb
22
+ ├── app
23
+ │ └── models
24
+ │ └── payment
25
+ │ └── charge_credit_card.rb
26
+ ├── lib
27
+ │ ├── payment_gateways
28
+ │ │ ├── adapters
29
+ │ │ │ ├── circle_up.rb
30
+ │ │ │ └── pay_friend.rb
31
+ │ │ ├── contract.rb
32
+ │ │ └── response.rb
33
+ │ └── payment_gateways.rb
34
+ └── vendor
35
+ ├── circle_up
36
+ │ └── client.rb
37
+ └── pay_friend
38
+ └── client.rb
39
+ ```
40
+
41
+ The files and directories are organized as follows:
42
+
43
+ - `Rakefile` runs the application.
44
+ - `config.rb` file contains the configurations.
45
+ - `app` directory contains the domain model where the business process to charge a credit card is implemented.
46
+ - `lib` directory contains the payment gateways contract and adapters.
47
+ - `vendor` directory contains the third-party API clients.
48
+
49
+ ## The ACL
50
+
51
+ The ACL is implemented in the `PaymentGateways` module (see `lib/payment_gateways.rb`). It translates the third-party APIs (see `vendor`) into something known by the application's domain model. Through this module, the application can charge a credit card without knowing the details/internals of the vendors.
52
+
53
+ ### 🤔 How does it work?
54
+
55
+ The `PaymentGateways::ChargeCreditCard` class (see `app/models/payment/charge_credit_card.rb`) uses`PaymentGateways::Contract` to ensure the `payment_gateway` object implements the required and known interface (input and output) to charge a credit card.
56
+
57
+ ```ruby
58
+ module Payment
59
+ class ChargeCreditCard
60
+ include ::BCDD::Result::Context.mixin(config: { addon: { continue: true } })
61
+
62
+ attr_reader :payment_gateway
63
+
64
+ def initialize(payment_gateway)
65
+ @payment_gateway = ::PaymentGateways::Contract.new(payment_gateway)
66
+ end
67
+
68
+ def call(amount:, details: {})
69
+ Given(amount:)
70
+ .and_then(:validate_amount)
71
+ .and_then(:charge_credit_card, details:)
72
+ .and_expose(:payment_charged, %i[payment_id])
73
+ end
74
+
75
+ private
76
+
77
+ def validate_amount(amount:)
78
+ return Continue() if amount.is_a?(::Numeric) && amount.positive?
79
+
80
+ Failure(:invalid_amount, erros: ['amount must be positive'])
81
+ end
82
+
83
+ def charge_credit_card(amount:, details:)
84
+ response = payment_gateway.charge_credit_card(amount:, details:)
85
+
86
+ Continue(payment_id: ::SecureRandom.uuid) if response.success?
87
+ end
88
+ end
89
+ end
90
+ ```
91
+
92
+ #### 📜 The Contract
93
+
94
+ The `PaymentGateways::Contract` defines the interface of the payment gateways. It is implemented by the `PaymentGateways::Adapters::CircleUp` and `PaymentGateways::Adapters::PayFriend` adapters.
95
+
96
+ ```ruby
97
+ module PaymentGateways
98
+ class Contract < ::BCDD::Contract::Proxy
99
+ def charge_credit_card(params)
100
+ params => { amount: Numeric, details: Hash }
101
+
102
+ outcome = object.charge_credit_card(params)
103
+
104
+ outcome => Response[true | false]
105
+
106
+ outcome
107
+ end
108
+ end
109
+ end
110
+ ```
111
+
112
+ In this case, the contract will ensure the input by using the `=>` pattern-matching operator, which will raise an exception if it does not match the expected types. After that, it calls the adapter's `charge_credit_card` method and ensures the output is a `PaymentGateways::Response` by using the `=>` operator again.
113
+
114
+ The response (see `lib/payment_gateways/response.rb`) will ensure the ACL, as it is the object known/exposed to the application.
115
+
116
+ ```ruby
117
+ module PaymentGateways
118
+ Response = ::Struct.new(:success?)
119
+ end
120
+ ```
121
+
122
+ #### 🔄 The Adapters
123
+
124
+ Let's see the payment gateways adapters:
125
+
126
+ `lib/payment_gateways/adapters/circle_up.rb`
127
+
128
+ ```ruby
129
+ module PaymentGateways
130
+ class Adapters::CircleUp
131
+ attr_reader :client
132
+
133
+ def initialize
134
+ @client = ::CircleUp::Client.new
135
+ end
136
+
137
+ def charge_credit_card(params)
138
+ params => { amount:, details: }
139
+
140
+ response = client.charge_cc(amount, details)
141
+
142
+ Response.new(response.ok?)
143
+ end
144
+ end
145
+ end
146
+ ```
147
+
148
+ `lib/payment_gateways/adapters/pay_friend.rb`
149
+
150
+ ```ruby
151
+ module PaymentGateways
152
+ class Adapters::PayFriend
153
+ attr_reader :client
154
+
155
+ def initialize
156
+ @client = ::PayFriend::Client.new
157
+ end
158
+
159
+ def charge_credit_card(params)
160
+ params => { amount:, details: }
161
+
162
+ response = client.charge(amount:, payment_data: details, payment_method: 'credit_card')
163
+
164
+ Response.new(response.status == 'success')
165
+ end
166
+ end
167
+ end
168
+ ```
169
+
170
+ You can see that each third-party API has its way of charging a credit card, so the adapters are responsible for translating the input/output from the third-party APIs to the output known by the application (the `PaymentGateways::Response`).
171
+
172
+ ## ⚖️ What is the benefit of doing this?
173
+
174
+ The benefit of doing this is that the core business logic is decoupled from the legacy/external dependencies, which makes it easier to test and promote changes in the code.
175
+
176
+ Using this example, if the third-party APIs change, we just need to implement a new adapter and make the business processes (`Payment::ChargeCreditCard`) use it. The business processes will not be affected as it is protected by the ACL.
177
+
178
+ ### How much to do this (create ACL)?
179
+
180
+ Use this pattern when there is a real need to decouple the core business logic from external dependencies.
181
+
182
+ You can start with a simple implementation (without ACL) and refactor it to use this pattern when the need arises.
183
+
184
+ ### Is it worth the overhead of contract checking at runtime?
185
+
186
+ You can eliminate the overhead by disabling the `BCDD::Contract::Proxy` class, which is a proxy that forwards all the method calls to the object it wraps.
187
+
188
+ When it is disabled, the `BCDD::Contract::Proxy.new` returns the given object so that the method calls are made directly to it.
189
+
190
+ To disable it, set the configuration to false:
191
+
192
+ ```ruby
193
+ BCDD::Contract.configuration do |config|
194
+ config.proxy_enabled = false
195
+ end
196
+ ```
197
+
198
+ ## 🏃‍♂️ How to run the application?
199
+
200
+ In the same directory as this `README`, run:
201
+
202
+ ```bash
203
+ rake
204
+
205
+ # -- CircleUp --
206
+ #
207
+ # #<BCDD::Result::Context::Success type=:payment_charged value={:payment_id=>"aa794f93-bab5-4b88-b098-4472a4aa2d33"}>
208
+ #
209
+ # -- PayFriend --
210
+ #
211
+ # #<BCDD::Result::Context::Success type=:payment_charged value={:payment_id=>"a2519a45-8bfc-471b-b07c-85f4e601de1b"}>
212
+ ```
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ if RUBY_VERSION <= '3.1'
4
+ puts 'This example requires Ruby 3.1 or higher.'
5
+ exit! 1
6
+ end
7
+
8
+ require_relative 'config'
9
+
10
+ task :default do
11
+ puts '====================='
12
+ puts 'Anti Corruption Layer'
13
+ puts '====================='
14
+
15
+ puts
16
+ puts '-- CircleUp --'
17
+ puts
18
+
19
+ circle_up_gateway = PaymentGateways::Adapters::CircleUp.new
20
+
21
+ p Payment::ChargeCreditCard.new(circle_up_gateway).call(amount: 100)
22
+
23
+ puts
24
+ puts '-- PayFriend --'
25
+ puts
26
+
27
+ pay_friend_gateway = PaymentGateways::Adapters::PayFriend.new
28
+
29
+ p Payment::ChargeCreditCard.new(pay_friend_gateway).call(amount: 200)
30
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Payment
6
+ class ChargeCreditCard
7
+ include ::BCDD::Result::Context.mixin(config: { addon: { continue: true } })
8
+
9
+ attr_reader :payment_gateway
10
+
11
+ def initialize(payment_gateway)
12
+ @payment_gateway = ::PaymentGateways::Contract.new(payment_gateway)
13
+ end
14
+
15
+ def call(amount:, details: {})
16
+ Given(amount:)
17
+ .and_then(:validate_amount)
18
+ .and_then(:charge_credit_card, details:)
19
+ .and_expose(:payment_charged, %i[payment_id])
20
+ end
21
+
22
+ private
23
+
24
+ def validate_amount(amount:)
25
+ return Continue() if amount.is_a?(::Numeric) && amount.positive?
26
+
27
+ Failure(:invalid_amount, erros: ['amount must be positive'])
28
+ end
29
+
30
+ def charge_credit_card(amount:, details:)
31
+ response = payment_gateway.charge_credit_card(amount:, details:)
32
+
33
+ Continue(payment_id: ::SecureRandom.uuid) if response.success?
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/inline'
4
+
5
+ $LOAD_PATH.unshift(__dir__)
6
+
7
+ gemfile do
8
+ source 'https://rubygems.org'
9
+
10
+ gem 'bcdd-result', '>= 0.12.0'
11
+ gem 'bcdd-contract', path: '../../'
12
+ end
13
+
14
+ require 'vendor/pay_friend/client'
15
+ require 'vendor/circle_up/client'
16
+
17
+ require 'lib/payment_gateways'
18
+
19
+ require 'app/models/payment/charge_credit_card'
20
+
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaymentGateways
4
+ class Adapters::CircleUp
5
+ attr_reader :client
6
+
7
+ def initialize
8
+ @client = ::CircleUp::Client.new
9
+ end
10
+
11
+ def charge_credit_card(params)
12
+ params => { amount:, details: }
13
+
14
+ response = client.charge_cc(amount, details)
15
+
16
+ Response.new(response.ok?)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaymentGateways
4
+ class Adapters::PayFriend
5
+ attr_reader :client
6
+
7
+ def initialize
8
+ @client = ::PayFriend::Client.new
9
+ end
10
+
11
+ def charge_credit_card(params)
12
+ params => { amount:, details: }
13
+
14
+ response = client.charge(amount:, payment_data: details, payment_method: 'credit_card')
15
+
16
+ Response.new(response.status == 'success')
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaymentGateways
4
+ class Contract < ::BCDD::Contract::Proxy
5
+ def charge_credit_card(params)
6
+ params => { amount: Numeric, details: Hash }
7
+
8
+ outcome = object.charge_credit_card(params)
9
+
10
+ outcome => Response[true | false]
11
+
12
+ outcome
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaymentGateways
4
+ Response = ::Struct.new(:success?)
5
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaymentGateways
4
+ require_relative 'payment_gateways/contract'
5
+ require_relative 'payment_gateways/response'
6
+
7
+ module Adapters
8
+ require_relative 'payment_gateways/adapters/circle_up'
9
+ require_relative 'payment_gateways/adapters/pay_friend'
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleUp
4
+ class Client
5
+ Resp = ::Struct.new(:ok?)
6
+
7
+ def charge_cc(_amount, _credit_card_data)
8
+ Resp.new(true)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PayFriend
4
+ class Client
5
+ APIResponse = ::Struct.new(:status)
6
+
7
+ def charge(amount:, payment_method:, payment_data:)
8
+ APIResponse.new('success')
9
+ end
10
+ end
11
+ end