solid-result 2.0.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 (119) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +98 -0
  3. data/.rubocop_todo.yml +12 -0
  4. data/CHANGELOG.md +600 -0
  5. data/CODE_OF_CONDUCT.md +84 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +2691 -0
  8. data/Rakefile +28 -0
  9. data/Steepfile +31 -0
  10. data/examples/multiple_listeners/Rakefile +55 -0
  11. data/examples/multiple_listeners/app/models/account/member.rb +10 -0
  12. data/examples/multiple_listeners/app/models/account/owner_creation.rb +62 -0
  13. data/examples/multiple_listeners/app/models/account.rb +11 -0
  14. data/examples/multiple_listeners/app/models/user/creation.rb +67 -0
  15. data/examples/multiple_listeners/app/models/user/token/creation.rb +51 -0
  16. data/examples/multiple_listeners/app/models/user/token.rb +7 -0
  17. data/examples/multiple_listeners/app/models/user.rb +15 -0
  18. data/examples/multiple_listeners/config/boot.rb +16 -0
  19. data/examples/multiple_listeners/config/initializers/solid_result.rb +9 -0
  20. data/examples/multiple_listeners/config.rb +27 -0
  21. data/examples/multiple_listeners/db/setup.rb +60 -0
  22. data/examples/multiple_listeners/lib/event_logs_listener/stdout.rb +60 -0
  23. data/examples/multiple_listeners/lib/runtime_breaker.rb +11 -0
  24. data/examples/multiple_listeners/lib/solid/result/event_logs_record.rb +27 -0
  25. data/examples/multiple_listeners/lib/solid/result/rollback_on_failure.rb +15 -0
  26. data/examples/service_objects/Rakefile +36 -0
  27. data/examples/service_objects/app/models/account/member.rb +10 -0
  28. data/examples/service_objects/app/models/account.rb +11 -0
  29. data/examples/service_objects/app/models/user/token.rb +7 -0
  30. data/examples/service_objects/app/models/user.rb +15 -0
  31. data/examples/service_objects/app/services/account/owner_creation.rb +47 -0
  32. data/examples/service_objects/app/services/application_service.rb +79 -0
  33. data/examples/service_objects/app/services/user/creation.rb +56 -0
  34. data/examples/service_objects/app/services/user/token/creation.rb +37 -0
  35. data/examples/service_objects/config/boot.rb +17 -0
  36. data/examples/service_objects/config/initializers/solid_result.rb +9 -0
  37. data/examples/service_objects/config.rb +20 -0
  38. data/examples/service_objects/db/setup.rb +49 -0
  39. data/examples/single_listener/Rakefile +92 -0
  40. data/examples/single_listener/app/models/account/member.rb +10 -0
  41. data/examples/single_listener/app/models/account/owner_creation.rb +62 -0
  42. data/examples/single_listener/app/models/account.rb +11 -0
  43. data/examples/single_listener/app/models/user/creation.rb +67 -0
  44. data/examples/single_listener/app/models/user/token/creation.rb +51 -0
  45. data/examples/single_listener/app/models/user/token.rb +7 -0
  46. data/examples/single_listener/app/models/user.rb +15 -0
  47. data/examples/single_listener/config/boot.rb +16 -0
  48. data/examples/single_listener/config/initializers/solid_result.rb +9 -0
  49. data/examples/single_listener/config.rb +23 -0
  50. data/examples/single_listener/db/setup.rb +49 -0
  51. data/examples/single_listener/lib/runtime_breaker.rb +11 -0
  52. data/examples/single_listener/lib/single_event_logs_listener.rb +117 -0
  53. data/examples/single_listener/lib/solid/result/rollback_on_failure.rb +15 -0
  54. data/lib/solid/failure.rb +23 -0
  55. data/lib/solid/output/callable_and_then.rb +40 -0
  56. data/lib/solid/output/expectations/mixin.rb +31 -0
  57. data/lib/solid/output/expectations.rb +25 -0
  58. data/lib/solid/output/failure.rb +9 -0
  59. data/lib/solid/output/mixin.rb +57 -0
  60. data/lib/solid/output/success.rb +37 -0
  61. data/lib/solid/output.rb +115 -0
  62. data/lib/solid/result/_self.rb +198 -0
  63. data/lib/solid/result/callable_and_then/caller.rb +49 -0
  64. data/lib/solid/result/callable_and_then/config.rb +15 -0
  65. data/lib/solid/result/callable_and_then/error.rb +11 -0
  66. data/lib/solid/result/callable_and_then.rb +9 -0
  67. data/lib/solid/result/config/options.rb +27 -0
  68. data/lib/solid/result/config/switcher.rb +82 -0
  69. data/lib/solid/result/config/switchers/addons.rb +25 -0
  70. data/lib/solid/result/config/switchers/constant_aliases.rb +33 -0
  71. data/lib/solid/result/config/switchers/features.rb +32 -0
  72. data/lib/solid/result/config/switchers/pattern_matching.rb +20 -0
  73. data/lib/solid/result/config.rb +64 -0
  74. data/lib/solid/result/contract/disabled.rb +25 -0
  75. data/lib/solid/result/contract/error.rb +17 -0
  76. data/lib/solid/result/contract/evaluator.rb +45 -0
  77. data/lib/solid/result/contract/for_types.rb +29 -0
  78. data/lib/solid/result/contract/for_types_and_values.rb +46 -0
  79. data/lib/solid/result/contract/interface.rb +21 -0
  80. data/lib/solid/result/contract/type_checker.rb +37 -0
  81. data/lib/solid/result/contract.rb +33 -0
  82. data/lib/solid/result/data.rb +33 -0
  83. data/lib/solid/result/error.rb +59 -0
  84. data/lib/solid/result/event_logs/config.rb +28 -0
  85. data/lib/solid/result/event_logs/listener.rb +51 -0
  86. data/lib/solid/result/event_logs/listeners.rb +87 -0
  87. data/lib/solid/result/event_logs/tracking/disabled.rb +15 -0
  88. data/lib/solid/result/event_logs/tracking/enabled.rb +161 -0
  89. data/lib/solid/result/event_logs/tracking.rb +26 -0
  90. data/lib/solid/result/event_logs/tree.rb +141 -0
  91. data/lib/solid/result/event_logs.rb +27 -0
  92. data/lib/solid/result/expectations/mixin.rb +58 -0
  93. data/lib/solid/result/expectations.rb +75 -0
  94. data/lib/solid/result/failure.rb +11 -0
  95. data/lib/solid/result/handler/allowed_types.rb +45 -0
  96. data/lib/solid/result/handler.rb +57 -0
  97. data/lib/solid/result/ignored_types.rb +14 -0
  98. data/lib/solid/result/mixin.rb +72 -0
  99. data/lib/solid/result/success.rb +11 -0
  100. data/lib/solid/result/version.rb +7 -0
  101. data/lib/solid/result.rb +27 -0
  102. data/lib/solid/success.rb +23 -0
  103. data/lib/solid-result.rb +3 -0
  104. data/sig/solid/failure.rbs +13 -0
  105. data/sig/solid/output.rbs +175 -0
  106. data/sig/solid/result/callable_and_then.rbs +60 -0
  107. data/sig/solid/result/config.rbs +102 -0
  108. data/sig/solid/result/contract.rbs +120 -0
  109. data/sig/solid/result/data.rbs +16 -0
  110. data/sig/solid/result/error.rbs +34 -0
  111. data/sig/solid/result/event_logs.rbs +189 -0
  112. data/sig/solid/result/expectations.rbs +71 -0
  113. data/sig/solid/result/handler.rbs +47 -0
  114. data/sig/solid/result/ignored_types.rbs +9 -0
  115. data/sig/solid/result/mixin.rbs +45 -0
  116. data/sig/solid/result/version.rbs +5 -0
  117. data/sig/solid/result.rbs +85 -0
  118. data/sig/solid/success.rbs +13 -0
  119. metadata +167 -0
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Account
4
+ class OwnerCreation < ApplicationService
5
+ input do
6
+ attribute :uuid, :string, default: -> { ::SecureRandom.uuid }
7
+ attribute :owner
8
+
9
+ before_validation do |input|
10
+ input.uuid = input.uuid.strip.downcase
11
+ end
12
+
13
+ validates :uuid, presence: true, format: { with: /\A[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}\z/i }
14
+ validates :owner, presence: true, type: ::Hash
15
+ end
16
+
17
+ def call(attributes)
18
+ rollback_on_failure {
19
+ Given(attributes)
20
+ .and_then(:create_owner)
21
+ .and_then(:create_account)
22
+ .and_then(:link_owner_to_account)
23
+ }.and_expose(:account_owner_created, %i[user account])
24
+ end
25
+
26
+ private
27
+
28
+ def create_owner(owner:, **)
29
+ ::User::Creation.call(owner).handle do |on|
30
+ on.success { |output| Continue(user: output[:user], token: output[:token]) }
31
+ on.failure { |output| Failure(:invalid_owner, **output) }
32
+ end
33
+ end
34
+
35
+ def create_account(uuid:, **)
36
+ account = ::Account.create(uuid:)
37
+
38
+ account.persisted? ? Continue(account:) : Failure(:invalid_record, **account.errors.messages)
39
+ end
40
+
41
+ def link_owner_to_account(account:, user:, **)
42
+ Member.create!(account:, user:, role: :owner)
43
+
44
+ Continue()
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationService
4
+ Error = ::Class.new(::StandardError)
5
+
6
+ class Input
7
+ def self.inherited(subclass)
8
+ subclass.include ::ActiveModel::API
9
+ subclass.include ::ActiveModel::Attributes
10
+ subclass.include ::ActiveModel::Dirty
11
+ subclass.include ::ActiveModel::Validations::Callbacks
12
+ end
13
+ end
14
+
15
+ class << self
16
+ def input=(klass)
17
+ const_defined?(:Input, false) and raise ArgumentError, "#{self}::Input class already defined"
18
+
19
+ unless klass.is_a?(::Class) && klass < Input
20
+ raise ArgumentError, 'must be a ApplicationService::Input subclass'
21
+ end
22
+
23
+ const_set(:Input, klass)
24
+ end
25
+
26
+ def input(&block)
27
+ return const_get(:Input, false) if const_defined?(:Input, false)
28
+
29
+ klass = ::Class.new(Input)
30
+ klass.class_eval(&block)
31
+
32
+ self.input = klass
33
+ end
34
+
35
+ def inherited(subclass)
36
+ subclass.include ::Solid::Output.mixin(config: { addon: { continue: true } })
37
+ end
38
+
39
+ def call(arg)
40
+ new(input.new(arg)).call!
41
+ end
42
+ end
43
+
44
+ private_class_method :new
45
+
46
+ attr_reader :input
47
+
48
+ def initialize(input)
49
+ @input = input
50
+ end
51
+
52
+ def call!
53
+ ::Solid::Result.event_logs(name: self.class.name) do
54
+ if input.invalid?
55
+ Failure(:invalid_input, input: input)
56
+ else
57
+ call(input.attributes.deep_symbolize_keys)
58
+ end
59
+ end
60
+ end
61
+
62
+ def call(attributes)
63
+ raise Error, 'must be implemented in a subclass'
64
+ end
65
+
66
+ private
67
+
68
+ def rollback_on_failure(model: ::ActiveRecord::Base)
69
+ result = nil
70
+
71
+ model.transaction do
72
+ result = yield
73
+
74
+ raise ::ActiveRecord::Rollback if result.failure?
75
+ end
76
+
77
+ result
78
+ end
79
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User
4
+ class Creation < ApplicationService
5
+ input do
6
+ attribute :uuid, :string, default: -> { ::SecureRandom.uuid }
7
+ attribute :name, :string
8
+ attribute :email, :string
9
+ attribute :password, :string
10
+ attribute :password_confirmation, :string
11
+
12
+ before_validation do |input|
13
+ input.uuid = input.uuid.strip.downcase
14
+ input.name = input.name.strip.gsub(/\s+/, ' ')
15
+ input.email = input.email.strip.downcase
16
+ end
17
+
18
+ validates :uuid, presence: true, format: { with: /\A[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}\z/i }
19
+ validates :name, presence: true
20
+ validates :email, presence: true, format: { with: ::URI::MailTo::EMAIL_REGEXP }
21
+ validates :password, :password_confirmation, presence: true
22
+ end
23
+
24
+ def call(attributes)
25
+ Given(attributes)
26
+ .and_then(:validate_email_uniqueness)
27
+ .then { |result|
28
+ rollback_on_failure {
29
+ result
30
+ .and_then(:create_user)
31
+ .and_then(:create_user_token)
32
+ }
33
+ }
34
+ .and_expose(:user_created, %i[user token])
35
+ end
36
+
37
+ private
38
+
39
+ def validate_email_uniqueness(email:, **)
40
+ ::User.exists?(email:) ? Failure(:email_already_taken) : Continue()
41
+ end
42
+
43
+ def create_user(uuid:, name:, email:, password:, password_confirmation:)
44
+ user = ::User.create(uuid:, name:, email:, password:, password_confirmation:)
45
+
46
+ user.persisted? ? Continue(user:) : Failure(:invalid_record, **user.errors.messages)
47
+ end
48
+
49
+ def create_user_token(user:, **)
50
+ Token::Creation.call(user: user).handle do |on|
51
+ on.success { |output| Continue(token: output[:token]) }
52
+ on.failure { raise 'Token creation failed' }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User::Token
4
+ class Creation < ApplicationService
5
+ input do
6
+ attribute :user
7
+ attribute :executed_at, :time, default: -> { ::Time.current }
8
+
9
+ validates :user, presence: true, type: ::User
10
+ validates :executed_at, presence: true
11
+ end
12
+
13
+ def call(attributes)
14
+ Given(attributes)
15
+ .and_then(:validate_token_existence)
16
+ .and_then(:create_token)
17
+ .and_expose(:token_created, %i[token])
18
+ end
19
+
20
+ private
21
+
22
+ def validate_token_existence(user:, **)
23
+ user.token.nil? ? Continue() : Failure(:token_already_exists)
24
+ end
25
+
26
+ def create_token(user:, executed_at:, **)
27
+ token = user.create_token(
28
+ access_token: ::SecureRandom.hex(24),
29
+ refresh_token: ::SecureRandom.hex(24),
30
+ access_token_expires_at: executed_at + 15.days,
31
+ refresh_token_expires_at: executed_at + 30.days
32
+ )
33
+
34
+ token.persisted? ? Continue(token:) : Failure(:token_creation_failed, **token.errors.messages)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,17 @@
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 'sqlite3', '~> 1.7'
11
+ gem 'bcrypt', '~> 3.1.20'
12
+ gem 'activerecord', '~> 7.1', '>= 7.1.3', require: 'active_record'
13
+ gem 'type_validator'
14
+ gem 'solid-result', path: '../../'
15
+ end
16
+
17
+ require 'active_support/all'
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ Solid::Result.config.then do |config|
4
+ config.addon.enable!(:continue)
5
+
6
+ config.pattern_matching.disable!(:nil_as_valid_value_checking)
7
+
8
+ # config.feature.disable!(:expectations) if Rails.env.production?
9
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/inline'
4
+
5
+ $LOAD_PATH.unshift(__dir__)
6
+
7
+ require_relative 'config/boot'
8
+ require_relative 'config/initializers/solid_result'
9
+
10
+ require 'db/setup'
11
+
12
+ require 'app/models/account'
13
+ require 'app/models/account/member'
14
+ require 'app/models/user'
15
+ require 'app/models/user/token'
16
+
17
+ require 'app/services/application_service'
18
+ require 'app/services/account/owner_creation'
19
+ require 'app/services/user/token/creation'
20
+ require 'app/services/user/creation'
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/all'
4
+
5
+ ActiveRecord::Base.establish_connection(
6
+ host: 'localhost',
7
+ adapter: 'sqlite3',
8
+ database: ':memory:'
9
+ )
10
+
11
+ ActiveRecord::Schema.define do
12
+ suppress_messages do
13
+ create_table :accounts do |t|
14
+ t.string :uuid, null: false, index: {unique: true}
15
+
16
+ t.timestamps
17
+ end
18
+
19
+ create_table :users do |t|
20
+ t.string :uuid, null: false, index: {unique: true}
21
+ t.string :name, null: false
22
+ t.string :email, null: false, index: {unique: true}
23
+ t.string :password_digest, null: false
24
+
25
+ t.timestamps
26
+ end
27
+
28
+ create_table :user_tokens do |t|
29
+ t.belongs_to :user, null: false, foreign_key: true, index: true
30
+ t.string :access_token, null: false
31
+ t.string :refresh_token, null: false
32
+ t.datetime :access_token_expires_at, null: false
33
+ t.datetime :refresh_token_expires_at, null: false
34
+
35
+ t.timestamps
36
+ end
37
+
38
+ create_table :account_members do |t|
39
+ t.integer :role, null: false, default: 0
40
+ t.belongs_to :user, null: false, foreign_key: true, index: true
41
+ t.belongs_to :account, null: false, foreign_key: true, index: true
42
+
43
+ t.timestamps
44
+
45
+ t.index %i[account_id role], unique: true, where: "(role = 0)"
46
+ t.index %i[account_id user_id], unique: true
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,92 @@
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
+ # Usage:
9
+ #
10
+ # rake DISABLE_EVENT_LOGS=t
11
+ # rake DISABLE_LISTENER=t
12
+ #
13
+ # rake HIDE_GIVEN_AND_CONTINUE=t
14
+ #
15
+ # rake BREAK_ACCOUNT_CREATION=t
16
+ # rake BREAK_USER_CREATION=t
17
+ # rake BREAK_USER_TOKEN_CREATION=t
18
+ #
19
+ # rake BREAK_ACCOUNT_CREATION=t HIDE_GIVEN_AND_CONTINUE=t
20
+ task default: %i[solid_result_event_logs]
21
+
22
+ task :config do
23
+ require_relative 'config'
24
+ end
25
+
26
+ desc 'creates an account and an owner user through Solid::Result'
27
+ task solid_result_event_logs: %i[config] do
28
+ Solid::Result.configuration do |config|
29
+ config.feature.disable!(:event_logs) if ENV['DISABLE_EVENT_LOGS']
30
+
31
+ config.event_logs.listener = SingleEventLogsListener unless ENV['DISABLE_LISTENER']
32
+ end
33
+
34
+ result = nil
35
+
36
+ bench = Benchmark.measure do
37
+ result = Account::OwnerCreation.new.call(
38
+ owner: {
39
+ name: "\tJohn Doe \n",
40
+ email: ' JOHN.doe@email.com',
41
+ password: '123123123',
42
+ password_confirmation: '123123123'
43
+ }
44
+ )
45
+ rescue RuntimeBreaker::Interruption => e
46
+ nil
47
+ end
48
+
49
+ puts "\nBenchmark: #{bench}"
50
+ end
51
+
52
+ desc 'creates an account and an owner user directly through ActiveRecord'
53
+ task raw_active_record: %i[config] do
54
+ require_relative 'config'
55
+
56
+ result = nil
57
+
58
+ bench = Benchmark.measure do
59
+ email = 'john.doe@email.com'
60
+
61
+ ActiveRecord::Base.transaction do
62
+ User.exists?(email:) and raise "User with email #{email} already exists"
63
+
64
+ user = User.create!(
65
+ uuid: ::SecureRandom.uuid,
66
+ name: 'John Doe',
67
+ email:,
68
+ password: '123123123',
69
+ password_confirmation: '123123123'
70
+ )
71
+
72
+ executed_at = ::Time.current
73
+
74
+ user.token.nil? or raise "User with email #{email} already has a token"
75
+
76
+ user.create_token!(
77
+ access_token: ::SecureRandom.hex(24),
78
+ refresh_token: ::SecureRandom.hex(24),
79
+ access_token_expires_at: executed_at + 15.days,
80
+ refresh_token_expires_at: executed_at + 30.days
81
+ )
82
+
83
+ account = Account.create!(uuid: ::SecureRandom.uuid)
84
+
85
+ Account::Member.create!(account: account, user: user, role: :owner)
86
+
87
+ result = { account: account, user: user }
88
+ end
89
+ end
90
+
91
+ puts "\nBenchmark: #{bench}"
92
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Account::Member < ActiveRecord::Base
4
+ self.table_name = 'account_members'
5
+
6
+ enum role: { owner: 0, admin: 1, contributor: 2 }
7
+
8
+ belongs_to :user, inverse_of: :memberships
9
+ belongs_to :account, inverse_of: :memberships
10
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Account
4
+ class OwnerCreation
5
+ include Solid::Output.mixin
6
+ include Solid::Result::RollbackOnFailure
7
+
8
+ def call(**input)
9
+ Solid::Result.event_logs(name: self.class.name) do
10
+ Given(input)
11
+ .and_then(:normalize_input)
12
+ .and_then(:validate_input)
13
+ .then { |result|
14
+ rollback_on_failure {
15
+ result
16
+ .and_then(:create_owner)
17
+ .and_then(:create_account)
18
+ .and_then(:link_owner_to_account)
19
+ }
20
+ }.and_expose(:account_owner_created, %i[user account])
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def normalize_input(**options)
27
+ uuid = String(options.fetch(:uuid) { ::SecureRandom.uuid }).strip.downcase
28
+
29
+ Continue(uuid:)
30
+ end
31
+
32
+ def validate_input(uuid:, owner:)
33
+ err = ::Hash.new { |hash, key| hash[key] = [] }
34
+
35
+ err[:uuid] << 'must be an UUID' unless uuid.match?(/\A[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}\z/i)
36
+ err[:owner] << 'must be a Hash' unless owner.is_a?(::Hash)
37
+
38
+ err.empty? ? Continue() : Failure(:invalid_input, **err)
39
+ end
40
+
41
+ def create_owner(owner:, **)
42
+ ::User::Creation.new.call(**owner).handle do |on|
43
+ on.success { |output| Continue(user: { record: output[:user], token: output[:token] }) }
44
+ on.failure { |output| Failure(:invalid_owner, **output) }
45
+ end
46
+ end
47
+
48
+ def create_account(uuid:, **)
49
+ ::RuntimeBreaker.try_to_interrupt(env: 'BREAK_ACCOUNT_CREATION')
50
+
51
+ account = ::Account.create(uuid:)
52
+
53
+ account.persisted? ? Continue(account:) : Failure(:invalid_record, **account.errors.messages)
54
+ end
55
+
56
+ def link_owner_to_account(account:, user:, **)
57
+ Member.create!(account:, user: user.fetch(:record), role: :owner)
58
+
59
+ Continue()
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Account < ActiveRecord::Base
4
+ has_many :memberships, inverse_of: :account, dependent: :destroy, class_name: '::Account::Member'
5
+ has_many :users, through: :memberships, inverse_of: :accounts
6
+
7
+ where_ownership = -> { where(account_members: {role: :owner}) }
8
+
9
+ has_one :ownership, where_ownership, dependent: nil, inverse_of: :account, class_name: '::Account::Member'
10
+ has_one :owner, through: :ownership, source: :user
11
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User
4
+ class Creation
5
+ include Solid::Output.mixin
6
+ include Solid::Result::RollbackOnFailure
7
+
8
+ def call(**input)
9
+ Solid::Result.event_logs(name: self.class.name) do
10
+ Given(input)
11
+ .and_then(:normalize_input)
12
+ .and_then(:validate_input)
13
+ .and_then(:validate_email_uniqueness)
14
+ .then { |result|
15
+ rollback_on_failure {
16
+ result
17
+ .and_then(:create_user)
18
+ .and_then(:create_user_token)
19
+ }
20
+ }
21
+ .and_expose(:user_created, %i[user token])
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def normalize_input(name:, email:, **options)
28
+ name = String(name).strip.gsub(/\s+/, ' ')
29
+ email = String(email).strip.downcase
30
+
31
+ uuid = String(options.fetch(:uuid) { ::SecureRandom.uuid }).strip.downcase
32
+
33
+ Continue(uuid:, name:, email:)
34
+ end
35
+
36
+ def validate_input(uuid:, name:, email:, password:, password_confirmation:)
37
+ err = ::Hash.new { |hash, key| hash[key] = [] }
38
+
39
+ err[:uuid] << 'must be an UUID' unless uuid.match?(/\A[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}\z/i)
40
+ err[:name] << 'must be present' if name.blank?
41
+ err[:email] << 'must be email' unless email.match?(::URI::MailTo::EMAIL_REGEXP)
42
+ err[:password] << 'must be present' if password.blank?
43
+ err[:password_confirmation] << 'must be present' if password_confirmation.blank?
44
+
45
+ err.empty? ? Continue() : Failure(:invalid_input, **err)
46
+ end
47
+
48
+ def validate_email_uniqueness(email:, **)
49
+ ::User.exists?(email:) ? Failure(:email_already_taken) : Continue()
50
+ end
51
+
52
+ def create_user(uuid:, name:, email:, password:, password_confirmation:)
53
+ ::RuntimeBreaker.try_to_interrupt(env: 'BREAK_USER_CREATION')
54
+
55
+ user = ::User.create(uuid:, name:, email:, password:, password_confirmation:)
56
+
57
+ user.persisted? ? Continue(user:) : Failure(:invalid_record, **user.errors.messages)
58
+ end
59
+
60
+ def create_user_token(user:, **)
61
+ Token::Creation.new.call(user: user).handle do |on|
62
+ on.success { |output| Continue(token: output[:token]) }
63
+ on.failure { raise 'Token creation failed' }
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User::Token
4
+ class Creation
5
+ include Solid::Output.mixin
6
+
7
+ def call(**input)
8
+ Solid::Result.event_logs(name: self.class.name) do
9
+ Given(input)
10
+ .and_then(:normalize_input)
11
+ .and_then(:validate_input)
12
+ .and_then(:validate_token_existence)
13
+ .and_then(:create_token)
14
+ .and_expose(:token_created, %i[token])
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def normalize_input(**options)
21
+ Continue(executed_at: options.fetch(:executed_at) { ::Time.current })
22
+ end
23
+
24
+ def validate_input(user:, executed_at:)
25
+ err = ::Hash.new { |hash, key| hash[key] = [] }
26
+
27
+ err[:user] << 'must be a User' unless user.is_a?(::User)
28
+ err[:user] << 'must be persisted' unless user.try(:persisted?)
29
+ err[:executed_at] << 'must be a time' unless executed_at.is_a?(::Time)
30
+
31
+ err.empty? ? Continue() : Failure(:invalid_user, **err)
32
+ end
33
+
34
+ def validate_token_existence(user:, **)
35
+ user.token.nil? ? Continue() : Failure(:token_already_exists)
36
+ end
37
+
38
+ def create_token(user:, executed_at:, **)
39
+ ::RuntimeBreaker.try_to_interrupt(env: 'BREAK_USER_TOKEN_CREATION')
40
+
41
+ token = user.create_token(
42
+ access_token: ::SecureRandom.hex(24),
43
+ refresh_token: ::SecureRandom.hex(24),
44
+ access_token_expires_at: executed_at + 15.days,
45
+ refresh_token_expires_at: executed_at + 30.days
46
+ )
47
+
48
+ token.persisted? ? Continue(token:) : Failure(:token_creation_failed, **token.errors.messages)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User::Token < ActiveRecord::Base
4
+ self.table_name = 'user_tokens'
5
+
6
+ belongs_to :user, inverse_of: :token
7
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User < ActiveRecord::Base
4
+ has_secure_password
5
+
6
+ has_many :memberships, inverse_of: :user, dependent: :destroy, class_name: '::Account::Member'
7
+ has_many :accounts, through: :memberships, inverse_of: :users
8
+
9
+ where_ownership = -> { where(account_members: { role: :owner }) }
10
+
11
+ has_one :ownership, where_ownership, inverse_of: :user, class_name: '::Account::Member'
12
+ has_one :account, through: :ownership, inverse_of: :owner
13
+
14
+ has_one :token, inverse_of: :user, dependent: :destroy, class_name: '::User::Token'
15
+ end
@@ -0,0 +1,16 @@
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 'sqlite3', '~> 1.7'
11
+ gem 'bcrypt', '~> 3.1.20'
12
+ gem 'activerecord', '~> 7.1', '>= 7.1.3', require: 'active_record'
13
+ gem 'solid-result', path: '../../'
14
+ end
15
+
16
+ require 'active_support/all'
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ Solid::Result.config.then do |config|
4
+ config.addon.enable!(:continue)
5
+
6
+ config.pattern_matching.disable!(:nil_as_valid_value_checking)
7
+
8
+ # config.feature.disable!(:expectations) if Rails.env.production?
9
+ end