├── .devcontainer ├── Dockerfile ├── compose.yml └── devcontainer.json ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .standard.yml ├── Appraisals ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── bin ├── rails_test └── test ├── gemfiles ├── rails_7.2.gemfile └── rails_8.0.gemfile ├── lib ├── generators │ ├── USAGE │ ├── templates │ │ ├── operation.rb │ │ └── operation_test.rb │ ├── typed_operation │ │ └── install │ │ │ ├── USAGE │ │ │ ├── install_generator.rb │ │ │ └── templates │ │ │ └── application_operation.rb │ └── typed_operation_generator.rb ├── typed_operation.rb └── typed_operation │ ├── action_policy_auth.rb │ ├── base.rb │ ├── curried.rb │ ├── immutable_base.rb │ ├── operations │ ├── callable.rb │ ├── executable.rb │ ├── introspection.rb │ ├── lifecycle.rb │ ├── parameters.rb │ ├── partial_application.rb │ └── property_builder.rb │ ├── partially_applied.rb │ ├── prepared.rb │ ├── railtie.rb │ └── version.rb ├── test ├── dummy │ ├── Rakefile │ ├── app │ │ ├── assets │ │ │ ├── images │ │ │ │ └── .keep │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── channels │ │ │ └── application_cable │ │ │ │ ├── channel.rb │ │ │ │ └── connection.rb │ │ ├── controllers │ │ │ ├── application_controller.rb │ │ │ └── concerns │ │ │ │ └── .keep │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── jobs │ │ │ └── application_job.rb │ │ ├── mailers │ │ │ └── application_mailer.rb │ │ ├── models │ │ │ ├── application_record.rb │ │ │ └── concerns │ │ │ │ └── .keep │ │ └── views │ │ │ └── layouts │ │ │ ├── application.html.erb │ │ │ ├── mailer.html.erb │ │ │ └── mailer.text.erb │ ├── bin │ │ ├── rails │ │ ├── rake │ │ └── setup │ ├── config.ru │ ├── config │ │ ├── application.rb │ │ ├── boot.rb │ │ ├── cable.yml │ │ ├── database.yml │ │ ├── environment.rb │ │ ├── environments │ │ │ ├── development.rb │ │ │ ├── production.rb │ │ │ └── test.rb │ │ ├── initializers │ │ │ ├── content_security_policy.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── inflections.rb │ │ │ └── permissions_policy.rb │ │ ├── locales │ │ │ └── en.yml │ │ ├── puma.rb │ │ ├── routes.rb │ │ └── storage.yml │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── log │ │ └── .keep │ └── public │ │ ├── 404.html │ │ ├── 422.html │ │ ├── 500.html │ │ ├── apple-touch-icon-precomposed.png │ │ ├── apple-touch-icon.png │ │ └── favicon.ico ├── lib │ └── generators │ │ ├── typed_operation │ │ └── install │ │ │ └── install_generator_test.rb │ │ └── typed_operation_generator_test.rb ├── test_helper.rb └── typed_operation │ ├── action_policy_auth_test.rb │ ├── base_test.rb │ ├── immutable_base_test.rb │ └── typed_operation_test.rb └── typed_operation.gemspec /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # Make sure RUBY_VERSION matches the Ruby version in .ruby-version or gemspec 2 | ARG RUBY_VERSION=3.4.2 3 | FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION 4 | 5 | USER root 6 | 7 | # Install pkg-config and SQLite development libraries 8 | RUN apt-get update -qq && \ 9 | apt-get install -y pkg-config libsqlite3-dev && \ 10 | apt-get clean && \ 11 | rm -rf /var/lib/apt/lists/* 12 | 13 | USER vscode 14 | 15 | # Ensure binding is always 0.0.0.0 16 | # Binds the server to all IP addresses of the container, so it can be accessed from outside the container. 17 | ENV BINDING="0.0.0.0" -------------------------------------------------------------------------------- /.devcontainer/compose.yml: -------------------------------------------------------------------------------- 1 | name: "typed_operation" 2 | 3 | services: 4 | typed_operation-dev-env: 5 | container_name: typed_operation-dev-env 6 | build: 7 | context: .. 8 | dockerfile: .devcontainer/Dockerfile 9 | ports: 10 | - "3000" -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typed_operation Gem Development", 3 | "dockerComposeFile": "compose.yml", 4 | "service": "typed_operation-dev-env", 5 | "containerEnv": { 6 | "RAILS_ENV": "development" 7 | }, 8 | "forwardPorts": [3000], 9 | "postCreateCommand": "bundle install && bundle exec appraisal install", 10 | "postStartCommand": "bundle exec rake test", 11 | "remoteUser": "vscode" 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | name: Run tests for (${{ matrix.ruby }} / Rails ${{ matrix.rails }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | ruby: [ "3.2", "3.3", "3.4" ] 13 | rails: [ "7.2", "8.0" ] 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Ruby 17 | uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ${{ matrix.ruby }} 20 | - name: Install gems 21 | env: 22 | MATRIX_RAILS_VERSION: ${{ matrix.rails }} 23 | run: | 24 | gem install bundler 25 | bundle install 26 | export BUNDLE_GEMFILE="${GITHUB_WORKSPACE}/gemfiles/rails_${MATRIX_RAILS_VERSION}.gemfile" 27 | bundle install --jobs 4 --retry 3 28 | - name: Run tests 29 | run: bundle exec ./bin/test 30 | - name: Run tests w. Rails stuff 31 | run: bundle exec ./bin/rails_test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /doc/ 3 | /log/*.log 4 | /pkg/ 5 | /tmp/ 6 | /test/dummy/db/*.sqlite3 7 | /test/dummy/db/*.sqlite3-* 8 | /test/dummy/log/*.log 9 | /test/dummy/storage/ 10 | /test/dummy/tmp/ 11 | .idea/ 12 | Gemfile.lock 13 | *.gem 14 | /coverage/ 15 | .tool-versions 16 | gemfiles/*.lock -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - 'lib/generators/typed_operation/install/templates/**/*' 3 | - 'lib/generators/templates/**/*' 4 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # Test against different Rails versions 2 | # Run 'bundle exec appraisal install' to generate Gemfiles 3 | # Run 'bundle exec appraisal rake test' to run tests against all versions 4 | 5 | appraise "rails-7.2" do 6 | gem "rails", "~> 7.2" 7 | end 8 | 9 | appraise "rails-8.0" do 10 | gem "rails", "~> 8.0" 11 | end 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [1.0.0.beta3] - 2025/04/08 3 | 4 | ### Breaking changes 5 | 6 | - Literal updated to release version so ImmutableBase now only freezes itself, not its properties too. 7 | 8 | ## [1.0.0.beta2] - 2024/06/24 9 | 10 | ### Added 11 | 12 | - Now uses the new Literal::Properties 13 | - Dropped the `#with` instance method 14 | - install generator now can take a `--action_policy` switch to include the Action Policy integration 15 | 16 | ## [1.0.0.beta1] - 2023/08/26 17 | 18 | ### Added 19 | 20 | - Action Policy integration. Optionally include `TypedOperation::ActionPolicyAuth` to get a operation execution authorization mechanism 21 | based on [Action Policy](https://actionpolicy.evilmartians.io/). This is an optional feature and is not included by default. 22 | 23 | ## [1.0.0.pre3] - 2023/08/24 24 | 25 | ### Added 26 | 27 | - It is now possible to define two hooks, `#before_execute_operation` & `#after_execute_operation` which are called before and after the operation `#perform` method is executed. Note if you 28 | implement these methods remember to call `super` in them. 29 | 30 | ### Changes 31 | 32 | - You now implement `#perform` (instead of `#call`) to define the operation logic. This is so that the before and after hooks can be called around the operation logic. It is still possible to 33 | use `#call` but then calling the hooks is the responsibility of the implementor. The recommended way is to use `#perform` to implement your operation logic now. 34 | 35 | ### Fixes 36 | 37 | - Fixed a bug where a coercion block on an optional param was causing an error when the param was not provided. 38 | 39 | ## [1.0.0.pre2] - 2023/08/22 40 | 41 | ### Breaking changes 42 | 43 | - `TypedOperation::Base` now uses `Literal::Struct` & is the parent class for an operation where the arguments are mutable (not frozen). But note that 44 | no writer methods are defined, so the arguments can still not be changed after initialization. Just that they are not frozen. 45 | - `TypedOperation::ImmutableBase` now uses `Literal::Data` & is the parent class for an operation where the arguments are immutable (frozen on initialization), 46 | thus giving stronger immutability guarantees (ie that the operation does not mutate its arguments). 47 | 48 | ## [1.0.0.pre1] - 2023/08/20 49 | 50 | ### Added 51 | 52 | - Positional params are now supported 53 | - A new set of methods exist to define params, `.param`, `.named`, `.positional`, `.optional` 54 | - Class methods to get names of parameters, positional and named, optional or required. 55 | - Added ability to pattern matching on params with operation instance or partially applied operations 56 | 57 | ### Breaking changes 58 | 59 | - TypedOperation now uses [Literal::Data](https://github.com/joeldrapper/literal) under the hood, instead of [vident-typed](https://github.com/stevegeek/vident-typed) 60 | - Param option `convert:` has been removed, use a coercion block instead 61 | - Param option `allow_nil:` has been removed, use `optional:` or `.optional()` instead 62 | - The method `.curry` now actually curries the operation, instead of partially applying (use `.with` for partial application of parameters) 63 | - `.operation_key` has been removed 64 | - Ruby >= 3.1 is now required 65 | 66 | ### Changed 67 | 68 | - TypedOperation does **not** depend on Rails. Rails generator support exists but is conditionally included. 69 | - Numerous fixes 70 | 71 | ## [0.4.2] - 2023/07/27 72 | 73 | ### Changed 74 | 75 | - Params that have default values are no longer required to prepare the operation. 76 | 77 | ## [0.4.1] - 2023-06-22 78 | 79 | ### Changed 80 | 81 | - Updated tests. 82 | - Tweaked operation templates. 83 | 84 | ## [0.4.0] - 2023-06-22 85 | 86 | ### Removed 87 | 88 | - Removed dry-monads as a dependency of TypedOperation. It can now be included in ApplicationOperation instead. 89 | 90 | ### Added 91 | 92 | - Generator now creates a test file. 93 | 94 | ### Changed 95 | 96 | - Avoided leaking the implementation detail of using dry-struct under the hood. 97 | 98 | ## [0.3.0] - 2023-06-19 99 | 100 | ### Removed 101 | 102 | - Ruby 2.7 is not supported currently. 103 | - Rails 6 is not supported currently due to vident dependency. Considering Literal::Attributes instead. 104 | 105 | ### Changed 106 | 107 | - Updated test config. 108 | - Added Github workflow for testing. 109 | 110 | ### Added 111 | 112 | - Added some tests and updated docs. 113 | - Added typed operation install and 'typed_operation' generators. 114 | 115 | ### Fixed 116 | 117 | - Fixed 'require's. 118 | 119 | ## [0.1.0] - 2023-05-04 120 | 121 | ### Added 122 | 123 | - Initial implementation of the project. 124 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at stevegeek@gmail.com. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | # Specify your gem's dependencies in typed_operation.gemspec. 5 | gemspec 6 | 7 | gem "literal", "~> 1.0" 8 | 9 | gem "standard" 10 | gem "simplecov", require: false, group: :test 11 | 12 | gem "rails" 13 | gem "sqlite3" 14 | gem "dry-monads" 15 | gem "action_policy" 16 | 17 | gem "appraisal", require: false 18 | 19 | # gem "typed_operation" 20 | # gem "type_fusion" 21 | 22 | # Start debugger with binding.b [https://github.com/ruby/debug] 23 | # gem "debug", ">= 1.0.0" 24 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Stephen Ierodiaconou 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypedOperation 2 | 3 | An implementation of a Command pattern, which is callable, and can be partially applied. 4 | 5 | Inputs to the operation are specified as typed attributes (uses [`literal`](https://github.com/joeldrapper/literal)). 6 | 7 | Type of result of the operation is up to you, eg you could use [`Dry::Monads`](https://dry-rb.org/gems/dry-monads/1.3/). 8 | 9 | ## Features 10 | 11 | - Operations can be **partially applied** or **curried** 12 | - Operations are **callable** 13 | - Operations can be **pattern matched** on 14 | - Parameters: 15 | - specified with **type constraints** (uses `literal` gem) 16 | - can be **positional** or **named** 17 | - can be **optional**, or have **default** values 18 | - can be **coerced** by providing a block 19 | 20 | ### Example 21 | 22 | ```ruby 23 | class ShelveBookOperation < ::TypedOperation::Base 24 | # Parameters can be specified with `positional_param`/`named_param` or directly with the 25 | # underlying `param` method. 26 | 27 | # Note that you may also like to simply alias the param methods to your own preferred names: 28 | # `positional`/`named` or `arg`/`key` for example. 29 | 30 | # A positional parameter (positional argument passed to the operation when creating it). 31 | positional_param :title, String 32 | # Or if you prefer: 33 | # `param :title, String, positional: true` 34 | 35 | # A named parameter (keyword argument passed to the operation when creating it). 36 | named_param :description, String 37 | # Or if you prefer: 38 | # `param :description, String` 39 | 40 | # `param` creates named parameters by default 41 | param :author_id, Integer, &:to_i 42 | param :isbn, String 43 | 44 | # Optional parameters are specified by wrapping the type constraint in the `optional` method, or using the `optional:` option 45 | param :shelf_code, optional(Integer) 46 | # Or if you prefer: 47 | # `named_param :shelf_code, Integer, optional: true` 48 | 49 | param :category, String, default: "unknown".freeze 50 | 51 | # optional hook called when the operation is initialized, and after the parameters have been set 52 | def prepare 53 | raise ArgumentError, "ISBN is invalid" unless valid_isbn? 54 | end 55 | 56 | # optionally hook in before execution ... and call super to allow subclasses to hook in too 57 | def before_execute_operation 58 | # ... 59 | super 60 | end 61 | 62 | # The 'work' of the operation, this is the main body of the operation and must be implemented 63 | def perform 64 | "Put away '#{title}' by author ID #{author_id}#{shelf_code ? " on shelf #{shelf_code}" : "" }" 65 | end 66 | 67 | # optionally hook in after execution ... and call super to allow subclasses to hook in too 68 | def after_execute_operation(result) 69 | # ... 70 | super 71 | end 72 | 73 | private 74 | 75 | def valid_isbn? 76 | # ... 77 | true 78 | end 79 | end 80 | 81 | shelve = ShelveBookOperation.new("The Hobbit", description: "A book about a hobbit", author_id: "1", isbn: "978-0261103283") 82 | # => #"The Hobbit", :description=>"A book about a hobbit", :author_id=>1, :isbn=>"978-0261103283", :shelf_code=>nil, :category=>"unknown"}, ... 83 | 84 | shelve.call 85 | # => "Put away 'The Hobbit' by author ID 1" 86 | 87 | shelve = ShelveBookOperation.with("The Silmarillion", description: "A book about the history of Middle-earth", shelf_code: 1) 88 | # => # "Put away 'The Silmarillion' by author ID 1 on shelf 1" 92 | 93 | curried = shelve.curry 94 | # => # "Put away 'The Silmarillion' by author ID 1 on shelf 1" 98 | 99 | shelve.call(author_id: "1", isbn: false) 100 | # => Raises an error because isbn is invalid 101 | # :in `initialize': Expected `false` to be of type: `String`. (Literal::TypeError) 102 | ``` 103 | 104 | ### Partially applying parameters 105 | 106 | Operations can also be partially applied and curried: 107 | 108 | ```ruby 109 | class TestOperation < ::TypedOperation::Base 110 | param :foo, String, positional: true 111 | param :bar, String 112 | param :baz, String, &:to_s 113 | 114 | def perform = "It worked! (#{foo}, #{bar}, #{baz})" 115 | end 116 | 117 | # Invoking the operation directly 118 | TestOperation.("1", bar: "2", baz: 3) 119 | # => "It worked! (1, 2, 3)" 120 | 121 | # Partial application of parameters 122 | partially_applied = TestOperation.with("1").with(bar: "2") 123 | # => #"2"}, @operation_class=TestOperation, @positional_args=["1"]> 124 | 125 | # You can partially apply more than one parameter at a time, and chain calls to `.with`. 126 | # With all the required parameters set, the operation is 'prepared' and can be instantiated and called 127 | prepared = TestOperation.with("1", bar: "2").with(baz: 3) 128 | # => #"2", :baz=>3}, @operation_class=TestOperation, @positional_args=["1"]> 129 | 130 | # A 'prepared' operation can instantiated & called 131 | prepared.call 132 | # => "It worked! (1, 2, 3)" 133 | 134 | # You can provide additional parameters when calling call on a partially applied operation 135 | partially_applied.call(baz: 3) 136 | # => "It worked! (1, 2, 3)" 137 | 138 | # Partial application can be done using `.with or `.[]` 139 | TestOperation.with("1")[bar: "2", baz: 3].call 140 | # => "It worked! (1, 2, 3)" 141 | 142 | # Currying an operation, note that *all required* parameters must be provided an argument in order 143 | TestOperation.curry.("1").("2").(3) 144 | # => "It worked! (1, 2, 3)" 145 | 146 | # You can also curry from an already partially applied operation, so you can set optional named parameters first. 147 | # Note currying won't let you set optional positional parameters. 148 | partially_applied = TestOperation.with("1") 149 | partially_applied.curry.("2").(3) 150 | # => "It worked! (1, 2, 3)" 151 | 152 | # > TestOperation.with("1").with(bar: "2").call 153 | # => Raises an error because it is PartiallyApplied and so can't be called (it is missing required args) 154 | # "Cannot call PartiallyApplied operation TestOperation (key: test_operation), are you expecting it to be Prepared? (TypedOperation::MissingParameterError)" 155 | 156 | TestOperation.with("1").with(bar: "2").with(baz: 3).operation 157 | # same as > TestOperation.new("1", bar: "2", baz: 3) 158 | # => 159 | 160 | # > TestOperation.with(foo: "1").with(bar: "2").operation 161 | # => Raises an error because it is PartiallyApplied so operation can't be instantiated 162 | # "Cannot instantiate Operation TestOperation (key: test_operation), as it is only partially applied. (TypedOperation::MissingParameterError)" 163 | ``` 164 | 165 | ## Documentation 166 | 167 | ### Create an operation (subclass `TypedOperation::Base` or `TypedOperation::ImmutableBase`) 168 | 169 | Create an operation by subclassing `TypedOperation::Base` or `TypedOperation::ImmutableBase` and specifying the parameters the operation requires. 170 | 171 | - `TypedOperation::Base` (uses `Literal::Struct`) is the parent class for an operation where the arguments are potentially mutable (ie not frozen). 172 | No attribute writer methods are defined, so the arguments can not be changed after initialization, but the values passed in are not guaranteed to be frozen. 173 | - `TypedOperation::ImmutableBase` (uses `Literal::Data`) is the parent class for an operation where the operation instance is frozen on initialization, 174 | thus giving a somewhat stronger immutability guarantee. 175 | 176 | > Note: you cannot include `TypedOperation::ActionPolicyAuth` into a `TypedOperation::ImmutableBase`. 177 | 178 | The subclass must implement the `#perform` method which is where the operations main work is done. 179 | 180 | The operation can also implement: 181 | 182 | - `#prepare` - called when the operation is initialized, and after the parameters have been set 183 | - `#before_execute_operation` - optionally hook in before execution ... and call super to allow subclasses to hook in too 184 | - `#after_execute_operation` - optionally hook in after execution ... and call super to allow subclasses to hook in too 185 | 186 | ```ruby 187 | # optionally hook in before execution... 188 | def before_execute_operation 189 | # Remember to call super 190 | super 191 | end 192 | 193 | def perform 194 | # ... implement me! 195 | end 196 | 197 | # optionally hook in after execution... 198 | def after_execute_operation(result) 199 | # Remember to call super, note the result is passed in and the return value of this method is the result of the operation 200 | # thus allowing you to modify the result if you wish 201 | super 202 | end 203 | ``` 204 | 205 | ### Specifying parameters (using `.param`) 206 | 207 | Parameters are specified using the provided class methods (`.positional_param` and `.named_param`), 208 | or using the underlying `.param` method. 209 | 210 | Types are specified using the `literal` gem. In many cases this simply means providing the class of the 211 | expected type, but there are also some other useful types provided by `literal` (eg `Union`). 212 | 213 | These can be either accessed via the `Literal` module, eg `Literal::Types::BooleanType`: 214 | 215 | ```ruby 216 | class MyOperation < ::TypedOperation::Base 217 | param :name, String 218 | param :age, Integer, optional: true 219 | param :choices, Literal::Types::ArrayType.new(String) 220 | param :chose, Literal::Types::BooleanType 221 | end 222 | 223 | MyOperation.new(name: "bob", choices: ["st"], chose: true) 224 | ``` 225 | 226 | or by including the `Literal::Types` module into your operation class, and using the aliases provided: 227 | 228 | ```ruby 229 | class MyOperation < ::TypedOperation::Base 230 | include Literal::Types 231 | 232 | param :name, String 233 | param :age, _Nilable(Integer) # optional can also be specifed using `.optional` 234 | param :choices, _Array(String) 235 | param :chose, _Boolean 236 | end 237 | ``` 238 | 239 | Type constraints can be modified to make the parameter optional using `.optional`. 240 | 241 | #### Your own aliases 242 | 243 | Note that you may also like to alias the param methods to your own preferred names in a common base operation class. 244 | 245 | Some possible aliases are: 246 | - `positional`/`named` 247 | - `arg`/`key` 248 | 249 | For example: 250 | 251 | ```ruby 252 | class ApplicationOperation < ::TypedOperation::Base 253 | class << self 254 | alias_method :arg, :positional_param 255 | alias_method :key, :named_param 256 | end 257 | end 258 | 259 | class MyOperation < ApplicationOperation 260 | arg :name, String 261 | key :age, Integer 262 | end 263 | 264 | MyOperation.new("Steve", age: 20) 265 | ``` 266 | 267 | #### Positional parameters (`positional: true` or `.positional_param`) 268 | 269 | Defines a positional parameter (positional argument passed to the operation when creating it). 270 | 271 | The following are equivalent: 272 | 273 | - `param , , positional: true, <**options>` 274 | - `positional_param , , <**options>` 275 | 276 | The `` is a symbolic name, used to create the accessor method, and when deconstructing to a hash. 277 | 278 | The `` constraint provides the expected type of the parameter (the type is a type signature compatible with `literal`). 279 | 280 | The `` are: 281 | - `default:` - a default value for the parameter (can be a proc or a frozen value) 282 | - `optional:` - a boolean indicating whether the parameter is optional (default: false). Note you may prefer to use the 283 | `.optional` method instead of this option. 284 | 285 | **Note** when positional arguments are provided to the operation, they are matched in order of definition or positional 286 | params. Also note that you cannot define required positional parameters after optional ones. 287 | 288 | Eg 289 | 290 | ```ruby 291 | class MyOperation < ::TypedOperation::Base 292 | positional_param :name, String, positional: true 293 | # Or alternatively => `param :name, String, positional: true` 294 | positional_param :age, Integer, default: -> { 0 } 295 | 296 | def perform 297 | puts "Hello #{name} (#{age})" 298 | end 299 | end 300 | 301 | MyOperation.new("Steve").call 302 | # => "Hello Steve (0)" 303 | 304 | MyOperation.with("Steve").call(20) 305 | # => "Hello Steve (20)" 306 | ``` 307 | 308 | #### Named (keyword) parameters 309 | 310 | Defines a named parameter (keyword argument passed to the operation when creating it). 311 | 312 | The following are equivalent: 313 | - `param , , <**options>` 314 | - `named_param , , <**options>` 315 | 316 | The `` is a symbol, used as parameter name for the keyword arguments in the operation constructor, to 317 | create the accessor method and when deconstructing to a hash. 318 | 319 | The type constraint and options are the same as for positional parameters. 320 | 321 | ```ruby 322 | class MyOperation < ::TypedOperation::Base 323 | named_param :name, String 324 | # Or alternatively => `param :name, String` 325 | named_param :age, Integer, default: -> { 0 } 326 | 327 | def perform 328 | puts "Hello #{name} (#{age})" 329 | end 330 | end 331 | 332 | MyOperation.new(name: "Steve").call 333 | # => "Hello Steve (0)" 334 | 335 | MyOperation.with(name: "Steve").call(age: 20) 336 | # => "Hello Steve (20)" 337 | ``` 338 | 339 | #### Using both positional and named parameters 340 | 341 | You can use both positional and named parameters in the same operation. 342 | 343 | ```ruby 344 | class MyOperation < ::TypedOperation::Base 345 | positional_param :name, String 346 | named_param :age, Integer, default: -> { 0 } 347 | 348 | def perform 349 | puts "Hello #{name} (#{age})" 350 | end 351 | end 352 | 353 | MyOperation.new("Steve").call 354 | # => "Hello Steve (0)" 355 | 356 | MyOperation.new("Steve", age: 20).call 357 | # => "Hello Steve (20)" 358 | 359 | MyOperation.with("Steve").call(age: 20) 360 | # => "Hello Steve (20)" 361 | ``` 362 | 363 | #### Optional parameters (using `optional:` or `.optional`) 364 | 365 | Optional parameters are ones that do not need to be specified for the operation to be instantiated. 366 | 367 | An optional parameter can be specified by: 368 | - using the `optional:` option 369 | - using the `.optional` method around the type constraint 370 | 371 | ```ruby 372 | class MyOperation < ::TypedOperation::Base 373 | param :name, String 374 | param :age, Integer, optional: true 375 | param :nickname, optional(String) 376 | # ... 377 | end 378 | 379 | MyOperation.new(name: "Steve") 380 | MyOperation.new(name: "Steve", age: 20) 381 | MyOperation.new(name: "Steve", nickname: "Steve-o") 382 | ``` 383 | 384 | This `.optional` class method effectively makes the type signature a union of the provided type and `NilClass`. 385 | 386 | #### Coercing parameters 387 | 388 | You can specify a block after a parameter definition to coerce the argument value. 389 | 390 | ```ruby 391 | param :name, String, &:to_s 392 | param :choice, Literal::Types::BooleanType do |v| 393 | v == "y" 394 | end 395 | ``` 396 | 397 | #### Default values (with `default:`) 398 | 399 | You can specify a default value for a parameter using the `default:` option. 400 | 401 | The default value can be a proc or a frozen value. If the value is specified as `nil` then the default value is literally nil and the parameter is optional. 402 | 403 | ```ruby 404 | param :name, String, default: "Steve".freeze 405 | param :age, Integer, default: -> { rand(100) } 406 | ``` 407 | 408 | If using the directive `# frozen_string_literal: true` then you string values are frozen by default. 409 | 410 | ### Partially applying (fixing parameters) on an operation (using `.with`) 411 | 412 | `.with(...)` creates a partially applied operation with the provided parameters. 413 | 414 | It is aliased to `.[]` for an alternative syntax. 415 | 416 | Note that `.with` can take both positional and keyword arguments, and can be chained. 417 | 418 | **An important caveat about partial application is that type checking is not done until the operation is instantiated** 419 | 420 | ```ruby 421 | MyOperation.new(123) 422 | # => Raises an error as the type of the first parameter is incorrect: 423 | # Expected `123` to be of type: `String`. (Literal::TypeError) 424 | 425 | op = MyOperation.with(123) 426 | # => # Now raises an error as the type of the first parameter is incorrect and operation is instantiated 431 | ``` 432 | 433 | ### Calling an operation (using `.call`) 434 | 435 | An operation can be invoked by: 436 | 437 | - instantiating it with at least required params and then calling the `#call` method on the instance 438 | - once a partially applied operation has been prepared (all required parameters have been set), the call 439 | method on `TypedOperation::Prepared` can be used to instantiate and call the operation. 440 | - once an operation is curried, the `#call` method on last TypedOperation::Curried in the chain will invoke the operation 441 | - calling `#call` on a partially applied operation and passing in any remaining required parameters 442 | - calling `#execute_operation` on an operation instance (this is the method that is called by `#call`) 443 | 444 | See the many examples in this document. 445 | 446 | ### Pattern matching on an operation 447 | 448 | `TypedOperation::Base` and `TypedOperation::PartiallyApplied` implement `deconstruct` and `deconstruct_keys` methods, 449 | so they can be pattern matched against. 450 | 451 | ```ruby 452 | case MyOperation.new("Steve", age: 20) 453 | in MyOperation[name, age] 454 | puts "Hello #{name} (#{age})" 455 | end 456 | 457 | case MyOperation.new("Steve", age: 20) 458 | in MyOperation[name:, age: 20] 459 | puts "Hello #{name} (#{age})" 460 | end 461 | ``` 462 | 463 | ### Introspection of parameters & other methods 464 | 465 | #### `.to_proc` 466 | 467 | Get a proc that calls `.call(...)` 468 | 469 | 470 | #### `#to_proc` 471 | 472 | Get a proc that calls the `#call` method on an operation instance 473 | 474 | #### `.prepared?` 475 | 476 | Check if an operation is prepared 477 | 478 | #### `.operation` 479 | 480 | Return an operation instance from a Prepared operation. Will raise if called on a PartiallyApplied operation 481 | 482 | #### `.positional_parameters` 483 | 484 | List of the names of the positional parameters, in order 485 | 486 | #### `.keyword_parameters` 487 | 488 | List of the names of the keyword parameters 489 | 490 | #### `.required_positional_parameters` 491 | 492 | List of the names of the required positional parameters, in order 493 | 494 | #### `.required_keyword_parameters` 495 | 496 | List of the names of the required keyword parameters 497 | 498 | #### `.optional_positional_parameters` 499 | 500 | List of the names of the optional positional parameters, in order 501 | 502 | #### `.optional_keyword_parameters` 503 | 504 | List of the names of the optional keyword parameters 505 | 506 | 507 | ### Using with Rails 508 | 509 | You can use the provided generator to create an `ApplicationOperation` class in your Rails project. 510 | 511 | You can then extend this to add extra functionality to all your operations. 512 | 513 | This is an example of a `ApplicationOperation` in a Rails app that uses `Dry::Monads`: 514 | 515 | ```ruby 516 | # frozen_string_literal: true 517 | 518 | class ApplicationOperation < ::TypedOperation::Base 519 | # We choose to use dry-monads for our operations, so include the required modules 520 | include Dry::Monads[:result] 521 | include Dry::Monads::Do.for(:perform) 522 | 523 | class << self 524 | # Setup our own preferred names for the DSL methods 525 | alias_method :positional, :positional_param 526 | alias_method :named, :named_param 527 | end 528 | 529 | # Parameters common to all Operations in this application 530 | named :initiator, optional(::User) 531 | 532 | private 533 | 534 | # We setup some helper methods for our operations to use 535 | 536 | def succeeded(value) 537 | Success(value) 538 | end 539 | 540 | def failed_with_value(value, message: "Operation failed", error_code: nil) 541 | failed(error_code || operation_key, message, value) 542 | end 543 | 544 | def failed_with_message(message, error_code: nil) 545 | failed(error_code || operation_key, message) 546 | end 547 | 548 | def failed(error_code, message = "Operation failed", value = nil) 549 | Failure[error_code, message, value] 550 | end 551 | 552 | def failed_with_code_and_value(error_code, value, message: "Operation failed") 553 | failed(error_code, message, value) 554 | end 555 | 556 | def operation_key 557 | self.class.name 558 | end 559 | end 560 | ``` 561 | 562 | ### Using with Action Policy (`action_policy` gem) 563 | 564 | > Note, this optional feature requires the `action_policy` gem to be installed and does not yet work with `ImmutableBase`. 565 | 566 | Add `TypedOperation::ActionPolicyAuth` to your `ApplicationOperation` (first `require` the module): 567 | 568 | ```ruby 569 | require "typed_operation/action_policy_auth" 570 | 571 | class ApplicationOperation < ::TypedOperation::Base 572 | # ... 573 | include TypedOperation::ActionPolicyAuth 574 | 575 | # You can specify a parameter to take the authorization context object, eg a user (can also be optional if some 576 | # operations don't require authorization) 577 | param :initiator, ::User # or optional(::User) 578 | end 579 | ``` 580 | 581 | #### Specify the action with `.action_type` 582 | 583 | Every operation must define what action_type it is, eg: 584 | 585 | ```ruby 586 | class MyUpdateOperation < ApplicationOperation 587 | action_type :update 588 | end 589 | ``` 590 | 591 | Any symbol can be used as the `action_type` and this is by default used to determine which policy method to call. 592 | 593 | #### Configuring auth with `.authorized_via` 594 | 595 | `.authorized_via` is used to specify how to authorize the operation. You must specify the name of a parameter 596 | for the policy authorization context. You can also specify multiple parameters if you wish. 597 | 598 | You can then either provide a block with the logic to perform the authorization check, or provide a policy class. 599 | 600 | The `record:` option lets you provide the name of the parameter which will be passed as the policy 'record'. 601 | 602 | For example: 603 | 604 | ```ruby 605 | class MyUpdateOperation < ApplicationOperation 606 | param :initiator, ::AdminUser 607 | param :order, ::Order 608 | 609 | action_type :update 610 | 611 | authorized_via :initiator, record: :order do 612 | # ... the permissions check, admin users can edit orders that are not finalized 613 | initiator.admin? && !record.finalized 614 | end 615 | 616 | def perform 617 | # ... 618 | end 619 | end 620 | ``` 621 | 622 | You can instead provide a policy class implementation: 623 | 624 | ```ruby 625 | class MyUpdateOperation < ApplicationOperation 626 | action_type :update 627 | 628 | class MyPolicyClass < OperationPolicy 629 | # The action_type defined on the operation determines which method is called on the policy class 630 | def update? 631 | # ... the permissions check 632 | initiator.admin? 633 | end 634 | 635 | # def my_check? 636 | # # ... the permissions check 637 | # end 638 | end 639 | 640 | authorized_via :initiator, with: MyPolicyClass 641 | 642 | # It is also possible to specify which policy method to call 643 | # authorized_via :initiator, with: MyPolicyClass, to: :my_check? 644 | end 645 | ``` 646 | 647 | with multiple parameters: 648 | 649 | ```ruby 650 | class MyUpdateOperation < ApplicationOperation 651 | # ... 652 | param :initiator, ::AdminUser 653 | param :user, ::User 654 | 655 | authorized_via :initiator, :user do 656 | initiator.active? && user.active? 657 | end 658 | 659 | # ... 660 | end 661 | ``` 662 | 663 | #### `.verify_authorized!` 664 | 665 | To ensure that subclasses always implement authentication you can add a call to `.verify_authorized!` to your base 666 | operation class. 667 | 668 | This will cause the execution of any subclasses to fail if no authorization is performed. 669 | 670 | ```ruby 671 | class MustAuthOperation < ApplicationOperation 672 | verify_authorized! 673 | end 674 | 675 | class MyUpdateOperation < MustAuthOperation 676 | def perform 677 | # ... 678 | end 679 | end 680 | 681 | MyUpdateOperation.call # => Raises an error that MyUpdateOperation does not perform any authorization 682 | ``` 683 | 684 | #### `#on_authorization_failure(err)` 685 | 686 | A hook is provided to allow you to do some work on an authorization failure. 687 | 688 | Simply override the `#on_authorization_failure(err)` method in your operation. 689 | 690 | ```ruby 691 | class MyUpdateOperation < ApplicationOperation 692 | action_type :update 693 | 694 | authorized_via :initiator do 695 | # ... the permissions check 696 | initiator.admin? 697 | end 698 | 699 | def perform 700 | # ... 701 | end 702 | 703 | def on_authorization_failure(err) 704 | # ... do something with the error, eg logging 705 | end 706 | end 707 | ``` 708 | 709 | Note you are provided the ActionPolicy error object, but you cannot stop the error from being re-raised. 710 | 711 | ### Using with `Dry::Monads` 712 | 713 | As per the example in [`Dry::Monads` documentation](https://dry-rb.org/gems/dry-monads/1.0/do-notation/) 714 | 715 | ```ruby 716 | class MyOperation < ::TypedOperation::Base 717 | include Dry::Monads[:result] 718 | include Dry::Monads::Do.for(:perform, :create_account) 719 | 720 | param :account_name, String 721 | param :owner, ::Owner 722 | 723 | def perform 724 | account = yield create_account(account_name) 725 | yield AnotherOperation.call(account, owner) 726 | 727 | Success(account) 728 | end 729 | 730 | private 731 | 732 | def create_account(account_name) 733 | # returns Success(account) or Failure(:cant_create) 734 | end 735 | end 736 | ``` 737 | 738 | ## Installation 739 | 740 | Add this line to your application's Gemfile: 741 | 742 | ```ruby 743 | gem "typed_operation" 744 | ``` 745 | 746 | And then execute: 747 | ```bash 748 | $ bundle 749 | ``` 750 | 751 | Or install it yourself as: 752 | ```bash 753 | $ gem install typed_operation 754 | ``` 755 | 756 | ### Add an `ApplicationOperation` to your project 757 | 758 | ```ruby 759 | bin/rails g typed_operation:install 760 | ``` 761 | 762 | Use the `--dry_monads` switch to `include Dry::Monads[:result]` into your `ApplicationOperation` (don't forget to also 763 | add `gem "dry-monads"` to your Gemfile) 764 | 765 | Use the `--action_policy` switch to add the `TypedOperation::ActionPolicyAuth` module to your `ApplicationOperation` 766 | (and you will also need to add `gem "action_policy"` to your Gemfile). 767 | 768 | ```ruby 769 | bin/rails g typed_operation:install --dry_monads --action_policy 770 | ``` 771 | 772 | ## Generate a new Operation 773 | 774 | ```ruby 775 | bin/rails g typed_operation TestOperation 776 | ``` 777 | 778 | You can optionally specify the directory to generate the operation in: 779 | 780 | ```ruby 781 | bin/rails g typed_operation TestOperation --path=app/operations 782 | ``` 783 | 784 | The default path is `app/operations`. 785 | 786 | The generator will also create a test file. 787 | 788 | ## Contributing 789 | 790 | Bug reports and pull requests are welcome on GitHub at https://github.com/stevegeek/typed_operation. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/stevegeek/typed_operation/blob/master/CODE_OF_CONDUCT.md). 791 | 792 | ## License 793 | 794 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 795 | 796 | ## Code of Conduct 797 | 798 | Everyone interacting in the TypedOperation project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/stevegeek/typed_operation/blob/master/CODE_OF_CONDUCT.md). 799 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "bundler/gem_tasks" 3 | 4 | desc "Run all tests" 5 | task :test do 6 | sh "bin/test" 7 | end 8 | 9 | desc "Run Rails tests" 10 | task :rails_test do 11 | sh "bin/rails_test" 12 | end 13 | 14 | task default: [:test, :rails_test] 15 | -------------------------------------------------------------------------------- /bin/rails_test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $: << File.expand_path("../test", __dir__) 3 | 4 | require "bundler/setup" 5 | require "rails/plugin/test" 6 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV["NO_RAILS"] = "true" 4 | 5 | require "bundler/setup" 6 | # 7 | # require "type_fusion" 8 | # 9 | # TypeFusion.config do |config| 10 | # # === application_name 11 | # # 12 | # # Set application_name to a string which is used to know where the samples 13 | # # came from. Set application_name to an empty string if you wish to not 14 | # # send the application name alongside the samples. 15 | # # 16 | # # Default: "TypeFusion" 17 | # # Default when using Rails: Rails.application.class.module_parent_name 18 | # # 19 | # # config.application_name = "YourApplication" 20 | # 21 | # # === endpoint 22 | # # 23 | # # Set endpoint to an URL where TypeFusion should send the samples to. 24 | # # 25 | # # Default: "https://gem.sh/api/v1/types/samples" 26 | # # 27 | # # config.endpoint = "https://your-domain.com/api/v1/types/samples" 28 | # 29 | # # === type_sample_request 30 | # # 31 | # # Set type_sample_request to a lambda which resolves to true/false 32 | # # to set if type sampling should be enabled for the whole rack request. 33 | # # 34 | # # Default: ->(rack_env) { [true, false, false, false].sample } 35 | # # 36 | # # config.type_sample_request = ->(rack_env) { [true, false, false, false].sample } 37 | # 38 | # # === type_sample_tracepoint_path 39 | # # 40 | # # Set type_sample_tracepoint_path to a lambda which resolves 41 | # # to true/false to check if a tracepoint_path should be sampled 42 | # # or not. 43 | # # 44 | # # This can be useful when you want to only sample method calls for 45 | # # certain gems or want to exclude a gem from being sampled. 46 | # # 47 | # # Example: 48 | # config.type_sample_tracepoint_path = ->(tracepoint_path) { 49 | # return true if tracepoint_path.include?("typed_operation") 50 | # 51 | # false 52 | # } 53 | # # 54 | # # Default: ->(tracepoint_path) { true } 55 | # # 56 | # # config.type_sample_tracepoint_path = ->(tracepoint_path) { true } 57 | # 58 | # # === type_sample_call_rate 59 | # # 60 | # # Set type_sample_call_rate to 1.0 to capture 100% of method calls 61 | # # within a rack request. 62 | # # 63 | # # Default: 0.001 64 | # # 65 | # config.type_sample_call_rate = 0.01 66 | # end 67 | 68 | path_to_tests = File.expand_path("../test", __dir__) 69 | # add to load path 70 | $: << path_to_tests 71 | # Get files to require 72 | files = Dir.glob(File.join(path_to_tests, "typed_operation/**/*.rb")) 73 | # Require files 74 | files.each { |file| require file.gsub(/^test\/|.rb$/, "") } 75 | -------------------------------------------------------------------------------- /gemfiles/rails_7.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "literal", "~> 1.0" 6 | gem "standard" 7 | gem "simplecov" 8 | gem "rails", "~> 7.2" 9 | gem "sqlite3" 10 | gem "dry-monads" 11 | gem "action_policy" 12 | gem "appraisal", require: false 13 | 14 | gemspec path: "../" 15 | -------------------------------------------------------------------------------- /gemfiles/rails_8.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "literal", "~> 1.0" 6 | gem "standard" 7 | gem "simplecov" 8 | gem "rails", "~> 8.0" 9 | gem "sqlite3" 10 | gem "dry-monads" 11 | gem "action_policy" 12 | gem "appraisal", require: false 13 | 14 | gemspec path: "../" 15 | -------------------------------------------------------------------------------- /lib/generators/USAGE: -------------------------------------------------------------------------------- 1 | Operation Generator 2 | =================== 3 | 4 | Description: 5 | ------------ 6 | This generator creates an 'TypedOperation' class that inherits from ApplicationOperation. 7 | 8 | Command: 9 | -------- 10 | rails generate typed_operation NAME [options] 11 | 12 | Arguments: 13 | ---------- 14 | NAME: The class name of the Operation class to be generated, e.g. 'MyOperation'. 15 | 16 | Options: 17 | -------- 18 | --path=path/to/directory: The path to the directory in which the operation class should be generated. The generated class will be namespaced according to the provided path. 19 | 20 | Example: 21 | -------- 22 | rails generate typed_operation MyOperation --path=app/operations 23 | 24 | This will create the following file: 25 | app/operations/my_operation.rb 26 | -------------------------------------------------------------------------------- /lib/generators/templates/operation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | <% if namespace_name.present? %> 4 | module <%= namespace_name %> 5 | class <%= name %> < ::ApplicationOperation 6 | # Replace with implementation... 7 | positional_param :required_positional_param, String 8 | param :required_named_param, String 9 | param :an_optional_param, Integer, optional: true do |value| 10 | value.to_i 11 | end 12 | 13 | def prepare 14 | # Prepare... 15 | end 16 | 17 | def perform 18 | # Perform... 19 | "Hello World!" 20 | end 21 | end 22 | end 23 | <% else %> 24 | class <%= name %> < ::ApplicationOperation 25 | # Replace with implementation... 26 | positional_param :required_positional_param, String 27 | param :required_named_param, String 28 | param :an_optional_param, Integer, optional: true do |value| 29 | value.to_i 30 | end 31 | 32 | def prepare 33 | # Prepare... 34 | end 35 | 36 | def perform 37 | # Perform... 38 | "Hello World!" 39 | end 40 | end 41 | <% end %> 42 | -------------------------------------------------------------------------------- /lib/generators/templates/operation_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | <% if namespace_name.present? %> 6 | module <%= namespace_name %> 7 | class <%= name %>Test < ActiveSupport::TestCase 8 | def setup 9 | @operation = <%= name %>.new(required_param: "test") 10 | end 11 | 12 | test "should raise ParameterError if required param is nil" do 13 | assert_raises(ParameterError) do 14 | <%= name %>.new(required_param: nil) 15 | end 16 | end 17 | 18 | test "should convert optional_param if it is not a string" do 19 | assert_equal <%= name %>.new(required_param: "foo", optional_param: 123).converts_param, "123" 20 | end 21 | 22 | test "call returns after operation" do 23 | result = @operation.call 24 | assert_equal result, "Hello World!" 25 | end 26 | end 27 | end 28 | <% else %> 29 | class <%= name %>Test < ActiveSupport::TestCase 30 | def setup 31 | @operation = <%= name %>.new(required_param: "test") 32 | end 33 | 34 | test "should raise ParameterError if required param is nil" do 35 | assert_raises(ParameterError) do 36 | <%= name %>.new(required_param: nil) 37 | end 38 | end 39 | 40 | test "should convert optional_param if it is not a string" do 41 | assert_equal <%= name %>.new(required_param: "foo", optional_param: 123).converts_param, "123" 42 | end 43 | 44 | test "call returns after operation" do 45 | result = @operation.call 46 | assert_equal result, "Hello World!" 47 | end 48 | end 49 | <% end %> 50 | -------------------------------------------------------------------------------- /lib/generators/typed_operation/install/USAGE: -------------------------------------------------------------------------------- 1 | Install Generator 2 | ================= 3 | 4 | Description: 5 | ------------ 6 | This generator installs a `ApplicationOperation` class which is your base class for all Operations. 7 | 8 | Command: 9 | -------- 10 | rails generate typed_operation:install 11 | 12 | Options: 13 | -------- 14 | --dry_monads: if specified the ApplicationOperation will include dry-monads Result and Do notation. 15 | --action_policy: if specified the ApplicationOperation will include action_policy authorization integration. 16 | 17 | Example: 18 | -------- 19 | rails generate typed_operation:install 20 | 21 | This will create the following file: 22 | app/operations/application_operation.rb 23 | -------------------------------------------------------------------------------- /lib/generators/typed_operation/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators/base" 4 | 5 | module TypedOperation 6 | module Install 7 | class InstallGenerator < Rails::Generators::Base 8 | class_option :dry_monads, type: :boolean, default: false 9 | class_option :action_policy, type: :boolean, default: false 10 | 11 | source_root File.expand_path("templates", __dir__) 12 | 13 | def copy_application_operation_file 14 | template "application_operation.rb", "app/operations/application_operation.rb" 15 | end 16 | 17 | private 18 | 19 | def include_dry_monads? 20 | options[:dry_monads] 21 | end 22 | 23 | def include_action_policy? 24 | options[:action_policy] 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/generators/typed_operation/install/templates/application_operation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationOperation < ::TypedOperation::Base 4 | <% if include_action_policy? -%> 5 | include TypedOperation::ActionPolicyAuth 6 | 7 | <% end -%> 8 | <% if include_dry_monads? -%> 9 | include Dry::Monads[:result] 10 | include Dry::Monads::Do.for(:perform) 11 | 12 | # Helper to execute then unwrap a successful result or raise an exception 13 | def call! 14 | call.value! 15 | end 16 | 17 | <% end -%> 18 | # Other common parameters & methods for Operations of this application... 19 | # Some examples: 20 | # 21 | # def self.operation_key 22 | # name.underscore.to_sym 23 | # end 24 | # 25 | # def operation_key 26 | # self.class.operation_key 27 | # end 28 | # 29 | # # Translation and localization 30 | # 31 | # def translate(key, **) 32 | # key = "operations.#{operation_key}.#{key}" if key.start_with?(".") 33 | # I18n.t(key, **) 34 | # end 35 | # alias_method :t, :translate 36 | end 37 | -------------------------------------------------------------------------------- /lib/generators/typed_operation_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators/named_base" 4 | 5 | class TypedOperationGenerator < Rails::Generators::NamedBase 6 | source_root File.expand_path("templates", __dir__) 7 | 8 | class_option :path, type: :string, default: "app/operations" 9 | 10 | def generate_operation 11 | template( 12 | File.join(self.class.source_root, "operation.rb"), 13 | File.join(options[:path], "#{file_name}.rb") 14 | ) 15 | template( 16 | File.join(self.class.source_root, "operation_test.rb"), 17 | File.join("test/", options[:path].gsub(/\Aapp\//, ""), "#{file_name}_test.rb") 18 | ) 19 | end 20 | 21 | private 22 | 23 | def namespace_name 24 | namespace_path = options[:path].gsub(/^app\/[^\/]*\//, "") 25 | namespace_path.split("/").map(&:camelize).join("::") 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/typed_operation.rb: -------------------------------------------------------------------------------- 1 | require "literal" 2 | 3 | require "typed_operation/version" 4 | require "typed_operation/railtie" if defined?(Rails::Railtie) 5 | require "typed_operation/operations/introspection" 6 | require "typed_operation/operations/parameters" 7 | require "typed_operation/operations/partial_application" 8 | require "typed_operation/operations/callable" 9 | require "typed_operation/operations/lifecycle" 10 | require "typed_operation/operations/property_builder" 11 | require "typed_operation/operations/executable" 12 | require "typed_operation/curried" 13 | require "typed_operation/immutable_base" 14 | require "typed_operation/base" 15 | require "typed_operation/partially_applied" 16 | require "typed_operation/prepared" 17 | 18 | module TypedOperation 19 | class InvalidOperationError < StandardError; end 20 | 21 | class MissingParameterError < ArgumentError; end 22 | 23 | class ParameterError < TypeError; end 24 | end 25 | -------------------------------------------------------------------------------- /lib/typed_operation/action_policy_auth.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "action_policy" 4 | 5 | # An optional module to include in your operation to enable authorization via ActionPolicies 6 | module TypedOperation 7 | class MissingAuthentication < StandardError; end 8 | 9 | module ActionPolicyAuth 10 | # Base class for any action policy classes used by operations 11 | class OperationPolicy 12 | include ActionPolicy::Policy::Core 13 | include ActionPolicy::Policy::Authorization 14 | include ActionPolicy::Policy::PreCheck 15 | include ActionPolicy::Policy::Reasons 16 | include ActionPolicy::Policy::Aliases 17 | include ActionPolicy::Policy::Scoping 18 | include ActionPolicy::Policy::Cache 19 | include ActionPolicy::Policy::CachedApply 20 | end 21 | 22 | def self.included(base) 23 | base.include ::ActionPolicy::Behaviour 24 | base.extend ClassMethods 25 | end 26 | 27 | module ClassMethods 28 | # Configure the operation to use ActionPolicy for authorization 29 | def authorized_via(*via, with: nil, to: nil, record: nil, &auth_block) 30 | # If a block is provided, you must not provide a policy class or method 31 | raise ArgumentError, "You must not provide a policy class or method when using a block" if auth_block && (with || to) 32 | 33 | parameters = positional_parameters + keyword_parameters 34 | raise ArgumentError, "authorize_via must be called with a valid param name" unless via.all? { |param| parameters.include?(param) } 35 | @_authorized_via_param = via 36 | 37 | action_type_method = :"#{action_type}?" if action_type 38 | # If an method name is provided, use it 39 | policy_method = to || action_type_method || raise(::TypedOperation::InvalidOperationError, "You must provide an action type or policy method name") 40 | @_policy_method = policy_method 41 | # If a policy class is provided, use it 42 | @_policy_class = if with 43 | with 44 | elsif auth_block 45 | policy_class = Class.new(OperationPolicy) do 46 | authorize(*via) 47 | 48 | define_method(policy_method, &auth_block) 49 | end 50 | const_set(:Policy, policy_class) 51 | policy_class 52 | else 53 | raise ::TypedOperation::InvalidOperationError, "You must provide either a policy class or a block" 54 | end 55 | 56 | if record 57 | unless parameters.include?(record) || method_defined?(record) || private_instance_methods.include?(record) 58 | raise ArgumentError, "to_authorize must be called with a valid param or method name" 59 | end 60 | @_to_authorize_param = record 61 | end 62 | 63 | # Configure action policy to use the param named in via as the context when instantiating the policy 64 | # ::ActionPolicy::Behaviour does not provide a authorize(*ids) method, so we have call once per param 65 | via.each do |param| 66 | authorize param 67 | end 68 | end 69 | 70 | def action_type(type = nil) 71 | @_action_type = type.to_sym if type 72 | @_action_type 73 | end 74 | 75 | def operation_policy_method 76 | @_policy_method 77 | end 78 | 79 | def operation_policy_class 80 | @_policy_class 81 | end 82 | 83 | def operation_record_to_authorize 84 | @_to_authorize_param 85 | end 86 | 87 | def checks_authorization? 88 | !(@_authorized_via_param.nil? || @_authorized_via_param.empty?) 89 | end 90 | 91 | # You can use this on an operation base class to ensure and subclasses always enable authorization 92 | def verify_authorized! 93 | return if verify_authorized? 94 | @_verify_authorized = true 95 | end 96 | 97 | def verify_authorized? 98 | @_verify_authorized 99 | end 100 | 101 | def inherited(subclass) 102 | super 103 | subclass.instance_variable_set(:@_authorized_via_param, @_authorized_via_param) 104 | subclass.instance_variable_set(:@_verify_authorized, @_verify_authorized) 105 | subclass.instance_variable_set(:@_policy_class, @_policy_class) 106 | subclass.instance_variable_set(:@_policy_method, @_policy_method) 107 | subclass.instance_variable_set(:@_action_type, @_action_type) 108 | end 109 | end 110 | 111 | private 112 | 113 | # Redefine it as private 114 | def execute_operation 115 | if self.class.verify_authorized? && !self.class.checks_authorization? 116 | raise ::TypedOperation::MissingAuthentication, "Operation #{self.class.name} must authorize. Remember to use `.authorize_via`" 117 | end 118 | operation_check_authorized! if self.class.checks_authorization? 119 | super 120 | end 121 | 122 | def operation_check_authorized! 123 | policy = self.class.operation_policy_class 124 | raise "No Action Policy policy class provided, or no #{self.class.name}::Policy found for this action" unless policy 125 | policy_method = self.class.operation_policy_method 126 | raise "No policy method provided or action_type not set for #{self.class.name}" unless policy_method 127 | # Record to authorize, if nil then action policy tries to work it out implicitly 128 | record_to_authorize = send(self.class.operation_record_to_authorize) if self.class.operation_record_to_authorize 129 | 130 | authorize! record_to_authorize, to: policy_method, with: policy 131 | rescue ::ActionPolicy::Unauthorized => e 132 | on_authorization_failure(e) 133 | raise e 134 | end 135 | 136 | # A hook for subclasses to override to do something on an authorization failure 137 | def on_authorization_failure(authorization_error) 138 | # noop 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /lib/typed_operation/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TypedOperation 4 | class Base < Literal::Struct 5 | extend Operations::Introspection 6 | extend Operations::Parameters 7 | extend Operations::PartialApplication 8 | 9 | include Operations::Lifecycle 10 | include Operations::Callable 11 | include Operations::Executable 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/typed_operation/curried.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TypedOperation 4 | class Curried 5 | def initialize(operation_class, partial_operation = nil) 6 | @operation_class = operation_class 7 | @partial_operation = partial_operation || operation_class.with 8 | end 9 | 10 | def call(arg) 11 | raise ArgumentError, "A prepared operation should not be curried" if @partial_operation.prepared? 12 | 13 | next_partially_applied = if next_parameter_positional? 14 | @partial_operation.with(arg) 15 | else 16 | @partial_operation.with(next_keyword_parameter => arg) 17 | end 18 | if next_partially_applied.prepared? 19 | next_partially_applied.call 20 | else 21 | Curried.new(@operation_class, next_partially_applied) 22 | end 23 | end 24 | 25 | def to_proc 26 | method(:call).to_proc 27 | end 28 | 29 | private 30 | 31 | def next_keyword_parameter 32 | remaining = @operation_class.required_keyword_parameters - @partial_operation.keyword_args.keys 33 | remaining.first 34 | end 35 | 36 | def next_parameter_positional? 37 | @partial_operation.positional_args.size < @operation_class.required_positional_parameters.size 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/typed_operation/immutable_base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TypedOperation 4 | class ImmutableBase < Literal::Data 5 | extend Operations::Introspection 6 | extend Operations::Parameters 7 | extend Operations::PartialApplication 8 | 9 | include Operations::Lifecycle 10 | include Operations::Callable 11 | include Operations::Executable 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/typed_operation/operations/callable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TypedOperation 4 | module Operations 5 | module Callable 6 | def self.included(base) 7 | base.extend(CallableMethods) 8 | end 9 | 10 | module CallableMethods 11 | def call(...) 12 | new(...).call 13 | end 14 | 15 | def to_proc 16 | method(:call).to_proc 17 | end 18 | end 19 | 20 | include CallableMethods 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/typed_operation/operations/executable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TypedOperation 4 | module Operations 5 | module Executable 6 | def call 7 | execute_operation 8 | end 9 | 10 | def execute_operation 11 | before_execute_operation 12 | retval = perform 13 | after_execute_operation(retval) 14 | end 15 | 16 | def before_execute_operation 17 | # noop 18 | end 19 | 20 | def after_execute_operation(retval) 21 | retval 22 | end 23 | 24 | def perform 25 | raise InvalidOperationError, "Operation #{self.class} does not implement #perform" 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/typed_operation/operations/introspection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TypedOperation 4 | module Operations 5 | # Introspection methods 6 | module Introspection 7 | def positional_parameters 8 | literal_properties.filter_map { |property| property.name if property.positional? } 9 | end 10 | 11 | def keyword_parameters 12 | literal_properties.filter_map { |property| property.name if property.keyword? } 13 | end 14 | 15 | def required_parameters 16 | literal_properties.filter { |property| property.required? } 17 | end 18 | 19 | def required_positional_parameters 20 | required_parameters.filter_map { |property| property.name if property.positional? } 21 | end 22 | 23 | def required_keyword_parameters 24 | required_parameters.filter_map { |property| property.name if property.keyword? } 25 | end 26 | 27 | def optional_positional_parameters 28 | positional_parameters - required_positional_parameters 29 | end 30 | 31 | def optional_keyword_parameters 32 | keyword_parameters - required_keyword_parameters 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/typed_operation/operations/lifecycle.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TypedOperation 4 | module Operations 5 | module Lifecycle 6 | # This is called by Literal on initialization of underlying Struct/Data 7 | def after_initialize 8 | prepare if respond_to?(:prepare) 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/typed_operation/operations/parameters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TypedOperation 4 | module Operations 5 | # Method to define parameters for your operation. 6 | module Parameters 7 | # Override literal `prop` to prevent creating writers (Literal::Data does this by default) 8 | def prop(name, type, kind = :keyword, reader: :public, writer: :public, default: nil) 9 | if self < ImmutableBase 10 | super(name, type, kind, reader:, default:) 11 | else 12 | super(name, type, kind, reader:, writer: false, default:) 13 | end 14 | end 15 | 16 | # Parameter for keyword argument, or a positional argument if you use positional: true 17 | # Required, but you can set a default or use optional: true if you want optional 18 | def param(name, signature = :any, **options, &converter) 19 | PropertyBuilder.new(self, name, signature, options).define(&converter) 20 | end 21 | 22 | # Alternative DSL 23 | 24 | # Parameter for positional argument 25 | def positional_param(name, signature = :any, **options, &converter) 26 | param(name, signature, **options.merge(positional: true), &converter) 27 | end 28 | 29 | # Parameter for a keyword or named argument 30 | def named_param(name, signature = :any, **options, &converter) 31 | param(name, signature, **options.merge(positional: false), &converter) 32 | end 33 | 34 | # Wrap a type signature in a NilableType meaning it is optional to TypedOperation 35 | def optional(type_signature) 36 | Literal::Types::NilableType.new(type_signature) 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/typed_operation/operations/partial_application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TypedOperation 4 | module Operations 5 | module PartialApplication 6 | def with(...) 7 | PartiallyApplied.new(self, ...).with 8 | end 9 | alias_method :[], :with 10 | 11 | def curry 12 | Curried.new(self) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/typed_operation/operations/property_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TypedOperation 4 | module Operations 5 | class PropertyBuilder 6 | def initialize(typed_operation, parameter_name, type_signature, options) 7 | @typed_operation = typed_operation 8 | @name = parameter_name 9 | @signature = type_signature 10 | @optional = options[:optional] # Wraps signature in NilableType 11 | @positional = options[:positional] # Changes kind to positional 12 | @reader = options[:reader] || :public 13 | @default_key = options.key?(:default) 14 | @default = options[:default] 15 | 16 | prepare_type_signature_for_literal 17 | end 18 | 19 | def define(&converter) 20 | # If nilable, then converter should not attempt to call the type converter block if the value is nil 21 | coerce_by = if type_nilable? && converter 22 | ->(v) { (v == Literal::Null || v.nil?) ? v : converter.call(v) } 23 | else 24 | converter 25 | end 26 | @typed_operation.prop( 27 | @name, 28 | @signature, 29 | @positional ? :positional : :keyword, 30 | default: default_value_for_literal, 31 | reader: @reader, 32 | &coerce_by 33 | ) 34 | end 35 | 36 | private 37 | 38 | def prepare_type_signature_for_literal 39 | @signature = Literal::Types::NilableType.new(@signature) if needs_to_be_nilable? 40 | union_with_nil_to_support_nil_default 41 | validate_positional_order_params! if @positional 42 | end 43 | 44 | # If already wrapped in a Nilable then don't wrap again 45 | def needs_to_be_nilable? 46 | @optional && !type_nilable? 47 | end 48 | 49 | def type_nilable? 50 | @signature.is_a?(Literal::Types::NilableType) 51 | end 52 | 53 | def union_with_nil_to_support_nil_default 54 | @signature = Literal::Types::UnionType.new(@signature, NilClass) if has_default_value_nil? 55 | end 56 | 57 | def has_default_value_nil? 58 | default_provided? && @default.nil? 59 | end 60 | 61 | def validate_positional_order_params! 62 | # Optional ones can always be added after required ones, or before any others, but required ones must be first 63 | unless type_nilable? || @typed_operation.optional_positional_parameters.empty? 64 | raise ParameterError, "Cannot define required positional parameter '#{@name}' after optional positional parameters" 65 | end 66 | end 67 | 68 | def default_provided? 69 | @default_key 70 | end 71 | 72 | def default_value_for_literal 73 | if has_default_value_nil? || type_nilable? 74 | -> {} 75 | else 76 | @default 77 | end 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/typed_operation/partially_applied.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TypedOperation 4 | class PartiallyApplied 5 | def initialize(operation_class, *positional_args, **keyword_args) 6 | @operation_class = operation_class 7 | @positional_args = positional_args 8 | @keyword_args = keyword_args 9 | end 10 | 11 | def with(*positional, **keyword) 12 | all_positional = positional_args + positional 13 | all_kw_args = keyword_args.merge(keyword) 14 | 15 | validate_positional_arg_count!(all_positional.size) 16 | 17 | if partially_applied?(all_positional, all_kw_args) 18 | PartiallyApplied.new(operation_class, *all_positional, **all_kw_args) 19 | else 20 | Prepared.new(operation_class, *all_positional, **all_kw_args) 21 | end 22 | end 23 | alias_method :[], :with 24 | 25 | def curry 26 | Curried.new(operation_class, self) 27 | end 28 | 29 | def call(...) 30 | prepared = with(...) 31 | return prepared.operation.call if prepared.is_a?(Prepared) 32 | raise MissingParameterError, "Cannot call PartiallyApplied operation #{operation_class.name} (key: #{operation_class.name}), are you expecting it to be Prepared?" 33 | end 34 | 35 | def operation 36 | raise MissingParameterError, "Cannot instantiate Operation #{operation_class.name} (key: #{operation_class.name}), as it is only partially applied." 37 | end 38 | 39 | def prepared? 40 | false 41 | end 42 | 43 | def to_proc 44 | method(:call).to_proc 45 | end 46 | 47 | def deconstruct 48 | positional_args + keyword_args.values 49 | end 50 | 51 | def deconstruct_keys(keys) 52 | h = keyword_args.dup 53 | positional_args.each_with_index { |v, i| h[positional_parameters[i]] = v } 54 | keys ? h.slice(*keys) : h 55 | end 56 | 57 | attr_reader :positional_args, :keyword_args 58 | 59 | private 60 | 61 | attr_reader :operation_class 62 | 63 | def required_positional_parameters 64 | @required_positional_parameters ||= operation_class.required_positional_parameters 65 | end 66 | 67 | def required_keyword_parameters 68 | @required_keyword_parameters ||= operation_class.required_keyword_parameters 69 | end 70 | 71 | def positional_parameters 72 | @positional_parameters ||= operation_class.positional_parameters 73 | end 74 | 75 | def validate_positional_arg_count!(count) 76 | if count > positional_parameters.size 77 | raise ArgumentError, "Too many positional arguments provided for #{operation_class.name} (key: #{operation_class.name})" 78 | end 79 | end 80 | 81 | def partially_applied?(all_positional, all_kw_args) 82 | missing_positional = required_positional_parameters.size - all_positional.size 83 | missing_keys = required_keyword_parameters - all_kw_args.keys 84 | 85 | missing_positional > 0 || missing_keys.size > 0 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/typed_operation/prepared.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TypedOperation 4 | class Prepared < PartiallyApplied 5 | def operation 6 | operation_class.new(*@positional_args, **@keyword_args) 7 | end 8 | 9 | def prepared? 10 | true 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/typed_operation/railtie.rb: -------------------------------------------------------------------------------- 1 | module TypedOperation 2 | class Railtie < ::Rails::Railtie 3 | generators do 4 | require "generators/typed_operation/install/install_generator" 5 | require "generators/typed_operation_generator" 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/typed_operation/version.rb: -------------------------------------------------------------------------------- 1 | module TypedOperation 2 | VERSION = "1.0.0.beta3" 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /test/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevegeek/typed_operation/275da305b9d2197836b260062d9b4f20188d09e5/test/dummy/app/assets/images/.keep -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* Application styles */ 2 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevegeek/typed_operation/275da305b9d2197836b260062d9b4f20188d09e5/test/dummy/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevegeek/typed_operation/275da305b9d2197836b260062d9b4f20188d09e5/test/dummy/app/models/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | 6 | <%= csrf_meta_tags %> 7 | <%= csp_meta_tag %> 8 | 9 | <%= stylesheet_link_tag "application" %> 10 | 11 | 12 | 13 | <%= yield %> 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /test/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /test/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /test/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?("config/database.yml") 22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | end 34 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | require "typed_operation" 9 | 10 | module Dummy 11 | class Application < Rails::Application 12 | config.load_defaults Rails::VERSION::STRING.to_f 13 | 14 | # Configuration for the application, engines, and railties goes here. 15 | # 16 | # These settings can be overridden in specific environments using the files 17 | # in config/environments, which are processed later. 18 | # 19 | # config.time_zone = "Central Time (US & Canada)" 20 | # config.eager_load_paths << Rails.root.join("extras") 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 6 | -------------------------------------------------------------------------------- /test/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: dummy_production 11 | -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable server timing 18 | config.server_timing = true 19 | 20 | # Enable/disable caching. By default caching is disabled. 21 | # Run rails dev:cache to toggle caching. 22 | if Rails.root.join("tmp/caching-dev.txt").exist? 23 | config.action_controller.perform_caching = true 24 | config.action_controller.enable_fragment_cache_logging = true 25 | 26 | config.cache_store = :memory_store 27 | config.public_file_server.headers = { 28 | "Cache-Control" => "public, max-age=#{2.days.to_i}" 29 | } 30 | else 31 | config.action_controller.perform_caching = false 32 | 33 | config.cache_store = :null_store 34 | end 35 | 36 | # Store uploaded files on the local file system (see config/storage.yml for options). 37 | config.active_storage.service = :local 38 | 39 | # Don't care if the mailer can't send. 40 | config.action_mailer.raise_delivery_errors = false 41 | 42 | config.action_mailer.perform_caching = false 43 | 44 | # Print deprecation notices to the Rails logger. 45 | config.active_support.deprecation = :log 46 | 47 | # Raise exceptions for disallowed deprecations. 48 | config.active_support.disallowed_deprecation = :raise 49 | 50 | # Tell Active Support which deprecation messages to disallow. 51 | config.active_support.disallowed_deprecation_warnings = [] 52 | 53 | # Raise an error on page load if there are pending migrations. 54 | config.active_record.migration_error = :page_load 55 | 56 | # Highlight code that triggered database queries in logs. 57 | config.active_record.verbose_query_logs = true 58 | 59 | # Raises error for missing translations. 60 | # config.i18n.raise_on_missing_translations = true 61 | 62 | # Annotate rendered view with file names. 63 | # config.action_view.annotate_rendered_view_with_filenames = true 64 | 65 | # Uncomment if you wish to allow Action Cable access from any origin. 66 | # config.action_cable.disable_request_forgery_protection = true 67 | end 68 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? 26 | 27 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 28 | # config.asset_host = "http://assets.example.com" 29 | 30 | # Specifies the header that your server uses for sending files. 31 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 32 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 33 | 34 | # Store uploaded files on the local file system (see config/storage.yml for options). 35 | config.active_storage.service = :local 36 | 37 | # Mount Action Cable outside main process or domain. 38 | # config.action_cable.mount_path = nil 39 | # config.action_cable.url = "wss://example.com/cable" 40 | # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] 41 | 42 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 43 | # config.force_ssl = true 44 | 45 | # Include generic and useful information about system operation, but avoid logging too much 46 | # information to avoid inadvertent exposure of personally identifiable information (PII). 47 | config.log_level = :info 48 | 49 | # Prepend all log lines with the following tags. 50 | config.log_tags = [:request_id] 51 | 52 | # Use a different cache store in production. 53 | # config.cache_store = :mem_cache_store 54 | 55 | # Use a real queuing backend for Active Job (and separate queues per environment). 56 | # config.active_job.queue_adapter = :resque 57 | # config.active_job.queue_name_prefix = "dummy_production" 58 | 59 | config.action_mailer.perform_caching = false 60 | 61 | # Ignore bad email addresses and do not raise email delivery errors. 62 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 63 | # config.action_mailer.raise_delivery_errors = false 64 | 65 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 66 | # the I18n.default_locale when a translation cannot be found). 67 | config.i18n.fallbacks = true 68 | 69 | # Don't log any deprecations. 70 | config.active_support.report_deprecations = false 71 | 72 | # Use default logging formatter so that PID and timestamp are not suppressed. 73 | config.log_formatter = ::Logger::Formatter.new 74 | 75 | # Use a different logger for distributed setups. 76 | # require "syslog/logger" 77 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name") 78 | 79 | if ENV["RAILS_LOG_TO_STDOUT"].present? 80 | logger = ActiveSupport::Logger.new($stdout) 81 | logger.formatter = config.log_formatter 82 | config.logger = ActiveSupport::TaggedLogging.new(logger) 83 | end 84 | 85 | # Do not dump schema after migrations. 86 | config.active_record.dump_schema_after_migration = false 87 | end 88 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # Turn false under Spring and add config.action_view.cache_template_loading = true. 12 | config.cache_classes = true 13 | 14 | # Eager loading loads your whole application. When running a single test locally, 15 | # this probably isn't necessary. It's a good idea to do in a continuous integration 16 | # system, or in some way before deploying your code. 17 | config.eager_load = ENV["CI"].present? 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | config.action_dispatch.show_exceptions = false 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | # Store uploaded files on the local file system in a temporary directory. 37 | config.active_storage.service = :test 38 | 39 | config.action_mailer.perform_caching = false 40 | 41 | # Tell Action Mailer not to deliver emails to the real world. 42 | # The :test delivery method accumulates sent emails in the 43 | # ActionMailer::Base.deliveries array. 44 | config.action_mailer.delivery_method = :test 45 | 46 | # Print deprecation notices to the stderr. 47 | config.active_support.deprecation = :stderr 48 | 49 | # Raise exceptions for disallowed deprecations. 50 | config.active_support.disallowed_deprecation = :raise 51 | 52 | # Tell Active Support which deprecation messages to disallow. 53 | config.active_support.disallowed_deprecation_warnings = [] 54 | 55 | # Raises error for missing translations. 56 | # config.i18n.raise_on_missing_translations = true 57 | 58 | # Annotate rendered view with file names. 59 | # config.action_view.annotate_rendered_view_with_filenames = true 60 | end 61 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap and inline scripts 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be filtered from the log file. Use this to limit dissemination of 4 | # sensitive information. See the ActiveSupport::ParameterFilter documentation for supported 5 | # notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 8 | ] 9 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /test/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # "true": "foo" 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /test/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 12 | # terminating a worker in development environments. 13 | # 14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 15 | 16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 17 | # 18 | port ENV.fetch("PORT") { 3000 } 19 | 20 | # Specifies the `environment` that Puma will run in. 21 | # 22 | environment ENV.fetch("RAILS_ENV") { "development" } 23 | 24 | # Specifies the `pidfile` that Puma will use. 25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 26 | 27 | # Specifies the number of `workers` to boot in clustered mode. 28 | # Workers are forked web server processes. If using threads and workers together 29 | # the concurrency of the application would be max `threads` * `workers`. 30 | # Workers do not work on JRuby or Windows (both of which do not support 31 | # processes). 32 | # 33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 34 | 35 | # Use the `preload_app!` method when specifying a `workers` number. 36 | # This directive tells Puma to first boot the application and load code 37 | # before forking the application. This takes advantage of Copy On Write 38 | # process behavior so workers use less memory. 39 | # 40 | # preload_app! 41 | 42 | # Allow puma to be restarted by `bin/rails restart` command. 43 | plugin :tmp_restart 44 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html 3 | 4 | # Defines the root path route ("/") 5 | # root "articles#index" 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket-<%= Rails.env %> 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket-<%= Rails.env %> 23 | 24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name-<%= Rails.env %> 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /test/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevegeek/typed_operation/275da305b9d2197836b260062d9b4f20188d09e5/test/dummy/lib/assets/.keep -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevegeek/typed_operation/275da305b9d2197836b260062d9b4f20188d09e5/test/dummy/log/.keep -------------------------------------------------------------------------------- /test/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevegeek/typed_operation/275da305b9d2197836b260062d9b4f20188d09e5/test/dummy/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevegeek/typed_operation/275da305b9d2197836b260062d9b4f20188d09e5/test/dummy/public/apple-touch-icon.png -------------------------------------------------------------------------------- /test/dummy/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevegeek/typed_operation/275da305b9d2197836b260062d9b4f20188d09e5/test/dummy/public/favicon.ico -------------------------------------------------------------------------------- /test/lib/generators/typed_operation/install/install_generator_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "generators/typed_operation/install/install_generator" 5 | 6 | class InstallGeneratorGeneratorTest < Rails::Generators::TestCase 7 | tests TypedOperation::Install::InstallGenerator 8 | destination Rails.root.join("tmp/generators") 9 | setup :prepare_destination 10 | teardown :prepare_destination 11 | 12 | test "generator runs without errors" do 13 | assert_nothing_raised do 14 | run_generator 15 | end 16 | end 17 | 18 | test "generator creates application_operation file" do 19 | run_generator 20 | assert_file "app/operations/application_operation.rb" 21 | end 22 | 23 | test "generated file contains correct content" do 24 | run_generator 25 | 26 | assert_file "app/operations/application_operation.rb" do |content| 27 | assert_match(/class ApplicationOperation/, content) 28 | assert_no_match(/Monads/, content) 29 | end 30 | end 31 | 32 | test "generator includes monads if included" do 33 | run_generator ["--dry_monads"] 34 | assert_file "app/operations/application_operation.rb" do |content| 35 | assert_match(/class ApplicationOperation/, content) 36 | assert_match(/Monads/, content) 37 | end 38 | end 39 | 40 | test "generator includes action_policy if included" do 41 | run_generator ["--action_policy"] 42 | assert_file "app/operations/application_operation.rb" do |content| 43 | assert_match(/class ApplicationOperation/, content) 44 | assert_match(/TypedOperation::ActionPolicyAuth/, content) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/lib/generators/typed_operation_generator_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "generators/typed_operation_generator" 5 | 6 | class OperationGeneratorTest < Rails::Generators::TestCase 7 | tests TypedOperationGenerator 8 | destination Rails.root.join("tmp/generators") 9 | setup :prepare_destination 10 | teardown :prepare_destination 11 | 12 | test "generator runs without errors" do 13 | assert_nothing_raised do 14 | run_generator ["TestOperation", "--path=app/operations"] 15 | end 16 | end 17 | 18 | test "generator creates operation file" do 19 | run_generator ["TestOperation", "--path=app/operations"] 20 | assert_file "app/operations/test_operation.rb" 21 | assert_file "test/operations/test_operation_test.rb" 22 | end 23 | 24 | test "generated file contains correct content" do 25 | run_generator ["TestOperation", "--path=app/operations"] 26 | 27 | assert_file "app/operations/test_operation.rb" do |content| 28 | assert_not_includes("module App::Operations", content) 29 | assert_match(/class TestOperation < ::ApplicationOperation/, content) 30 | assert_match(/positional_param :required_positional_param, String/, content) 31 | end 32 | 33 | assert_file "test/operations/test_operation_test.rb" do |content| 34 | assert_not_includes("module App::Operations", content) 35 | assert_match(/class TestOperationTest < ActiveSupport::TestCase/, content) 36 | end 37 | end 38 | 39 | test "generated file contains correct content with alternate path" do 40 | run_generator ["TestPathOperation", "--path=app/things/stuff"] 41 | 42 | assert_file "app/things/stuff/test_path_operation.rb" do |content| 43 | assert_match(/module Stuff/, content) 44 | assert_match(/class TestPathOperation < ::ApplicationOperation/, content) 45 | assert_match(/param :an_optional_param, Integer, optional: true do/, content) 46 | end 47 | 48 | assert_file "test/things/stuff/test_path_operation_test.rb" do |content| 49 | assert_match(/module Stuff/, content) 50 | assert_match(/class TestPathOperationTest < ActiveSupport::TestCase/, content) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "simplecov" 2 | SimpleCov.start 3 | 4 | if ENV["NO_RAILS"] 5 | puts "Running tests without Rails (ie not running the generator tests)" 6 | 7 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 8 | require "typed_operation" 9 | 10 | require "minitest/autorun" 11 | 12 | # require "typed_operation" 13 | # require "type_fusion/minitest" 14 | else 15 | puts "Running tests with Rails (ie also running the generator tests)" 16 | 17 | # Configure Rails Environment 18 | ENV["RAILS_ENV"] = "test" 19 | 20 | require_relative "../test/dummy/config/environment" 21 | ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)] 22 | require "rails/test_help" 23 | 24 | # Load fixtures from the engine 25 | if ActiveSupport::TestCase.respond_to?(:fixture_path=) 26 | ActiveSupport::TestCase.fixture_path = File.expand_path("fixtures", __dir__) 27 | ActionDispatch::IntegrationTest.fixture_path = ActiveSupport::TestCase.fixture_path 28 | ActiveSupport::TestCase.file_fixture_path = ActiveSupport::TestCase.fixture_path + "/files" 29 | ActiveSupport::TestCase.fixtures :all 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/typed_operation/action_policy_auth_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | require "typed_operation/action_policy_auth" 6 | 7 | module TypedOperation 8 | class ActionPolicyAuthTest < Minitest::Test 9 | User = Struct.new(:name) 10 | 11 | class TestAuthBaseOperation < ::TypedOperation::Base 12 | include ::TypedOperation::ActionPolicyAuth 13 | 14 | param :your_name, String 15 | param :initiator, optional(User) 16 | param :friend, optional(User) 17 | 18 | def perform 19 | "Hi #{your_name}! I am #{initiator&.name || "?"}" 20 | end 21 | end 22 | 23 | class TestOperationWithAuth < TestAuthBaseOperation 24 | action_type :say_hi 25 | 26 | authorized_via :initiator do 27 | initiator.name == "Admin" 28 | end 29 | end 30 | 31 | class TestOperationNoAuthConfigured < TestAuthBaseOperation 32 | action_type :say_hi 33 | end 34 | 35 | class TestOperationAuthWithInheritance < TestOperationNoAuthConfigured 36 | class MyPolicy < OperationPolicy 37 | def say_hi? 38 | true 39 | end 40 | end 41 | 42 | authorized_via :initiator, with: MyPolicy 43 | end 44 | 45 | class TestOperationWithNoPolicyAuthMethod < TestOperationNoAuthConfigured 46 | class Policy < OperationPolicy 47 | def foo? 48 | true 49 | end 50 | end 51 | 52 | authorized_via :initiator, with: Policy 53 | end 54 | 55 | class TestOperationWithRequiredAuth < TestAuthBaseOperation 56 | action_type :say_hi 57 | 58 | verify_authorized! 59 | end 60 | 61 | class TestOperationWithRequiredAuthAndNoAuthDefined < TestOperationWithRequiredAuth 62 | end 63 | 64 | class TestOperationWithRequiredAuthAndAuthDefined < TestOperationWithRequiredAuth 65 | authorized_via :initiator do 66 | initiator.name == "Admin" 67 | end 68 | end 69 | 70 | class TestOperationWithAuthRecord < TestOperationWithRequiredAuth 71 | authorized_via :initiator, record: :friend do 72 | initiator.name == "Admin" && record.name == "Alice" 73 | end 74 | end 75 | 76 | class TestOperationWithAuthViaMultiple < TestOperationWithRequiredAuth 77 | authorized_via :initiator, :friend do 78 | initiator.name == "Admin" && friend.name == "Alice" 79 | end 80 | end 81 | 82 | def setup 83 | @admin = User.new("Admin") 84 | @alice = User.new("Alice") 85 | @not_admin = User.new("Not Admin") 86 | end 87 | 88 | def test_fails_to_execute_authed_operation_with_nil_context 89 | assert_raises(ActionPolicy::AuthorizationContextMissing) { TestOperationWithAuth.new(your_name: "Alice").call } 90 | assert_raises(ActionPolicy::AuthorizationContextMissing) { TestOperationWithAuth.new(your_name: "Alice", initiator: nil).call } 91 | end 92 | 93 | def test_fails_to_execute_authed_operation_with_unauthorized_context 94 | assert_raises(ActionPolicy::Unauthorized) { TestOperationWithAuth.new(your_name: "Alice", initiator: @not_admin).call } 95 | end 96 | 97 | def test_successful_operation_with_auth 98 | assert_equal "Hi Alice! I am Admin", TestOperationWithAuth.new(your_name: "Alice", initiator: @admin).call 99 | end 100 | 101 | # test "authorized_via raises when trying to set policy and define inline auth rule" do 102 | def test_authorized_via_raises_when_trying_to_set_policy_and_define_inline_auth_rule 103 | assert_raises(ArgumentError) do 104 | Class.new(TestAuthBaseOperation) do 105 | authorized_via :initiator, with: TestAuthBaseOperation::OperationPolicy do 106 | true 107 | end 108 | end 109 | end 110 | end 111 | 112 | def test_authorized_via_raises_when_trying_to_set_action_name_and_define_inline_auth_rule 113 | assert_raises(ArgumentError) do 114 | Class.new(TestAuthBaseOperation) do 115 | authorized_via :initiator, to: :my_name? do 116 | true 117 | end 118 | end 119 | end 120 | end 121 | 122 | def test_authorized_via_raises_when_the_via_param_option_specifies_an_invalid_param_name 123 | assert_raises(ArgumentError) do 124 | Class.new(TestAuthBaseOperation) do 125 | authorized_via(:foo) { true } 126 | end 127 | end 128 | end 129 | 130 | def test_authorized_via_raises_when_policy_is_not_set 131 | assert_raises(::TypedOperation::InvalidOperationError) do 132 | Class.new(TestAuthBaseOperation) do 133 | action_type :say_hi 134 | 135 | authorized_via :initiator 136 | end 137 | end 138 | end 139 | 140 | def test_authorized_via_raises_when_action_not_set 141 | assert_raises(::TypedOperation::InvalidOperationError) do 142 | Class.new(TestAuthBaseOperation) do 143 | authorized_via :initiator, with: TestAuthBaseOperation::OperationPolicy 144 | end 145 | end 146 | end 147 | 148 | def test_authorized_via_raises_when_policy_not_set_and_action_set_via_to 149 | assert_raises(::TypedOperation::InvalidOperationError) do 150 | Class.new(TestAuthBaseOperation) do 151 | authorized_via :initiator, to: :say_hi? 152 | end 153 | end 154 | end 155 | 156 | def test_authorized_via_raises_when_record_not_valid_param_or_method 157 | assert_raises(ArgumentError) do 158 | Class.new(TestAuthBaseOperation) do 159 | action_type :say_hi 160 | 161 | authorized_via(:initiator, record: :foo) { true } 162 | end 163 | end 164 | end 165 | 166 | def test_authorized_via_does_not_raise_when_record_is_method 167 | Class.new(TestAuthBaseOperation) do 168 | action_type :say_hi 169 | 170 | def foo 171 | "foo" 172 | end 173 | 174 | authorized_via(:initiator, record: :foo) { true } 175 | end 176 | end 177 | 178 | def test_successful_operation_without_authorization_if_its_not_needed_by_the_operation 179 | assert_equal TestOperationNoAuthConfigured.with(your_name: "Alice").call, "Hi Alice! I am ?" 180 | end 181 | 182 | def test_successful_operation_with_inheritance 183 | res = TestOperationAuthWithInheritance.with(your_name: "Alice").call(initiator: @admin) 184 | assert_equal "Hi Alice! I am Admin", res 185 | end 186 | 187 | def test_it_raises_if_policy_class_does_not_define_the_policy_method 188 | assert_raises(ActionPolicy::UnknownRule) do 189 | TestOperationWithNoPolicyAuthMethod.with(your_name: "Alice").call(initiator: @admin) 190 | end 191 | end 192 | 193 | def test_it_raises_if_no_auth_was_defined_when_required 194 | assert_raises(TypedOperation::MissingAuthentication) do 195 | TestOperationWithRequiredAuthAndNoAuthDefined.with(your_name: "Alice").call 196 | end 197 | end 198 | 199 | def test_it_does_not_raise_if_auth_was_defined_when_required 200 | assert_equal "Hi Alice! I am Admin", TestOperationWithRequiredAuthAndAuthDefined.with(your_name: "Alice", initiator: @admin).call 201 | end 202 | 203 | def test_authorizes_with_record 204 | assert_equal "Hi Alice! I am Admin", TestOperationWithAuthRecord.with(your_name: "Alice", initiator: @admin, friend: @alice).call 205 | end 206 | 207 | def test_fails_to_authorize_with_record 208 | assert_raises(ActionPolicy::Unauthorized) { TestOperationWithAuthRecord.with(your_name: "Alice", initiator: @admin, friend: @not_admin).call } 209 | end 210 | 211 | def test_authorizes_with_multiple_via 212 | assert_equal "Hi Alice! I am Admin", TestOperationWithAuthViaMultiple.with(your_name: "Alice", initiator: @admin, friend: @alice).call 213 | end 214 | 215 | def test_fails_to_authorize_with_multiple_via 216 | assert_raises(ActionPolicy::Unauthorized) { TestOperationWithAuthViaMultiple.with(your_name: "Alice", initiator: @admin, friend: @not_admin).call } 217 | end 218 | end 219 | end 220 | -------------------------------------------------------------------------------- /test/typed_operation/base_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "dry-monads" 5 | 6 | module TypedOperation 7 | class BaseTest < Minitest::Test 8 | class TestPositionalOperation < ::TypedOperation::Base 9 | param :first, String, positional: true 10 | param :second, String, optional: true, positional: true, &:to_s 11 | 12 | def before_execute_operation 13 | @before_was_called = true 14 | super 15 | end 16 | 17 | def after_execute_operation(_retval) 18 | @after_was_called = true 19 | super 20 | end 21 | 22 | def perform 23 | if second 24 | "#{first}/#{second}" 25 | else 26 | "#{first}!" 27 | end 28 | end 29 | end 30 | 31 | class TestKeywordAndPositionalOperation < ::TypedOperation::Base 32 | param :pos1, String, positional: true 33 | param :pos2, String, default: "pos2", positional: true 34 | param :kw1, String 35 | param :kw2, String, default: "kw2" 36 | 37 | def perform 38 | "#{pos1}/#{pos2}/#{kw1}/#{kw2}" 39 | end 40 | end 41 | 42 | class TestAlternativeDslOperation < ::TypedOperation::Base 43 | positional_param :pos1, String 44 | positional_param :pos2, String, default: "pos2" 45 | positional_param :pos3, optional(String) 46 | named_param :kw1, String 47 | named_param :kw2, String, default: "kw2" 48 | named_param :kw3, optional(String) 49 | 50 | def perform 51 | "#{pos1}/#{pos2}/#{pos3}/#{kw1}/#{kw2}/#{kw3}" 52 | end 53 | end 54 | 55 | class TestOperation < ::TypedOperation::Base 56 | param :foo, String 57 | param :bar, String 58 | param :baz, String do |value| 59 | value.to_s 60 | end 61 | 62 | param :with_default, String, default: "qux" 63 | param :can_be_nil, Integer, optional: true 64 | param :can_also_be_nil, TypedOperation::Base, default: nil 65 | 66 | def prepare 67 | @local_var = 123 68 | end 69 | 70 | def perform 71 | "It worked, (#{foo}/#{bar}/#{baz}/#{with_default}/#{can_be_nil}/#{can_also_be_nil})" 72 | end 73 | end 74 | 75 | class TestCurryOperation < ::TypedOperation::Base 76 | param :pos1, String, positional: true 77 | param :pos2, String, positional: true 78 | param :pos3, optional(String), positional: true 79 | param :kw1, String 80 | param :kw2, String 81 | param :kw3, optional(String) 82 | 83 | def perform 84 | "#{pos1}/#{pos2}/#{pos3}/#{kw1}/#{kw2}/#{kw3}" 85 | end 86 | end 87 | 88 | class MyMutableOperation < ::TypedOperation::Base 89 | param :my_hash, Hash, default: -> { {} } 90 | end 91 | 92 | class TestInvalidOperation < ::TypedOperation::Base 93 | end 94 | 95 | def test_class_method_positional_parameters 96 | assert_equal %i[first second], TestPositionalOperation.positional_parameters 97 | assert_equal %i[pos1 pos2], TestKeywordAndPositionalOperation.positional_parameters 98 | end 99 | 100 | def test_class_method_keyword_parameters 101 | assert_equal [], TestPositionalOperation.keyword_parameters 102 | assert_equal %i[kw1 kw2], TestKeywordAndPositionalOperation.keyword_parameters 103 | end 104 | 105 | def test_class_method_required_positional_parameters 106 | assert_equal %i[first], TestPositionalOperation.required_positional_parameters 107 | assert_equal %i[pos1], TestKeywordAndPositionalOperation.required_positional_parameters 108 | end 109 | 110 | def test_class_method_required_keyword_parameters 111 | assert_equal [], TestPositionalOperation.required_keyword_parameters 112 | assert_equal %i[kw1], TestKeywordAndPositionalOperation.required_keyword_parameters 113 | end 114 | 115 | def test_class_method_optional_positional_parameters 116 | assert_equal %i[second], TestPositionalOperation.optional_positional_parameters 117 | assert_equal %i[pos2], TestKeywordAndPositionalOperation.optional_positional_parameters 118 | end 119 | 120 | def test_class_method_optional_keyword_parameters 121 | assert_equal [], TestPositionalOperation.optional_keyword_parameters 122 | assert_equal %i[kw2], TestKeywordAndPositionalOperation.optional_keyword_parameters 123 | end 124 | 125 | def test_positional_param_coercion_block 126 | operation = TestPositionalOperation.new("first", 123) 127 | assert_equal "first/123", operation.call 128 | end 129 | 130 | def test_optional_param_coercion 131 | operation = TestPositionalOperation.new("first") 132 | assert_equal "first!", operation.call 133 | end 134 | 135 | def test_operation_is_callable 136 | operation = TestPositionalOperation.new("first", 123) 137 | assert_equal "first/123", operation.call 138 | end 139 | 140 | def test_operation_call_executes_operation_and_before_and_after_callbacks 141 | operation = TestPositionalOperation.new("first", 123) 142 | assert_equal "first/123", operation.call 143 | assert operation.instance_variable_get(:@before_was_called) 144 | assert operation.instance_variable_get(:@after_was_called) 145 | end 146 | 147 | def test_operation_execute_operation_and_runs_callbacks 148 | operation = TestPositionalOperation.new("first", 123) 149 | assert_equal "first/123", operation.execute_operation 150 | assert operation.instance_variable_get(:@before_was_called) 151 | assert operation.instance_variable_get(:@after_was_called) 152 | end 153 | 154 | def test_operation_acts_as_proc 155 | assert_equal ["first!", "second!"], ["first", "second"].map(&TestPositionalOperation) 156 | end 157 | 158 | def test_operation_raises_on_invalid_positional_params 159 | assert_raises do 160 | Class.new(::TypedOperation::Base) do 161 | # This is invalid, because positional params can't be optional before required ones 162 | positional_param :first, String, optional: true 163 | positional_param :second, String 164 | end 165 | end 166 | end 167 | 168 | def test_operation_raises_on_invalid_positional_params_using_optional 169 | assert_raises(::TypedOperation::ParameterError) do 170 | Class.new(::TypedOperation::Base) do 171 | # This is invalid, because positional params can't be optional before required ones 172 | positional_param :first, optional(String) 173 | positional_param :second, Literal::Types::NilableType.new(String) 174 | positional_param :third, String # required after optional is not possible 175 | end 176 | end 177 | end 178 | 179 | def test_operation_acts_as_proc_on_partially_applied 180 | curried_operation = TestPositionalOperation.with("first") 181 | assert_equal ["first/second", "first/third"], ["second", "third"].map(&curried_operation) 182 | end 183 | 184 | def test_partially_applied_as_proc_with_mixed_args 185 | operation = TestAlternativeDslOperation.with("first", kw1: "bar", kw3: "123") 186 | assert_equal ["first/1//bar/kw2/123", "first/2//bar/kw2/123", "first/3//bar/kw2/123"], ["1", "2", "3"].map(&operation) 187 | end 188 | 189 | def test_operation_to_proc 190 | operation = TestPositionalOperation.new("first") 191 | assert_equal "first!", operation.to_proc.call 192 | end 193 | 194 | def test_operation_positional_args 195 | operation = TestPositionalOperation.new("first", "second") 196 | assert_equal "first", operation.first 197 | assert_equal "second", operation.second 198 | end 199 | 200 | def test_operation_optional_positional_args 201 | operation = TestPositionalOperation.new("first") 202 | assert_equal "first!", operation.call 203 | end 204 | 205 | def test_positional_arg_count_must_make_sense 206 | assert_raises(ArgumentError) { TestPositionalOperation.new("first", "second", "third") } 207 | end 208 | 209 | def test_positional_arg_count_must_make_sense_when_partial_application 210 | assert_raises(ArgumentError) { TestPositionalOperation.with("first", "second", "third") } 211 | end 212 | 213 | def test_operation_mix_args 214 | operation = TestKeywordAndPositionalOperation.new("first", "second", kw1: "foo", kw2: "bar") 215 | assert_equal "first/second/foo/bar", operation.call 216 | end 217 | 218 | def test_operation_optional_mix_args 219 | operation = TestKeywordAndPositionalOperation.new("first", kw1: "bar") 220 | assert_equal "first/pos2/bar/kw2", operation.call 221 | end 222 | 223 | def test_operation_alternative_dsl 224 | operation = TestAlternativeDslOperation.new("first", kw1: "bar", kw3: "123") 225 | assert_equal "first/pos2//bar/kw2/123", operation.call 226 | end 227 | 228 | def test_prepared 229 | prepared = TestOperation.with(foo: "1").with(bar: "2", baz: "3") 230 | assert_instance_of TypedOperation::Prepared, prepared 231 | end 232 | 233 | def test_operation_attributes_are_set 234 | operation = TestOperation.new(foo: "1", bar: "2", baz: "3") 235 | assert_equal "1", operation.foo 236 | assert_equal "2", operation.bar 237 | assert_equal "3", operation.baz 238 | end 239 | 240 | def test_operation_supports_default_params 241 | operation = TestOperation.new(foo: "1", bar: "2", baz: "3") 242 | assert_equal "qux", operation.with_default 243 | end 244 | 245 | def test_operation_supports_nil_default_values 246 | operation = TestOperation.new(foo: "1", bar: "2", baz: "3") 247 | assert_nil operation.can_also_be_nil 248 | end 249 | 250 | def test_operation_supports_nil_params 251 | operation = TestOperation.new(foo: "1", bar: "2", baz: "3") 252 | assert_nil operation.can_be_nil 253 | end 254 | 255 | def test_operation_sets_nilable_params 256 | operation = TestOperation.new(foo: "1", bar: "2", baz: "3", can_be_nil: 123) 257 | assert_equal 123, operation.can_be_nil 258 | end 259 | 260 | def test_operation_params_type_can_be_arbitrary_class 261 | some_instance = TestOperation.new(foo: "1", bar: "2", baz: "3") 262 | operation = TestOperation.new(foo: "1", bar: "2", baz: "3", can_also_be_nil: some_instance) 263 | assert_equal some_instance, operation.can_also_be_nil 264 | end 265 | 266 | def test_operation_params_type_can_be_arbitrary_class_raises 267 | assert_raises(TypeError) { TestOperation.new(foo: "1", bar: "2", baz: "3", can_also_be_nil: Set.new) } 268 | end 269 | 270 | def test_operation_is_prepared 271 | operation = TestOperation.new(foo: "1", bar: "2", baz: "3") 272 | assert_equal 123, operation.instance_variable_get(:@local_var) 273 | end 274 | 275 | def test_operation_invocation 276 | assert_equal "It worked, (1/2/3/qux//)", TestOperation.call(foo: "1", bar: "2", baz: "3") 277 | end 278 | 279 | def test_raises_on_invalid_param_type 280 | assert_raises(TypeError) { TestOperation.new(foo: 1, bar: "2", baz: "3") } 281 | end 282 | 283 | def test_partially_applied 284 | partially_applied = TestOperation.with(foo: "1").with(bar: "2") 285 | assert_instance_of TypedOperation::PartiallyApplied, partially_applied 286 | end 287 | 288 | def test_partially_applied_using_aliases 289 | partially_applied = TestOperation[foo: "1"] 290 | assert_instance_of TypedOperation::PartiallyApplied, partially_applied 291 | end 292 | 293 | def test_prepared_call 294 | result = TestOperation.with(foo: "1").with(bar: "2").with(baz: "3").call 295 | assert_equal "It worked, (1/2/3/qux//)", result 296 | end 297 | 298 | def test_prepared_operation_returns_an_instance_of_the_operation_with_attributes_set 299 | operation = TestOperation.with(foo: "1").with(bar: "2").with(baz: 3).operation 300 | assert_instance_of TestOperation, operation 301 | assert_equal "1", operation.foo 302 | end 303 | 304 | def test_partially_applied_operation_raises_on_operation 305 | assert_raises(TypedOperation::MissingParameterError) { TestOperation.with(foo: "1").operation } 306 | end 307 | 308 | def test_operation_invocation_with_missing_param 309 | partially_applied = TestOperation.with(foo: "1") 310 | assert_raises(TypedOperation::MissingParameterError) { partially_applied.call } 311 | end 312 | 313 | def test_missing_param_error_is_a_argument_error 314 | partially_applied = TestOperation.with(foo: "1") 315 | assert_raises(ArgumentError) { partially_applied.call } 316 | end 317 | 318 | def test_operation_creation_with_missing_param 319 | assert_raises(ArgumentError) { TestOperation.new(foo: "1") } 320 | end 321 | 322 | def test_operation_instance_support_pattern_matching_on_mixed_arguments 323 | operation = TestKeywordAndPositionalOperation.new("first", "second", kw1: "foo", kw2: "bar") 324 | assert_equal ["first", "second", "foo", "bar"], operation.deconstruct 325 | assert_equal({pos1: "first", pos2: "second", kw1: "foo", kw2: "bar"}, operation.deconstruct_keys(nil)) 326 | assert_equal({pos1: "first", kw2: "bar"}, operation.deconstruct_keys(%i[pos1 kw2])) 327 | end 328 | 329 | def test_partially_applied_operation_support_pattern_matching_on_mixed_arguments 330 | operation = TestKeywordAndPositionalOperation.with("first", "second", kw2: "bar") 331 | assert_equal ["first", "second", "bar"], operation.deconstruct 332 | assert_equal({pos1: "first", pos2: "second", kw2: "bar"}, operation.deconstruct_keys(nil)) 333 | assert_equal({pos2: "second", kw2: "bar"}, operation.deconstruct_keys(%i[pos2 kw2])) 334 | end 335 | 336 | def test_operation_instance_supports_pattern_matching_params 337 | operation = TestOperation.new(foo: "1", bar: "2", baz: "3", can_be_nil: 5) 338 | assert_equal ["1", "2", "3", "qux", 5, nil], operation.deconstruct 339 | assert_equal({foo: "1", bar: "2", baz: "3", with_default: "qux", can_be_nil: 5, can_also_be_nil: nil}, operation.deconstruct_keys(nil)) 340 | assert_equal({foo: "1", can_be_nil: 5}, operation.deconstruct_keys(%i[foo can_be_nil])) 341 | 342 | case operation 343 | in TestOperation[foo: foo, with_default: default, **rest] 344 | assert_equal "1", foo 345 | assert_equal "qux", default 346 | assert_equal({bar: "2", baz: "3", can_be_nil: 5, can_also_be_nil: nil}, rest) 347 | else 348 | raise Minitest::Assertion, "Pattern match failed" 349 | end 350 | case operation 351 | in String => foo, String => bar, String => baz, String => with_default, Integer => can_be_nil, NilClass => can_also_be_nil 352 | assert_equal "1", foo 353 | assert_equal "2", bar 354 | assert_equal "3", baz 355 | assert_equal "qux", with_default 356 | assert_equal 5, can_be_nil 357 | assert_nil can_also_be_nil 358 | else 359 | raise Minitest::Assertion, "Pattern match failed" 360 | end 361 | end 362 | 363 | def test_operation_partially_applied_supports_pattern_matching_currently_applied_params 364 | partially_applied = TestOperation.with(foo: "1", bar: "2") 365 | case partially_applied 366 | in TypedOperation::PartiallyApplied[foo: foo, bar: bar, **rest] 367 | assert_equal "1", foo 368 | assert_equal "2", bar 369 | assert_equal({}, rest) 370 | else 371 | raise Minitest::Assertion, "Pattern match failed" 372 | end 373 | case partially_applied 374 | in String => foo, String => bar 375 | assert_equal "1", foo 376 | assert_equal "2", bar 377 | else 378 | raise Minitest::Assertion, "Pattern match failed" 379 | end 380 | end 381 | 382 | def test_operation_prepared_supports_pattern_matching_currently_applied_params 383 | prepared = TestOperation.with(foo: "1", bar: "2", baz: "3") 384 | 385 | case prepared 386 | in TypedOperation::Prepared[foo: foo, bar: bar, **rest] 387 | assert_equal "1", foo 388 | assert_equal "2", bar 389 | assert_equal({baz: "3"}, rest) 390 | else 391 | raise Minitest::Assertion, "Pattern match failed" 392 | end 393 | case prepared 394 | in String => foo, String => bar, String => baz 395 | assert_equal "1", foo 396 | assert_equal "2", bar 397 | assert_equal "3", baz 398 | else 399 | raise Minitest::Assertion, "Pattern match failed" 400 | end 401 | end 402 | 403 | def test_raises_when_operation_has_no_perform_method_defined 404 | error = assert_raises(::TypedOperation::InvalidOperationError) { TestInvalidOperation.call } 405 | assert_equal "Operation TypedOperation::BaseTest::TestInvalidOperation does not implement #perform", error.message 406 | end 407 | 408 | def test_operation_of_one_required_param_can_curry 409 | curried_operation = TestPositionalOperation.curry 410 | assert_instance_of TypedOperation::Curried, curried_operation 411 | assert_equal ["one!", "two!"], ["one", "two"].map(&curried_operation) 412 | end 413 | 414 | def test_operation_of_multliple_required_params_can_curry 415 | curried_operation = TestOperation.curry 416 | res = ["1", "2", 3].reduce(curried_operation) { |curried, arg| curried.call(arg) } 417 | assert_equal "It worked, (1/2/3/qux//)", res 418 | end 419 | 420 | def test_operation_can_be_partially_applied_then_curry 421 | partially_applied = TestCurryOperation.with("a", kw2: "e", kw3: "f") 422 | curried_operation = partially_applied.curry 423 | assert_instance_of TypedOperation::Curried, curried_operation 424 | assert_equal "a/b//d/e/f", curried_operation.call("b").call("d") 425 | end 426 | 427 | def test_operation_instance_can_be_copied_using_dup 428 | operation = TestKeywordAndPositionalOperation.new("1", "2", kw1: "1", kw2: "2") 429 | operation2 = operation.dup 430 | assert_equal "1/2/1/2", operation2.call 431 | end 432 | 433 | def test_operation_should_not_freeze_arguments 434 | operation = MyMutableOperation.new(my_hash: {a: 1}) 435 | refute operation.my_hash.frozen? 436 | operation.my_hash[:b] = 2 437 | assert_equal({a: 1, b: 2}, operation.my_hash) 438 | end 439 | 440 | def test_with_dry_maybe_monad_partially_apply_then_curry 441 | operation_class = Class.new(::TypedOperation::Base) do 442 | positional_param :v1, Integer 443 | positional_param :v2, Integer 444 | 445 | def perform 446 | ::Dry::Monads::Maybe(v1 + v2) 447 | end 448 | end 449 | 450 | operation = operation_class.with(1) 451 | operation2 = operation_class.with(3) 452 | m = ::Dry::Monads::Maybe::Some.new(2) 453 | assert_equal m.bind(&operation.curry).bind(&operation2.curry), Dry::Monads::Maybe::Some.new(6) 454 | m = Dry::Monads::Maybe::None.instance 455 | assert_equal m.bind(&operation.curry).bind(&operation2.curry), Dry::Monads::Maybe::None.instance 456 | end 457 | 458 | def test_can_dup 459 | operation = TestOperation.new(foo: "1", bar: "2", baz: "3") 460 | operation2 = operation.dup 461 | assert_equal "1", operation2.foo 462 | assert_equal "2", operation2.bar 463 | assert_equal "3", operation2.baz 464 | end 465 | 466 | def test_not_frozen 467 | operation = TestOperation.new(foo: "1", bar: "2", baz: "3") 468 | refute operation.frozen? 469 | end 470 | 471 | def test_cannot_write_property 472 | operation = TestOperation.new(foo: "1", bar: "2", baz: "3") 473 | assert_raises do 474 | operation.foo = 2 475 | end 476 | end 477 | end 478 | end 479 | -------------------------------------------------------------------------------- /test/typed_operation/immutable_base_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | module TypedOperation 6 | class ImmutableBaseTest < Minitest::Test 7 | class MyImmutableOperation < ::TypedOperation::ImmutableBase 8 | param :my_hash, Hash, default: -> { {} } 9 | end 10 | 11 | def test_immutable_operation_should_be_frozen 12 | operation = MyImmutableOperation.new(my_hash: {a: 1}) 13 | assert operation.frozen? 14 | assert_raises(RuntimeError) { operation.instance_variable_set(:@my_hash, {}) } 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/typed_operation/typed_operation_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TypedOperationTest < Minitest::Test 6 | def test_it_has_a_version_number 7 | assert TypedOperation::VERSION 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /typed_operation.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/typed_operation/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "typed_operation" 5 | spec.version = TypedOperation::VERSION 6 | spec.authors = ["Stephen Ierodiaconou"] 7 | spec.email = ["stevegeek@gmail.com"] 8 | spec.homepage = "https://github.com/stevegeek/typed_operation" 9 | spec.summary = "TypedOperation is a command pattern with typed parameters, which is callable, and can be partially applied." 10 | spec.description = "Command pattern, which is callable, and can be partially applied, curried and has typed parameters. Authorization to execute via action_policy if desired." 11 | spec.license = "MIT" 12 | 13 | spec.required_ruby_version = ">= 3.2" 14 | 15 | spec.metadata["homepage_uri"] = spec.homepage 16 | spec.metadata["source_code_uri"] = "https://github.com/stevegeek/typed_operation" 17 | 18 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 19 | Dir["lib/**/*", "MIT-LICENSE", "README.md"] 20 | end 21 | 22 | spec.add_dependency "literal", ">= 1.0.0", "< 2.0.0" 23 | end 24 | --------------------------------------------------------------------------------