rider-kick 0.0.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-09-01
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 kotarominami
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # RiderKick
2
+ This gem provides helper interfaces and classes to assist in the construction of application with
3
+ Clean Architecture, as described in [Robert Martin's seminal book](https://www.amazon.com/gp/product/0134494164).
4
+
5
+
6
+ ## Installation
7
+
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem 'rider-kick'
12
+ ```
13
+
14
+ And then execute:
15
+
16
+ $ bundle install
17
+ $ bundle binstubs rider-kick
18
+
19
+ ## Philosophy
20
+
21
+ The intention of this gem is to help you build applications that are built from the use case down,
22
+ and decisions about I/O can be deferred until the last possible moment.
23
+
24
+ ## Clean Architecture
25
+ This structure provides helper interfaces and classes to assist in the construction of application with Clean Architecture, as described in Robert Martin's seminal book.
26
+
27
+ ```
28
+ - app
29
+ - services
30
+ - api
31
+ - ...
32
+ - domains
33
+ - entities (Contract Response)
34
+ - builder
35
+ - repositories (Business logic)
36
+ - use_cases (Just Usecase)
37
+ - utils (Class Reusable)
38
+ ```
39
+ ## Screaming architecture - use cases as an organisational principle
40
+ Uncle Bob suggests that your source code organisation should allow developers to easily find a listing of all use cases your application provides. Here's an example of how this might look in a this application.
41
+ ```
42
+ - app
43
+ - domains
44
+ - core
45
+ ...
46
+ - usecase
47
+ - retail_customer_opens_bank_account.rb
48
+ - retail_customer_makes_deposit.rb
49
+ - ...
50
+ ```
51
+ Note that the use case name contains:
52
+
53
+ - the user role
54
+ - the action
55
+ - the (sometimes implied) subject
56
+ ```ruby
57
+ [user role][action][subject].rb
58
+ # retail_customer_opens_bank_account.rb
59
+ # admin_fetch_info.rb [specific usecase]
60
+ # fetch_info.rb [generic usecase] every role can access it
61
+ ```
62
+
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/lib/rider-kick.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rider_kick/entities/failure_details'
4
+ require 'rider_kick/builders/abstract_active_record_entity_builder'
5
+ require 'rider_kick/matchers/use_case_result'
6
+ require 'rider_kick/use_cases/abstract_use_case'
7
+ require 'rider_kick/use_cases/contract'
8
+
9
+ require 'rider_kick/types'
10
+ require 'rider_kick/version'
11
+
12
+ module RiderKick
13
+ class Error < StandardError; end
14
+ end
@@ -0,0 +1,124 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module RiderKick
5
+ module Builders
6
+ # Helps to take an instance of an AR model and wrap it up in the given Entity
7
+ # Any columns from the AR model that do not directly map to an attribute on the Entity
8
+ # can be specified by overriding #attributes_for_entity.
9
+ class AbstractActiveRecordEntityBuilder
10
+ # @param [Class] A Dry::Struct based entity that this builder will construct instances of
11
+ def self.acts_as_builder_for_entity(entity_class)
12
+ @has_many_builders = []
13
+ @belongs_to_builders = []
14
+
15
+ define_singleton_method :has_many_builders do
16
+ @has_many_builders
17
+ end
18
+
19
+ define_singleton_method :belongs_to_builders do
20
+ @belongs_to_builders
21
+ end
22
+
23
+ define_method :entity_class do
24
+ entity_class
25
+ end
26
+
27
+ private :entity_class
28
+ end
29
+
30
+ def self.has_many(relation_name, use:)
31
+ @has_many_builders << [relation_name, use]
32
+ end
33
+
34
+ def self.belongs_to(relation_name, use:)
35
+ @belongs_to_builders << [relation_name, use]
36
+ end
37
+
38
+ # @param [ActiveRecord::Base] An ActiveRecord model to map to the entity
39
+ def initialize(params)
40
+ @params = params
41
+ end
42
+
43
+ def build
44
+ entity_class.new(all_attributes_for_entity)
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :params
50
+
51
+ def entity_attribute_names
52
+ @entity_attributes ||= begin
53
+ if entity_class.respond_to?(:schema) # Dry::Struct
54
+ schema_keys = entity_class.schema.keys
55
+ elsif entity_class.respond_to?(:decorator) # T::Struct
56
+ schema_keys = entity_class.decorator.props.keys
57
+ else
58
+ raise 'Cannot determine schema format'
59
+ end
60
+ first_key = schema_keys.first
61
+ if first_key.is_a?(Symbol)
62
+ schema_keys
63
+ elsif first_key.respond_to?(:name)
64
+ schema_keys.map(&:name)
65
+ else
66
+ raise 'Cannot determine schema format'
67
+ end
68
+ end
69
+ end
70
+
71
+ def params_attributes
72
+ @params_attributes ||= @params.attributes
73
+ end
74
+
75
+ def symbolized_params_attributes
76
+ @symbolized_params_attributes ||= Hash[
77
+ params_attributes.map { |(key, value)| [key.to_sym, value] }
78
+ ]
79
+ end
80
+
81
+ def ar_attributes_for_entity
82
+ symbolized_params_attributes.slice(*entity_attribute_names)
83
+ end
84
+
85
+ def attributes_for_belongs_to_relations
86
+ self.class.belongs_to_builders.map do |belongs_to_builder_config|
87
+ relation_name, builder_class = belongs_to_builder_config
88
+ relation = @params.public_send(relation_name)
89
+
90
+ [
91
+ relation_name,
92
+ relation ? builder_class.new(relation).build : nil
93
+ ]
94
+ end.to_h
95
+ end
96
+
97
+ def attributes_for_has_many_relations
98
+ self.class.has_many_builders.map do |has_many_builder_config|
99
+ relation_name, builder_class = has_many_builder_config
100
+ relations = @params.public_send(relation_name)
101
+ built_relations = relations.map do |relation|
102
+ builder_class.new(relation).build
103
+ end
104
+
105
+ [
106
+ relation_name,
107
+ built_relations
108
+ ]
109
+ end.to_h
110
+ end
111
+
112
+ def attributes_for_entity
113
+ {}
114
+ end
115
+
116
+ def all_attributes_for_entity
117
+ ar_attributes_for_entity
118
+ .merge(attributes_for_belongs_to_relations)
119
+ .merge(attributes_for_has_many_relations)
120
+ .merge(attributes_for_entity)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,30 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require 'rider_kick/types'
5
+ require 'dry/struct'
6
+
7
+ module RiderKick
8
+ module Entities
9
+ class FailureDetails < Dry::Struct
10
+ failure_types = Types::Strict::String.enum(
11
+ 'error',
12
+ 'expectation_failed',
13
+ 'not_found',
14
+ 'unauthorized',
15
+ 'unprocessable_entity'
16
+ )
17
+ attribute :type, failure_types
18
+ attribute :message, Types::Strict::String
19
+ attribute :other_properties, Types::Strict::Hash.default({}.freeze)
20
+
21
+ def self.from_array(array)
22
+ new(message: 'failure 1, failure 2', other_properties: {}, type: 'error')
23
+ end
24
+
25
+ def self.from_string(string)
26
+ new(message: string, other_properties: {}, type: 'error')
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,51 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require 'rider_kick/entities/failure_details'
5
+ require 'dry-matcher'
6
+ require 'dry/monads/all'
7
+
8
+ module RiderKick
9
+ module Matchers
10
+ class UseCaseResult
11
+ def self.call(result, &block)
12
+ new.matcher.call(result, &block)
13
+ end
14
+
15
+ def matcher
16
+ Dry::Matcher.new(success: success_case, failure: failure_case)
17
+ end
18
+
19
+ private
20
+
21
+ def success_case
22
+ Dry::Matcher::Case.new(
23
+ match: ->(value) { value.is_a?(Dry::Monads::Success) },
24
+ resolve: ->(value) { value.value! }
25
+ )
26
+ end
27
+
28
+ def failure_case
29
+ Dry::Matcher::Case.new(
30
+ match: ->(value) { value.is_a?(Dry::Monads::Failure) },
31
+ resolve: ->(value) { resolve_failure_value(value) }
32
+ )
33
+ end
34
+
35
+ def resolve_failure_value(value)
36
+ failure = value.failure
37
+ case failure
38
+ when Array
39
+ Entities::FailureDetails.from_array(failure)
40
+ when String
41
+ Entities::FailureDetails.from_string(failure)
42
+ when Entities::FailureDetails
43
+ failure
44
+ else
45
+ type_list = [Array, String, Entities::FailureDetails].map(&:to_s).join(' or ')
46
+ raise ArgumentError, "Unexpected failure value - must be #{type_list}"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rider_kick/matchers/use_case_result'
4
+
5
+ RSpec.describe RiderKick::Matchers::UseCaseResult do
6
+ describe '.call' do
7
+ subject(:call) do
8
+ described_class.call(result) do |matcher|
9
+ matcher.success(&:to_s)
10
+ matcher.failure { |failure_details| failure_details }
11
+ end
12
+ end
13
+
14
+ context do
15
+ let(:result) { Dry::Monads::Success('success!') }
16
+
17
+ it { is_expected.to eq 'success!' }
18
+ end
19
+
20
+ context do
21
+ let(:result) { Dry::Monads::Failure('failure!') }
22
+
23
+ specify do
24
+ expect(call).to eq RiderKick::Entities::FailureDetails.new(
25
+ message: 'failure!',
26
+ other_properties: {},
27
+ type: 'error'
28
+ )
29
+ end
30
+ end
31
+
32
+ context do
33
+ let(:result) { Dry::Monads::Failure(['failure 1', 'failure 2']) }
34
+
35
+ specify do
36
+ expect(call).to eq RiderKick::Entities::FailureDetails.new(
37
+ message: 'failure 1, failure 2',
38
+ other_properties: {},
39
+ type: 'error'
40
+ )
41
+ end
42
+ end
43
+
44
+ context do
45
+ let(:failure_details) do
46
+ RiderKick::Entities::FailureDetails.new(
47
+ message: 'failure!',
48
+ other_properties: { a: :b },
49
+ type: 'unauthorized'
50
+ )
51
+ end
52
+ let(:result) { Dry::Monads::Failure(failure_details) }
53
+
54
+ it { is_expected.to eq failure_details }
55
+ end
56
+
57
+ context do
58
+ let(:result) { Dry::Monads::Failure(:no) }
59
+
60
+ specify { expect { call }.to raise_error ArgumentError }
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,10 @@
1
+ # typed: ignore
2
+ # frozen_string_literal: true
3
+
4
+ require 'dry-types'
5
+
6
+ module Types
7
+ include Dry.Types()
8
+ # File = Types.Instance(::File) | Types.Instance(ActionDispatch::Http::UploadedFile)
9
+ # public_constant :File
10
+ end
@@ -0,0 +1,36 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require 'dry/monads/result'
5
+ require 'dry/validation'
6
+ require 'dry/matcher/result_matcher'
7
+ require 'rider_kick/use_cases/contract'
8
+
9
+ module RiderKick
10
+ module UseCases
11
+ class AbstractUseCase
12
+ include Dry::Monads[:result]
13
+
14
+ def self.contract(base_contract = Contract, &proc)
15
+ @contract ||= Class.new(base_contract, &proc)
16
+ end
17
+
18
+ def self.contract!(args)
19
+ context = args.fetch(:context, {})
20
+ @results = @contract.new(context).call(args)
21
+ end
22
+
23
+ def initialize(contract)
24
+ @contract = contract
25
+ end
26
+
27
+ def build_parameter!
28
+ if @contract.success?
29
+ Success(Hashie::Mash.new(@contract.to_h))
30
+ else
31
+ Failure(@contract.errors.to_h)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,9 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ module RiderKick
5
+ module UseCases
6
+ class Contract < Dry::Validation::Contract
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RiderKick
4
+ VERSION = '0.0.1'
5
+ public_constant :VERSION
6
+ end
@@ -0,0 +1,4 @@
1
+ module RiderKick
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,185 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rider-kick
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Kotaro Minami
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-09-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dry-matcher
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 1.0.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 1.0.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: dry-monads
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 1.6.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 1.6.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: dry-struct
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 1.6.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 1.6.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: dry-transaction
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 0.16.0
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 0.16.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: dry-types
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 1.7.2
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 1.7.2
83
+ - !ruby/object:Gem::Dependency
84
+ name: dry-validation
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 1.10.0
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 1.10.0
97
+ - !ruby/object:Gem::Dependency
98
+ name: bundler
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: 2.5.18
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: 2.5.18
111
+ - !ruby/object:Gem::Dependency
112
+ name: rake
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: 13.2.1
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: 13.2.1
125
+ - !ruby/object:Gem::Dependency
126
+ name: rspec
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: 3.13.0
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: 3.13.0
139
+ description: An attempt at building a reusable Clean Architecture framework for Ruby.
140
+ email:
141
+ - kotaroisme@gmail.com
142
+ executables: []
143
+ extensions: []
144
+ extra_rdoc_files: []
145
+ files:
146
+ - ".rspec"
147
+ - ".rubocop.yml"
148
+ - CHANGELOG.md
149
+ - LICENSE.txt
150
+ - README.md
151
+ - Rakefile
152
+ - lib/rider-kick.rb
153
+ - lib/rider_kick/builders/abstract_active_record_entity_builder.rb
154
+ - lib/rider_kick/entities/failure_details.rb
155
+ - lib/rider_kick/matchers/use_case_result.rb
156
+ - lib/rider_kick/matchers/use_case_result_spec.rb
157
+ - lib/rider_kick/types.rb
158
+ - lib/rider_kick/use_cases/abstract_use_case.rb
159
+ - lib/rider_kick/use_cases/contract.rb
160
+ - lib/rider_kick/version.rb
161
+ - sig/rider_kick.rbs
162
+ homepage: https://github.com/kotaroisme/rider-kick
163
+ licenses:
164
+ - MIT
165
+ metadata: {}
166
+ post_install_message:
167
+ rdoc_options: []
168
+ require_paths:
169
+ - lib
170
+ required_ruby_version: !ruby/object:Gem::Requirement
171
+ requirements:
172
+ - - ">="
173
+ - !ruby/object:Gem::Version
174
+ version: 3.0.0
175
+ required_rubygems_version: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - ">="
178
+ - !ruby/object:Gem::Version
179
+ version: '0'
180
+ requirements: []
181
+ rubygems_version: 3.5.18
182
+ signing_key:
183
+ specification_version: 4
184
+ summary: Clean Architecture Framework.
185
+ test_files: []