sorbet_operation 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.editorconfig +12 -0
- data/.rubocop.yml +20 -0
- data/.ruby-version +1 -0
- data/.yardopts +3 -0
- data/Gemfile +39 -0
- data/Gemfile.lock +135 -0
- data/LICENSE.txt +21 -0
- data/README.md +116 -0
- data/Rakefile +22 -0
- data/lib/sorbet_operation/base.rb +87 -0
- data/lib/sorbet_operation/failure.rb +17 -0
- data/lib/sorbet_operation/result.rb +200 -0
- data/lib/sorbet_operation/version.rb +6 -0
- data/lib/sorbet_operation.rb +31 -0
- data/sorbet_operation.gemspec +38 -0
- metadata +77 -0
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
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
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,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: []
|