sorbet_operation 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '0654591b2ec0d42146c7942a293437e589d7665bb12cccea68851b7350f91379'
4
+ data.tar.gz: 90718cfed8a9f9ba88e5074f94fe4d337a31e94a8297ae679c8b0fdfa3bce89a
5
+ SHA512:
6
+ metadata.gz: e1dd98bdcdd68fbebc21d861892538310ee750fb23e255900cc7eb3eff1964b7d81e43d9675be30fcaa5c44ddc6f10737e2ad5a75d3c20b5fee4b21a0cb7dccd
7
+ data.tar.gz: e9f3b644e2a89d7482e8a98e1d9e3085e4e5709a89b9c2abb73de39a7478caf3a64ecef3818b0b0e0b964032999fe7b7666962da3d7b84f73f761d3c9673f951
data/.editorconfig ADDED
@@ -0,0 +1,12 @@
1
+ # EditorConfig is awesome: https://EditorConfig.org
2
+
3
+ # top-most EditorConfig file
4
+ root = true
5
+
6
+ [*]
7
+ indent_style = space
8
+ indent_size = 2
9
+ end_of_line = lf
10
+ charset = utf-8
11
+ trim_trailing_whitespace = true
12
+ insert_final_newline = true
data/.rubocop.yml ADDED
@@ -0,0 +1,20 @@
1
+ # Opinionated cops - see https://ruby-style-guide.shopify.dev/ for explanations
2
+ inherit_gem:
3
+ rubocop-shopify: rubocop.yml
4
+
5
+ require:
6
+ - rubocop-minitest
7
+ - rubocop-performance
8
+ - rubocop-rake
9
+ - rubocop-sorbet
10
+
11
+ AllCops:
12
+ NewCops: enable
13
+ SuggestExtensions: false
14
+ TargetRubyVersion: 3.0
15
+
16
+ Minitest/MultipleAssertions:
17
+ Enabled: false
18
+
19
+ Sorbet/StrictSigil:
20
+ Enabled: true
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.0.6
data/.yardopts ADDED
@@ -0,0 +1,3 @@
1
+ --markup markdown
2
+ --protected
3
+ --plugin sorbet
data/Gemfile ADDED
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in sorbet_operation.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ group :development, :test do
11
+ gem "pry"
12
+ gem "pry-byebug"
13
+ end
14
+
15
+ group :development do
16
+ gem "sorbet", "~> 0.5.10736"
17
+ gem "tapioca", require: false
18
+
19
+ gem "rubocop", require: false
20
+ gem "rubocop-minitest", require: false
21
+ gem "rubocop-performance", require: false
22
+ gem "rubocop-rake", require: false
23
+ gem "rubocop-shopify", require: false
24
+ gem "rubocop-sorbet", require: false
25
+
26
+ gem "bundler-audit", require: false
27
+ end
28
+
29
+ group :test do
30
+ gem "minitest", "~> 5.0"
31
+ gem "minitest-reporters", "~> 1.4"
32
+
33
+ gem "simplecov", require: false
34
+ end
35
+
36
+ group :docs do
37
+ gem "yard"
38
+ gem "yard-sorbet"
39
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,135 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ sorbet_operation (0.1.0)
5
+ sorbet-runtime (~> 0.5.10741)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ ansi (1.5.0)
11
+ ast (2.4.2)
12
+ builder (3.2.4)
13
+ bundler-audit (0.9.1)
14
+ bundler (>= 1.2.0, < 3)
15
+ thor (~> 1.0)
16
+ byebug (11.1.3)
17
+ coderay (1.1.3)
18
+ diff-lcs (1.5.0)
19
+ docile (1.4.0)
20
+ json (2.6.3)
21
+ method_source (1.0.0)
22
+ minitest (5.18.0)
23
+ minitest-reporters (1.6.0)
24
+ ansi
25
+ builder
26
+ minitest (>= 5.0)
27
+ ruby-progressbar
28
+ netrc (0.11.0)
29
+ parallel (1.22.1)
30
+ parser (3.2.2.0)
31
+ ast (~> 2.4.1)
32
+ pry (0.14.2)
33
+ coderay (~> 1.1)
34
+ method_source (~> 1.0)
35
+ pry-byebug (3.10.1)
36
+ byebug (~> 11.0)
37
+ pry (>= 0.13, < 0.15)
38
+ rainbow (3.1.1)
39
+ rake (13.0.6)
40
+ rbi (0.0.16)
41
+ ast
42
+ parser (>= 2.6.4.0)
43
+ sorbet-runtime (>= 0.5.9204)
44
+ unparser
45
+ regexp_parser (2.7.0)
46
+ rexml (3.2.5)
47
+ rubocop (1.48.1)
48
+ json (~> 2.3)
49
+ parallel (~> 1.10)
50
+ parser (>= 3.2.0.0)
51
+ rainbow (>= 2.2.2, < 4.0)
52
+ regexp_parser (>= 1.8, < 3.0)
53
+ rexml (>= 3.2.5, < 4.0)
54
+ rubocop-ast (>= 1.26.0, < 2.0)
55
+ ruby-progressbar (~> 1.7)
56
+ unicode-display_width (>= 2.4.0, < 3.0)
57
+ rubocop-ast (1.28.0)
58
+ parser (>= 3.2.1.0)
59
+ rubocop-minitest (0.29.0)
60
+ rubocop (>= 1.39, < 2.0)
61
+ rubocop-performance (1.16.0)
62
+ rubocop (>= 1.7.0, < 2.0)
63
+ rubocop-ast (>= 0.4.0)
64
+ rubocop-rake (0.6.0)
65
+ rubocop (~> 1.0)
66
+ rubocop-shopify (2.12.0)
67
+ rubocop (~> 1.44)
68
+ rubocop-sorbet (0.7.0)
69
+ rubocop (>= 0.90.0)
70
+ ruby-progressbar (1.13.0)
71
+ simplecov (0.22.0)
72
+ docile (~> 1.1)
73
+ simplecov-html (~> 0.11)
74
+ simplecov_json_formatter (~> 0.1)
75
+ simplecov-html (0.12.3)
76
+ simplecov_json_formatter (0.1.4)
77
+ sorbet (0.5.10741)
78
+ sorbet-static (= 0.5.10741)
79
+ sorbet-runtime (0.5.10741)
80
+ sorbet-static (0.5.10741-universal-darwin-22)
81
+ sorbet-static (0.5.10741-x86_64-linux)
82
+ sorbet-static-and-runtime (0.5.10741)
83
+ sorbet (= 0.5.10741)
84
+ sorbet-runtime (= 0.5.10741)
85
+ spoom (1.2.1)
86
+ sorbet (>= 0.5.10187)
87
+ sorbet-runtime (>= 0.5.9204)
88
+ thor (>= 0.19.2)
89
+ tapioca (0.11.4)
90
+ bundler (>= 2.2.25)
91
+ netrc (>= 0.11.0)
92
+ parallel (>= 1.21.0)
93
+ rbi (~> 0.0.0, >= 0.0.16)
94
+ sorbet-static-and-runtime (>= 0.5.10187)
95
+ spoom (~> 1.2.0, >= 1.2.0)
96
+ thor (>= 1.2.0)
97
+ yard-sorbet
98
+ thor (1.2.1)
99
+ unicode-display_width (2.4.2)
100
+ unparser (0.6.7)
101
+ diff-lcs (~> 1.3)
102
+ parser (>= 3.2.0)
103
+ webrick (1.7.0)
104
+ yard (0.9.28)
105
+ webrick (~> 1.7.0)
106
+ yard-sorbet (0.8.0)
107
+ sorbet-runtime (>= 0.5)
108
+ yard (>= 0.9)
109
+
110
+ PLATFORMS
111
+ arm64-darwin-22
112
+ x86_64-linux
113
+
114
+ DEPENDENCIES
115
+ bundler-audit
116
+ minitest (~> 5.0)
117
+ minitest-reporters (~> 1.4)
118
+ pry
119
+ pry-byebug
120
+ rake (~> 13.0)
121
+ rubocop
122
+ rubocop-minitest
123
+ rubocop-performance
124
+ rubocop-rake
125
+ rubocop-shopify
126
+ rubocop-sorbet
127
+ simplecov
128
+ sorbet (~> 0.5.10736)
129
+ sorbet_operation!
130
+ tapioca
131
+ yard
132
+ yard-sorbet
133
+
134
+ BUNDLED WITH
135
+ 2.4.3
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Thatch Health, Inc.
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,116 @@
1
+ # sorbet_operation
2
+
3
+ [![Build Status](https://github.com/thatch-health/sorbet_operation/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/thatch-health/sorbet_operation/actions?query=branch%3Amain)
4
+
5
+ sorbet_operation is a minimal operation framework that leverages Sorbet's type system to ensure that operations are well-typed and that their inputs and outputs are well-defined.
6
+
7
+ An operation is a Ruby class that encapsulates business logic. It is similar to a service class, but whereas service classes often group several related methods, an operation object does one and only one thing.
8
+
9
+ For example, here is an operation that creates a new user:
10
+ ```ruby
11
+ class CreateUser < SorbetOperation::Base
12
+ ValueType = type_member { { fixed: User } }
13
+
14
+ sig { params(user_params: ActiveSupport::Parameters).void }
15
+ def initialize(user_params)
16
+ @user_params = user_params
17
+ end
18
+
19
+ protected
20
+
21
+ sig { returns(ValueType) }
22
+ def execute
23
+ User.create!(@user_params)
24
+ rescue => e
25
+ raise SorbetOperation::Failure, "User creation failed: #{e.message}"
26
+ end
27
+ end
28
+ ```
29
+
30
+ In a Rails controller, this operation could be used as follows:
31
+ ```ruby
32
+ class UsersController < ApplicationController
33
+ def create
34
+ result = CreateUser.new(user_params).perform
35
+ if operation.success?
36
+ user = result.unwrap!
37
+ T.reveal_type(user) # `User`
38
+ redirect_to user
39
+ else
40
+ error = result.unwrap_error!
41
+ T.reveal_type(error) # `SorbetOperation::Error`
42
+ render :new, alert: error.message
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def user_params
49
+ params.require(:user).permit(:name, :email, :password)
50
+ end
51
+ end
52
+ ```
53
+
54
+ Operations return a result object which indicates whether the operation was successful or not. The result object wraps the return value of the operation if it was successful, or an instance of `SorbetOperation::Error` if it failed.
55
+
56
+ ## Installation
57
+
58
+ This gem is not yet published to RubyGems.org. For now, you can install it by adding the following to your `Gemfile`:
59
+ ```ruby
60
+ gem "sorbet_operation", github: "thatch-health/sorbet_operation", branch: "main"
61
+ ```
62
+
63
+ TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
64
+
65
+ Install the gem and add to the application's Gemfile by executing:
66
+
67
+ $ bundle add UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
68
+
69
+ If bundler is not being used to manage dependencies, install the gem by executing:
70
+
71
+ $ gem install UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
72
+
73
+ ## Usage
74
+
75
+ An operation is a Ruby class that derives from `SorbetOperation::Base`. `SorbetOperation::Base` is an abstract generic class which requires derived classes to do two things:
76
+ 1. define the return type using the `ValueType` generic type member
77
+ 2. define an `#execute` method that returns a `ValueType`
78
+
79
+ The `#execute` method should be `protected` or `private`, since it is not meant to be invoked directly; rather, operation callers should use the `#perform` public method to actually perform the operation. (Unfortunately, at this time there is no mechanism to enforce that `#execute` is not a public method on child classes, so it's up to the programmer to be vigilant.)
80
+
81
+ The `#execute` method does not take any arguments. Most operations require one or more input values. Input values should be passed to the `#initialize` constructor method and stored as instance variables, which can then be accessed from the `#execute` body.
82
+
83
+ There are two possible outcomes for an operation:
84
+ 1. if `#execute` returns an instance of `ValueType`, then the operation result is a success
85
+ 2. if `#execute` raises a `SorbetOperation::Error`, then the operation result is a failure
86
+
87
+ Exceptions that are not an instance of `SorbetOperation::Error` will not be caught by the framework and will be propagated to the operation callsite.
88
+
89
+ ### Using results
90
+
91
+ Operation callers call `#perform` to perform the operation. `#perform` does not directly the return value of the operation; rather, it returns an instance of `SorbetOperation::Result`, a generic class that wraps the return value or the error depending on whether the operation succeeds or fails.
92
+
93
+ The `SorbetOperation::Result` class is inspired by Rust's [`Result`](https://doc.rust-lang.org/std/result/enum.Result.html) type, and as a result the API is very similar.
94
+
95
+ ### Operations without a return value
96
+
97
+ Some operations may be pure side-effects and not need to return anything. When this is the case, you can simply define `ValueType` to be `NilClass`:
98
+ ```ruby
99
+ ValueType = { { fixed: NilClass } }
100
+ ```
101
+
102
+ (Alternatively, you could use `Sorbet::Private::Static::Void` instead of `NilClass`. This is arguably better typing, but relying on a type nested under the `Sorbet::Private` namespace is not recommended.)
103
+
104
+ ## Development
105
+
106
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
107
+
108
+ To install this gem onto your local machine, run `bin/rake install`. To release a new version, update the version number in `version.rb`, and then run `bin/rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
109
+
110
+ ## Contributing
111
+
112
+ Bug reports and pull requests are welcome on GitHub at https://github.com/thatch-health/sorbet_operation.
113
+
114
+ ## License
115
+
116
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+ require "rubocop/rake_task"
6
+ require "yard"
7
+ require "bundler/audit/task"
8
+
9
+ Rake::TestTask.new(:test) do |t|
10
+ t.libs << "test"
11
+ t.libs << "lib"
12
+ t.test_files = FileList["test/**/*_test.rb"]
13
+ end
14
+ task default: :test
15
+
16
+ RuboCop::RakeTask.new do |t|
17
+ t.options = ["--parallel"]
18
+ end
19
+
20
+ YARD::Rake::YardocTask.new
21
+
22
+ Bundler::Audit::Task.new
@@ -0,0 +1,87 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "logger"
7
+
8
+ require_relative "failure"
9
+ require_relative "result"
10
+
11
+ module SorbetOperation
12
+ # Abstract base class for operations.
13
+ #
14
+ # Subclasses must:
15
+ #
16
+ # 1. define the {ValueType} type member
17
+ # 2. implement the {#execute} method
18
+ class Base
19
+ extend T::Sig
20
+ extend T::Helpers
21
+ extend T::Generic
22
+
23
+ abstract!
24
+
25
+ # The type of the value returned by this operation. The type can be any
26
+ # valid Sorbet type, as long as it's a subtype of `Object`.
27
+ #
28
+ # @example If the operation returns a String or nil
29
+ # ValueType = type_member { { fixed: T.nilable(String) } }
30
+ #
31
+ # @example If the operation does not return a value
32
+ # ValueType = type_member { { fixed: NilClass } }
33
+ #
34
+ # @see https://sorbet.org/docs/generics#type_member--type_template
35
+ # @see https://sorbet.org/docs/generics#bounds-on-type_members-and-type_templates-fixed-upper-lower
36
+ ValueType = type_member { { upper: Object } }
37
+
38
+ # Performs the operation and returns the result.
39
+ sig { returns(Result[ValueType]) }
40
+ def perform
41
+ logger.debug("Performing operation #{self.class.name}")
42
+
43
+ begin
44
+ value = execute
45
+ rescue Failure => e
46
+ logger.debug("Operation #{self.class.name} failed, failure = #{e.inspect}")
47
+
48
+ Result.new(false, nil, e)
49
+ else
50
+ logger.debug("Operation #{self.class.name} succeeded, return value = #{value.inspect}")
51
+
52
+ Result.new(true, value, nil)
53
+ end
54
+ end
55
+
56
+ # The logger for this operation.
57
+ sig { params(logger: ::Logger).void }
58
+ attr_writer :logger
59
+
60
+ protected
61
+
62
+ # Implement this method in subclasses to perform the operation.
63
+ #
64
+ # This method must either return a value of type {ValueType}, in which
65
+ # case the operation is considered successful, or raise an exception of
66
+ # type {SorbetOperation::Failure}, in which case the operation is
67
+ # considered failed.
68
+ #
69
+ # Raising an exception of any other type will result in an unhandled
70
+ # exception. The exception will not be caught and will be propagated to
71
+ # the caller.
72
+ #
73
+ # This method should be declared as `protected` in subclasses to prevent
74
+ # callers from calling it directly. Callers should instead call {#perform}
75
+ # to perform the operation and get the result.
76
+ sig { abstract.returns(ValueType) }
77
+ def execute; end
78
+
79
+ # Returns the logger for this operation. If no logger has been set, the
80
+ # default logger will be returned instead.
81
+ sig { returns(::Logger) }
82
+ def logger
83
+ @logger = T.let(@logger, T.nilable(::Logger))
84
+ @logger ||= SorbetOperation.default_logger
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,17 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module SorbetOperation
7
+ # Exception class used to indicate that an operation failed.
8
+ #
9
+ # Raise this exception (or a subclass of it) from an operation's
10
+ # {SorbetOperation::Operation#execute} method to indicate that the operation
11
+ # failed.
12
+ #
13
+ # If you need to pass additional information about the failure, you can
14
+ # subclass this exception and add any additional attributes you need.
15
+ class Failure < ::StandardError
16
+ end
17
+ end
@@ -0,0 +1,200 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require_relative "failure"
7
+
8
+ module SorbetOperation
9
+ # {SorbetOperation::Result} is a generic class that represents the result of
10
+ # an operation, either success or failure.
11
+ #
12
+ # If the result is a success, it wraps a value of type member
13
+ # {SorbetOperation::Result::ValueType}.
14
+ #
15
+ # If the result is a failure, it wraps an exception of type
16
+ # {SorbetOperation::Failure}.
17
+ class Result
18
+ extend T::Sig
19
+ extend T::Generic
20
+
21
+ # The type of the value wrapped by the {Result}. The type can be any
22
+ # valid Sorbet type, as long as it's a subtype of `Object`.
23
+ ValueType = type_member { { upper: Object } }
24
+
25
+ # Constructs a new {Result}, either a success or a failure.
26
+ #
27
+ # If `success` is `true`, then `value` must be provided (although it can
28
+ # be nil, because {ValueType} may be nilable) and `error` must be nil.
29
+ #
30
+ # If `success` is `false`, then `value` must be nil and `error` must be
31
+ # non-nil.
32
+ #
33
+ # Calling this constructor directly should rarely be necessary. In normal
34
+ # usage, {SorbetOperation::Base#perform} will return a {Result} for you.
35
+ sig { params(success: T::Boolean, value: T.nilable(ValueType), error: T.nilable(Failure)).void }
36
+ def initialize(success, value, error)
37
+ @success = success
38
+ @value = value
39
+ @error = error
40
+
41
+ # NOTE: these checks are annoying. A better API would be to make this
42
+ # constructor private and provide two factory methods:
43
+ # - `Result.success(value)`
44
+ # - `Result.failure(error)`
45
+ #
46
+ # However, in order to do this, we would need to be able to use
47
+ # {ValueType} in class methods. At this time, there is no way to tell
48
+ # Sorbet that a generic type applies to both the class and its
49
+ # singleton. We would need to duplicate the value type:
50
+ # ```
51
+ # ValueTypeMember = type_member { { upper: Object } }
52
+ # ValueTypeTemplate = type_template { { upper: Object } }
53
+ # ```
54
+ # and every subclass would need to specify both (and ensure that
55
+ # they're both set to the same type). This would be quite clumsy. Since
56
+ # `Result` should rarely be instantiated directly (rather, it's
57
+ # instantiated by `SorbetOperation::Base#perform`), we'll just live with
58
+ # this less than ideal API for now.
59
+ if @success
60
+ # We can't test that value is not nil because the value type can be
61
+ # nilable. (In theory we could check if the type is nilable and only
62
+ # apply the check if it's not, but that's not worth the complexity.)
63
+ unless error.nil?
64
+ raise ArgumentError, "Cannot pass an error to a success result"
65
+ end
66
+ elsif error.nil?
67
+ raise ArgumentError, "Must pass an error to a failure result"
68
+ elsif !value.nil?
69
+ raise ArgumentError, "Cannot pass a value to a failure result"
70
+ end
71
+ end
72
+
73
+ # Returns `true` if the result is a success.
74
+ sig { returns(T::Boolean) }
75
+ def success?
76
+ @success
77
+ end
78
+
79
+ # Returns `true` if the result is a failure.
80
+ sig { returns(T::Boolean) }
81
+ def failure?
82
+ !success?
83
+ end
84
+
85
+ # Returns the contained value if the result is a success, otherwise raises
86
+ # the contained error.
87
+ sig { returns(ValueType) }
88
+ def unwrap!
89
+ raise T.must(@error) if failure?
90
+
91
+ casted_value
92
+ end
93
+
94
+ # Returns the contained value if the result is a success, otherwise
95
+ # returns `nil`.
96
+ sig { returns(T.nilable(ValueType)) }
97
+ def safe_unwrap
98
+ return nil if failure?
99
+
100
+ casted_value
101
+ end
102
+
103
+ # Returns the contained value if the result is a success, otherwise
104
+ # returns the provided default value.
105
+ #
106
+ # @example
107
+ # result = SomeOperation.new.perform
108
+ # result.failure? # => true
109
+ # value = result.unwrap_or(456)
110
+ # value # => 456
111
+ sig { params(default: ValueType).returns(ValueType) }
112
+ def unwrap_or(default)
113
+ return casted_value if success?
114
+
115
+ default
116
+ end
117
+
118
+ # Returns the contained value if the result is a success, otherwise calls
119
+ # the block with the contained error and returns the block's return value.
120
+ #
121
+ # @example
122
+ # result = SomeOperation.new.perform
123
+ # result.failure? # => true
124
+ # value = result.unwrap_or_else { |_| 456 }
125
+ # value # => 456
126
+ sig { params(blk: T.proc.params(error: Failure).returns(ValueType)).returns(ValueType) }
127
+ def unwrap_or_else(&blk)
128
+ return casted_value if success?
129
+
130
+ yield(T.must(@error))
131
+ end
132
+
133
+ # Returns the contained error if the result is a failure, otherwise raises
134
+ # an error.
135
+ sig { returns(Failure) }
136
+ def unwrap_error!
137
+ return T.must(@error) if failure?
138
+
139
+ # TODO: custom error type?
140
+ raise "Called `unwrap_err!` on a success"
141
+ end
142
+
143
+ # Returns the contained error if the result is a failure, otherwise
144
+ # returns `nil`.
145
+ sig { returns(T.nilable(Failure)) }
146
+ def safe_unwrap_error
147
+ return T.must(@error) if failure?
148
+
149
+ nil
150
+ end
151
+
152
+ # Yields the contained value if the result is a success, otherwise does
153
+ # nothing. Returns `self` so this call can be chained to `#on_failure`.
154
+ #
155
+ # @example
156
+ # SomeOperation.new.perform
157
+ # .on_success { |value| puts "Success! Value: #{value}" }
158
+ # .on_failure { |error| puts "Failure! Error: #{error}" }
159
+ sig { params(blk: T.proc.params(value: ValueType).void).returns(T.self_type) }
160
+ def on_success(&blk)
161
+ yield(casted_value) if success?
162
+ self
163
+ end
164
+
165
+ # Yields the contained error if the result is a failure, otherwise does
166
+ # nothing. Returns `self` so this call can be chained to `#on_success`.
167
+ #
168
+ # @example
169
+ # SomeOperation.new.perform
170
+ # .on_success { |value| puts "Success! Value: #{value}" }
171
+ # .on_failure { |error| puts "Failure! Error: #{error}" }
172
+ sig { params(blk: T.proc.params(error: Failure).void).returns(T.self_type) }
173
+ def on_failure(&blk)
174
+ yield(T.must(@error)) if failure?
175
+ self
176
+ end
177
+
178
+ private
179
+
180
+ # A word of explanation as to why this is necessary: the `value` argument
181
+ # in `Result`'s constructor is typed as `T.nilable(ValueType)`, because it
182
+ # will be `nil` for failure results.
183
+ #
184
+ # The signatures for `unwrap!`, `unwrap_or_else`, and `on_success` all use
185
+ # (non-nilable) `ValueType` because in those cases, we know that the result
186
+ # is a success.
187
+ #
188
+ # However, `ValueType` can be nilable, in which case `nil` is a valid
189
+ # value for a success result. As a result, we can't just wrap `value` in
190
+ # `T.must`. Instead, we cast `@value` from `T.nilable(ValueType)` to
191
+ # `ValueType`, which is ~the same thing as `T.must` but doesn't raise a
192
+ # runtime error if `ValueType` is nilable and `@value` is `nil`.
193
+ #
194
+ # There's probably a better way to handle this.
195
+ sig { returns(ValueType) }
196
+ def casted_value
197
+ T.cast(@value, ValueType)
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,6 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module SorbetOperation
5
+ VERSION = "0.1.0"
6
+ end
@@ -0,0 +1,31 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "logger"
7
+
8
+ # foo
9
+ #
10
+ # fewfwefw
11
+ module SorbetOperation
12
+ class << self
13
+ extend T::Sig
14
+
15
+ # Returns the default logger used by operations.
16
+ sig { returns(::Logger) }
17
+ def default_logger
18
+ @default_logger = T.let(@default_logger, T.nilable(::Logger))
19
+ @default_logger ||= ::Logger.new($stdout, level: ::Logger::INFO)
20
+ end
21
+
22
+ # Sets the default logger used by operations.
23
+ sig { params(default_logger: T.nilable(::Logger)).void }
24
+ attr_writer :default_logger
25
+ end
26
+ end
27
+
28
+ require_relative "sorbet_operation/base"
29
+ require_relative "sorbet_operation/failure"
30
+ require_relative "sorbet_operation/result"
31
+ require_relative "sorbet_operation/version"
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/sorbet_operation/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "sorbet_operation"
7
+ spec.version = SorbetOperation::VERSION
8
+ spec.authors = ["Thatch Health, Inc."]
9
+ spec.email = ["sorbet-operation@thatch.ai"]
10
+
11
+ spec.summary = "Sorbet-powered operation framework."
12
+ spec.description = "sorbet_operation is a minimal operation framework that leverages Sorbet's type system to "\
13
+ "ensure that operations are well-typed and that their inputs and outputs are well-defined."
14
+ spec.homepage = "https://github.com/thatch-health/sorbet_operation"
15
+ spec.license = "MIT"
16
+ spec.required_ruby_version = ">= 3.0.0"
17
+
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = spec.homepage
20
+ spec.metadata["changelog_uri"] = "https://github.com/thatch-health/sorbet_operation/blob/main/CHANGELOG.md"
21
+
22
+ # Specify which files should be added to the gem when it is released.
23
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
24
+ spec.files = Dir.chdir(__dir__) do
25
+ %x(git ls-files -z).split("\x0").reject do |f|
26
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features|sorbet)/|\.(?:git|circleci|vscode)|appveyor)})
27
+ end
28
+ end
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+
33
+ # Uncomment to register a new dependency of your gem
34
+ spec.add_runtime_dependency("sorbet-runtime", "~> 0.5.10741")
35
+
36
+ # For more information and examples about making a new gem, check out our
37
+ # guide at: https://bundler.io/guides/creating_gem.html
38
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sorbet_operation
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Thatch Health, Inc.
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-08-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sorbet-runtime
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.5.10741
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.5.10741
27
+ description: sorbet_operation is a minimal operation framework that leverages Sorbet's
28
+ type system to ensure that operations are well-typed and that their inputs and outputs
29
+ are well-defined.
30
+ email:
31
+ - sorbet-operation@thatch.ai
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - ".editorconfig"
37
+ - ".rubocop.yml"
38
+ - ".ruby-version"
39
+ - ".yardopts"
40
+ - Gemfile
41
+ - Gemfile.lock
42
+ - LICENSE.txt
43
+ - README.md
44
+ - Rakefile
45
+ - lib/sorbet_operation.rb
46
+ - lib/sorbet_operation/base.rb
47
+ - lib/sorbet_operation/failure.rb
48
+ - lib/sorbet_operation/result.rb
49
+ - lib/sorbet_operation/version.rb
50
+ - sorbet_operation.gemspec
51
+ homepage: https://github.com/thatch-health/sorbet_operation
52
+ licenses:
53
+ - MIT
54
+ metadata:
55
+ homepage_uri: https://github.com/thatch-health/sorbet_operation
56
+ source_code_uri: https://github.com/thatch-health/sorbet_operation
57
+ changelog_uri: https://github.com/thatch-health/sorbet_operation/blob/main/CHANGELOG.md
58
+ post_install_message:
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: 3.0.0
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.4.10
74
+ signing_key:
75
+ specification_version: 4
76
+ summary: Sorbet-powered operation framework.
77
+ test_files: []