├── .github ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── docs.yml │ └── ruby.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── app └── controllers │ └── granite │ ├── controller.rb │ └── controller │ └── translations.rb ├── bin └── setup ├── config └── rubocop-default.yml ├── docker-compose.yml ├── docs ├── img │ ├── favicon.ico │ └── logo.png ├── index.md ├── projectors.md ├── testing.md └── tutorial.md ├── gemfiles ├── rails.6.1.gemfile ├── rails.7.0.gemfile ├── rails.7.1.gemfile ├── rails.7.2.gemfile └── rails.8.0.gemfile ├── granite.gemspec ├── lib ├── generators │ ├── USAGE │ ├── granite │ │ └── install_controller_generator.rb │ ├── granite_generator.rb │ └── templates │ │ ├── granite_action.rb.erb │ │ ├── granite_action_spec.rb.erb │ │ ├── granite_base_action.rb.erb │ │ └── granite_business_action.rb.erb ├── granite.rb ├── granite │ ├── action.rb │ ├── action │ │ ├── error.rb │ │ ├── exceptions_handling.rb │ │ ├── instrumentation.rb │ │ ├── performer.rb │ │ ├── performing.rb │ │ ├── policies.rb │ │ ├── policies │ │ │ ├── always_allow_strategy.rb │ │ │ ├── any_strategy.rb │ │ │ └── required_performer_strategy.rb │ │ ├── precondition.rb │ │ ├── preconditions.rb │ │ ├── preconditions │ │ │ ├── base_precondition.rb │ │ │ ├── embedded_precondition.rb │ │ │ └── object_precondition.rb │ │ ├── projectors.rb │ │ ├── subject.rb │ │ ├── transaction.rb │ │ ├── transaction_manager.rb │ │ ├── transaction_manager │ │ │ └── transactions_stack.rb │ │ └── translations.rb │ ├── assign_data.rb │ ├── base.rb │ ├── config.rb │ ├── context.rb │ ├── context_proxy.rb │ ├── context_proxy │ │ ├── data.rb │ │ └── proxy.rb │ ├── dispatcher.rb │ ├── error.rb │ ├── projector.rb │ ├── projector │ │ ├── controller_actions.rb │ │ ├── error.rb │ │ ├── helpers.rb │ │ └── translations.rb │ ├── rails.rb │ ├── routing.rb │ ├── routing │ │ ├── cache.rb │ │ ├── caching.rb │ │ ├── declarer.rb │ │ ├── mapper.rb │ │ ├── mapping.rb │ │ └── route.rb │ ├── rspec.rb │ ├── rspec │ │ ├── action_helpers.rb │ │ ├── have_projector.rb │ │ ├── perform_action.rb │ │ ├── projector_helpers.rb │ │ ├── raise_validation_error.rb │ │ └── satisfy_preconditions.rb │ ├── translations.rb │ └── version.rb ├── rubocop-granite.rb └── rubocop │ ├── granite.rb │ └── granite │ └── inject.rb ├── mkdocs.yml └── spec ├── app └── controllers │ └── granite │ └── controller │ └── translations_spec.rb ├── fixtures ├── action_example.rb ├── action_spec_example.rb ├── base_action_example.rb ├── collection_action_example.rb ├── collection_action_spec_example.rb └── simple_action_example.rb ├── lib ├── generators │ └── granite_generator_spec.rb ├── granite │ ├── action │ │ ├── instrumentation_spec.rb │ │ ├── performer_spec.rb │ │ ├── performing_spec.rb │ │ ├── policies │ │ │ ├── always_allow_strategy_spec.rb │ │ │ ├── any_strategy_spec.rb │ │ │ └── required_performer_strategy_spec.rb │ │ ├── policies_spec.rb │ │ ├── precondition_spec.rb │ │ ├── preconditions │ │ │ ├── base_precondition_spec.rb │ │ │ ├── embedded_precondition_spec.rb │ │ │ └── object_precondition_spec.rb │ │ ├── preconditions_spec.rb │ │ ├── projectors_spec.rb │ │ ├── subject_spec.rb │ │ ├── transaction_manager │ │ │ └── transactions_stack_spec.rb │ │ ├── transaction_manager_spec.rb │ │ ├── transaction_spec.rb │ │ └── translations_spec.rb │ ├── action_spec.rb │ ├── assign_data_spec.rb │ ├── config_spec.rb │ ├── context_proxy │ │ └── proxy_spec.rb │ ├── context_proxy_spec.rb │ ├── context_spec.rb │ ├── dispatcher_spec.rb │ ├── projector │ │ ├── controller_actions_spec.rb │ │ ├── helpers_spec.rb │ │ └── translations_spec.rb │ ├── projector_spec.rb │ ├── railtie_spec.rb │ ├── routing │ │ ├── cache_spec.rb │ │ ├── caching_spec.rb │ │ ├── declarer_spec.rb │ │ ├── mapper_spec.rb │ │ └── route_spec.rb │ ├── rspec │ │ ├── have_projector_spec.rb │ │ ├── perform_action_spec.rb │ │ ├── raise_validation_error_spec.rb │ │ └── satisfy_preconditions_spec.rb │ └── translations_spec.rb └── granite_spec.rb ├── spec_helper.rb └── support ├── class_helpers.rb ├── data.rb ├── database.yml.example ├── matchers └── negated.rb ├── models ├── application_record.rb ├── role.rb ├── student.rb ├── teacher.rb └── user.rb ├── rails.rb ├── schema.rb └── translations.rb /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Describe the changes and motivations for the pull request. 2 | 3 | ### Review 4 | 5 | - [ ] Document code according to [Getting Started with Yard](http://www.rubydoc.info/gems/yard/file/docs/GettingStarted.md). 6 | - [ ] All tests are passing. 7 | - [ ] Test manually. 8 | - [ ] Get approval. 9 | 10 | ### Pre-merge checklist 11 | 12 | - [ ] The PR relates to a single subject with a clear title and description in grammatically correct, complete sentences. 13 | - [ ] Verify that feature branch is up-to-date with `master` (if not - rebase it). 14 | - [ ] Double check the quality of [commit messages](http://chris.beams.io/posts/git-commit/). 15 | - [ ] Squash related commits together. 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | registries: 3 | toptal-github: 4 | type: "git" 5 | url: "https://github.com" 6 | username: "x-access-token" 7 | password: "${{secrets.DEPENDABOT_GITHUB_TOKEN}}" 8 | 9 | updates: 10 | - package-ecosystem: bundler 11 | directory: "/" 12 | schedule: 13 | interval: "monthly" 14 | pull-request-branch-name: 15 | separator: "-" 16 | labels: 17 | - "no-jira" 18 | - "ruby" 19 | - "dependencies" 20 | - "WIP" 21 | - "DevX" 22 | reviewers: 23 | - "toptal/devx" 24 | registries: 25 | - toptal-github 26 | insecure-external-code-execution: allow 27 | open-pull-requests-limit: 10 28 | ignore: 29 | - dependency-name: '*' 30 | update-types: [ 31 | 'version-update:semver-patch' 32 | ] 33 | groups: 34 | development-dependencies-group: 35 | dependency-type: development 36 | update-types: 37 | - "minor" 38 | exclude-patterns: 39 | - "rubocop*" 40 | production-dependencies-group: 41 | dependency-type: production 42 | update-types: 43 | - "minor" 44 | rubocop: 45 | patterns: 46 | - "rubocop*" 47 | update-types: 48 | - "major" 49 | - "minor" 50 | - package-ecosystem: "github-actions" 51 | directory: "/" 52 | schedule: 53 | interval: "monthly" 54 | pull-request-branch-name: 55 | separator: "-" 56 | labels: 57 | - "no-jira" 58 | - "dependencies" 59 | - "gha" 60 | - "WIP" 61 | - "DevX" 62 | reviewers: 63 | - "toptal/devx" 64 | open-pull-requests-limit: 10 65 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | on: 3 | push: 4 | branches: 5 | - master 6 | permissions: 7 | contents: write 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version: 3.x 16 | - uses: actions/cache@v4 17 | with: 18 | key: ${{ github.ref }} 19 | path: .cache 20 | - run: pip install mkdocs-material 21 | - run: mkdocs gh-deploy --force 22 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Ruby 9 | 10 | on: 11 | push: 12 | branches: [ master ] 13 | pull_request: 14 | branches: [ master ] 15 | 16 | jobs: 17 | rspec: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | matrix: 21 | include: 22 | - { ruby: '2.7', rails: '6.1' } 23 | - { ruby: '3.0', rails: '7.0' } 24 | - { ruby: '3.1', rails: '7.0' } 25 | - { ruby: '3.2', rails: '7.1' } 26 | - { ruby: '3.2', rails: '7.2' } 27 | - { ruby: '3.2', rails: '8.0' } 28 | env: 29 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails.${{ matrix.rails }}.gemfile 30 | 31 | services: 32 | postgres: 33 | image: postgres 34 | env: 35 | POSTGRES_USER: granite 36 | POSTGRES_PASSWORD: granite 37 | # Set health checks to wait until postgres has started 38 | options: >- 39 | --health-cmd pg_isready 40 | --health-interval 10s 41 | --health-timeout 5s 42 | --health-retries 5 43 | ports: 44 | # Maps tcp port 5432 on service container to the host 45 | - 5432:5432 46 | 47 | steps: 48 | - uses: actions/checkout@v4 49 | - name: Set up Ruby 50 | # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby, 51 | # change this to (see https://github.com/ruby/setup-ruby#versioning): 52 | # uses: ruby/setup-ruby@v1 53 | uses: ruby/setup-ruby@v1 54 | with: 55 | ruby-version: ${{ matrix.ruby }} 56 | bundler-cache: true 57 | - name: Run tests 58 | env: 59 | DATABASE_URL: "postgres://granite:granite@localhost:5432/granite" 60 | RAILS_ENV: test 61 | run: bundle exec rake 62 | 63 | rubocop: 64 | runs-on: ubuntu-latest 65 | steps: 66 | - uses: actions/checkout@v4 67 | - uses: ruby/setup-ruby@v1 68 | with: 69 | ruby-version: 3.0 70 | bundler-cache: true 71 | - run: bundle exec rubocop 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | 4 | /coverage/ 5 | /spec/support/database.yml 6 | /log/*.log 7 | /*.gem 8 | /Gemfile.lock 9 | /gemfiles/*.lock 10 | .ruby-version 11 | .ruby-gemset 12 | pkg/ 13 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -I lib/ 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rails 3 | - rubocop-rspec 4 | - rubocop-rspec_rails 5 | 6 | AllCops: 7 | Include: 8 | - '**/*.rb' 9 | - '**/Gemfile' 10 | - '**/Rakefile' 11 | Exclude: 12 | - 'vendor/bundle/**/*' 13 | - 'log/**/*' 14 | - 'spec/fixtures/**/*' 15 | DisplayCopNames: true 16 | TargetRubyVersion: 2.6 17 | TargetRailsVersion: 5.1 18 | NewCops: enable 19 | 20 | RSpec/MultipleExpectations: 21 | Enabled: false 22 | 23 | RSpec/NestedGroups: 24 | Enabled: false 25 | 26 | RSpec/MessageSpies: 27 | Enabled: false 28 | 29 | Gemspec/DevelopmentDependencies: 30 | Enabled: false 31 | 32 | Naming/FileName: 33 | Exclude: 34 | - 'lib/rubocop-granite.rb' 35 | 36 | Style/FrozenStringLiteralComment: 37 | Enabled: false 38 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | %w[6.1 7.0 7.1 7.2 8.0].each do |version| 2 | appraise "rails.#{version}" do 3 | gem 'actionpack', "~> #{version}.0" 4 | gem 'activesupport', "~> #{version}.0" 5 | gem 'activerecord', "~> #{version}.0" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Next 2 | 3 | ## v0.17.5 4 | 5 | * Support for Rails 7.2 and 8.0 by @galathius and @dependabot in https://github.com/toptal/granite/pull/144 6 | 7 | ## v0.17.4 8 | 9 | * Undeprecate the `Granite::Action#perform` method. 10 | 11 | ## v0.17.3 12 | 13 | * Stopped preconditions from being executed twice on `#try_perform!` 14 | 15 | ## v0.17.2 16 | 17 | * Deprecate `Granite::Action#perform` in favor of using `Granite::Action#try_perform!`, as these 2 methods are basically identical. It will be removed in next major version. (https://github.com/toptal/granite/pull/117) 18 | 19 | ## v0.17.1 20 | 21 | * Fix in_transaction collisions with other gems (https://github.com/toptal/granite/pull/112) 22 | 23 | ## v0.17.0 24 | 25 | * Support for ruby 3.2 by @konalegi in https://github.com/toptal/granite/pull/100 26 | * Refine main page of the Granite docs by @jarosluv in https://github.com/toptal/granite/pull/101 27 | * Refine documentation. Part 2 by @jarosluv in https://github.com/toptal/granite/pull/103 28 | * Support for Rails 7.1 by @ojab and @dependabot in https://github.com/toptal/granite/pull/107 29 | * [BREAKING] Drop support for Rails < 6.0 by @ojab and @dependabot in https://github.com/toptal/granite/pull/107 30 | 31 | ## v0.16.0 32 | 33 | * Add `after_initialize` callback. 34 | * Fix `after_commit` being called even if `perform` fails (because of failing preconditions/validations). 35 | * Extract `Granite::Util` to `granite-form`. It's now called `Granite::Form::Util`. 36 | * [BREAKING] Change granite projector specs to use controller specs instead of request specs: 37 | * This means that you'll have to replace `get projector.action_path` with `get :action`. 38 | * As a temporary measure you should be able to `include RSpec::Rails::RequestExampleGroup` in your describe block to use request specs temporarily. This is not guaranteed to not break in the future. 39 | 40 | ## v0.15.1 41 | 42 | * Remove `BA` prefix in granite action generator 43 | * Remove automatic synchronization from `embeds_many`/`embeds_one` associated objects (`action.association`) to their appropriate virtual attribute (`action.attributes('association')`) 44 | * Update minimum granite-form version to 0.3.0 45 | 46 | ## v0.15.0 47 | 48 | * [BREAKING] Change form builder from ActiveData to Granite::Form. This means Granite no longer depends 49 | on ActiveData, Granite::Form currently is a direct replacement for ActiveData that uses same syntax. 50 | * Add support for detecting types of aliased attributes when using `represents` 51 | 52 | ## v0.14.2 53 | 54 | * Fix error existence check on `Granite::Action#merge_errors` in Rails 6.1 55 | * Add `Granite::Action.subject?` helper method 56 | * Fix ActiveRecord::Enum handling with represents 57 | 58 | ## v0.14.1 59 | 60 | * Introduce the ruby2_keywords (https://github.com/ruby/ruby2_keywords) gem in order 61 | to provide compatibility with Ruby 3 for some internal methods 62 | 63 | ## v0.14.0 64 | 65 | * Introduce instrumentation and RSpec matcher to check if action was performed: 66 | https://toptal.github.io/granite/testing/#testing-composition 67 | * Introduce `Action.with` as a more powerful replacement for `Action.as`, that allows passing more than 68 | just performer: https://toptal.github.io/granite/#context-performer 69 | 70 | ## v0.13.0 71 | 72 | * Fix Ruby 3 Warnings 73 | * Improve how projector specs initialize controller to be more rails like and fix several issues. 74 | * [BREAKING] As a result abstract actions/projectors will have to be initialized using `prepend_before` in projector specs. 75 | 76 | ## v0.12.1 77 | 78 | * Fix parameterized precondition error messages not working in Ruby 3. 79 | 80 | ## v0.12.0 81 | 82 | * Support for Rails 6.1 (via https://github.com/toptal/active_data fork) 83 | * Support for Rails 7.0 84 | * Fix `represents` with `default: false` not seeing any changes in model 85 | 86 | ## v0.11.1 87 | 88 | * Make `assign_data` protected so that it can be called from other actions. 89 | 90 | ## v0.11.0 91 | 92 | * [BREAKING] Implemented `assign_data`, which replaces `before_validation` as a way to set data for models before validations. 93 | * Converted `represents` to use `assign_data` 94 | * Fix dispatcher not working correctly with blank routes (e.g. `post :perform, as: ''`) 95 | 96 | ## v0.10.0 97 | 98 | * Fix Ruby 2.7 and 3.0 compatibility issues 99 | 100 | ## v0.9.9 101 | 102 | * Simplify translations code when expanding relative keys (`.key`) 103 | * Fix one Ruby 3 incompatibility 104 | 105 | ## v0.9.8 106 | 107 | * Extract `Granite::Util` which allows evaluating conditions 108 | 109 | ## v0.9.7 110 | 111 | * fix `represents` to skip not defined attributes on the reference 112 | 113 | ## v0.9.6 114 | 115 | * fix gemspec to include `config` directory 116 | * update readme 117 | 118 | ## v0.9.5 119 | 120 | * add rubocop config that can be included in projects using Granite 121 | 122 | ## v0.9.4 123 | 124 | * fix documentation 125 | * add Rails 6 support 126 | * fix path helper to work with string arguments 127 | 128 | ## v0.9.3 129 | 130 | * move `apply_association_changes!` to perform block 131 | 132 | ## v0.9.2 133 | 134 | * `satisfy_preconditions` matcher supports composable matchers. 135 | 136 | ## v0.9.1 137 | 138 | ### Changes 139 | 140 | * `satisfy_preconditions` matcher supports regular expressions 141 | 142 | ### Bug fixes 143 | 144 | * remove callback loop triggered by executing action in `after_commit` of another action 145 | 146 | ## v0.9.0 147 | 148 | ### Breaking Changes 149 | 150 | * nested executions of actions creates a proper nested transactions using `ActiveRecord::Base.transaction(requires_new: true)`. [See how it works at Nested Transaction section](https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html) 151 | 152 | * don't silence `Granite::Action::Rollback` error when there is no `ActiveRecord` 153 | 154 | ### Changes 155 | 156 | * introduced `after_commit` callback for actions 157 | 158 | ## v0.8.3 159 | 160 | ### Breaking Changes 161 | 162 | * `represents` attribute with a default value updates corresponding model's attribute even when action's attribute was not changed 163 | 164 | * represented value of model goes through defaultize and typecaster 165 | 166 | ## v0.8.0 167 | 168 | ### Changes 169 | 170 | * `represents` supports `allow_nil` option 171 | 172 | ## v0.7.0 173 | 174 | In the beginning was the Word, and the Word was **Granite** 175 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | git_source(:toptal) { |repo| "https://github.com/toptal/#{repo}.git" } 3 | 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Toptal 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Granite 2 | 3 | Granite is an alternative Rails application architecture framework. 4 | 5 | [![Build Status](https://travis-ci.org/toptal/granite.svg?branch=master)](https://travis-ci.org/toptal/granite) 6 | 7 | ## Installation 8 | 9 | Add this line to your application's Gemfile: 10 | 11 | ```ruby 12 | gem 'granite' 13 | ``` 14 | 15 | And then execute: 16 | 17 | $ bundle 18 | 19 | Or install it yourself as: 20 | 21 | $ gem install granite 22 | 23 | ## Usage 24 | 25 | Please see [our official documentation](https://toptal.github.io/granite/) or check the 26 | [granite application example](https://github.com/toptal/example_granite_application). 27 | 28 | ### Versioning 29 | 30 | We use [semantic versioning](https://semver.org/) for our [releases](https://github.com/toptal/granite/releases). 31 | 32 | ## Contributing 33 | 34 | Bug reports and pull requests are welcome on GitHub at https://github.com/toptal/granite. 35 | 36 | This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 37 | 38 | ### Running specs 39 | 40 | To run specs you can run 41 | 42 | ``` 43 | bin/setup 44 | docker-compose up 45 | rspec 46 | ``` 47 | 48 | ### Using Granite's Rubocop config 49 | 50 | Add this to your Rubocop config file: 51 | 52 | ``` 53 | require: 54 | - rubocop-granite 55 | ``` 56 | 57 | This will add config for `Lint/UselessAccessModifier` to treat `projector` as separate context. It is equivalent to: 58 | 59 | ``` 60 | Lint/UselessAccessModifier: 61 | ContextCreatingMethods: 62 | - projector 63 | ``` 64 | 65 | ## License 66 | 67 | Granite is released under the [MIT License](https://opensource.org/licenses/MIT). 68 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | require 'bump/tasks' 4 | 5 | RSpec::Core::RakeTask.new(:spec) 6 | 7 | task default: :spec 8 | 9 | Bump.tag_by_default = true 10 | Bump.changelog = true 11 | -------------------------------------------------------------------------------- /app/controllers/granite/controller.rb: -------------------------------------------------------------------------------- 1 | require 'action_controller' 2 | 3 | module Granite 4 | class Controller < Granite.base_controller_class # :nodoc: 5 | include Controller::Translations 6 | helper Controller::Translations 7 | 8 | singleton_class.__send__(:attr_accessor, :projector_class) 9 | singleton_class.delegate :projector_path, :projector_name, :action_class, to: :projector_class 10 | delegate :projector_path, :projector_name, :action_class, :projector_class, to: 'self.class' 11 | 12 | abstract! 13 | 14 | before_action :authorize_action! 15 | 16 | def projector 17 | @projector ||= 18 | begin 19 | projector_class = action_class.public_send(projector_name) 20 | projector_class = projector_class.with(projector_context) if respond_to?(:projector_context, true) 21 | projector_class.new(projector_params) 22 | end 23 | end 24 | helper_method :projector 25 | 26 | delegate :action, to: :projector 27 | helper_method :action 28 | 29 | def self.local_prefixes 30 | [projector_path] 31 | end 32 | 33 | private 34 | 35 | def projector_params 36 | params 37 | end 38 | 39 | def authorize_action! 40 | action.authorize! 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /app/controllers/granite/controller/translations.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | class Controller 3 | module Translations # :nodoc: 4 | def i18n_scopes 5 | Granite::Translations.combine_paths(projector.i18n_scopes, [*action_name, nil]) 6 | end 7 | 8 | def translate(*args, **options) 9 | key, options = Granite::Translations.scope_translation_args(i18n_scopes, *args, **options) 10 | super(key, **options) 11 | end 12 | 13 | alias t translate 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # A setup script for development environment of Granite 4 | 5 | cp spec/support/database.yml.example spec/support/database.yml 6 | -------------------------------------------------------------------------------- /config/rubocop-default.yml: -------------------------------------------------------------------------------- 1 | Lint/UselessAccessModifier: 2 | ContextCreatingMethods: 3 | - projector 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | postgresql: 4 | image: 'postgres:12.4' 5 | environment: 6 | POSTGRES_USER: granite 7 | POSTGRES_PASSWORD: granite 8 | volumes: 9 | - granite_dbdata:/var/lib/postgresql/data 10 | ports: 11 | - '5432:5432' 12 | 13 | volumes: 14 | granite_dbdata: 15 | -------------------------------------------------------------------------------- /docs/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/granite/35981e554e47ea462398cf0423ffc01c7d557b8b/docs/img/favicon.ico -------------------------------------------------------------------------------- /docs/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/granite/35981e554e47ea462398cf0423ffc01c7d557b8b/docs/img/logo.png -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | Granite provides several RSpec helpers for testing your application. To use them, add `require 'granite/rspec'` to your `rails_helper.rb` file. 4 | 5 | All specs that live in `spec/apq/actions/` will get tagged with `:granite_action` type and will have access to Granite action specific helpers. 6 | 7 | All specs that live in `spec/apq/projectors/` will get tagged with `:granite_projector` type and will have access to Granite projector specific helpers. 8 | 9 | ## Subject 10 | 11 | The subject is an instance of the action being tested. You can create it using the `as` method to specify the performer, and passing any necessary attributes as arguments. Here's an example: 12 | 13 | ```ruby 14 | subject(:action) { described_class.as(performer).new(user, attributes) } 15 | let(:user) { User.new } 16 | let(:attributes) { {} } 17 | ``` 18 | 19 | ## Projectors 20 | 21 | You can test your projectors using the `have_projector` matcher. Here's an example: 22 | 23 | ```ruby 24 | it { is_expected.to have_projector(:simple) } 25 | ``` 26 | 27 | You can also test overridden projector methods like this: 28 | 29 | ```ruby 30 | describe 'projectors', type: :granite_projector do 31 | subject { action.modal } 32 | projector { described_class.modal } 33 | 34 | it { expect(projector.perform_success_response).to eq(my_success: 'yes') } 35 | end 36 | ``` 37 | 38 | If you need to test controller methods, you can do so like this: 39 | 40 | ```ruby 41 | describe 'projectors', type: :granite_projector do 42 | projector { described_class.modal } 43 | before { get :confirm, params: attributes } 44 | it { expect(response).to be_successful } 45 | end 46 | ``` 47 | 48 | To test projectors, you can define a abstract action class and use it to test the projector like this: 49 | 50 | ```ruby 51 | describe SimpleProjector do 52 | let(:dummy_action_class) do 53 | Class.new BaseAction do 54 | projector :simple 55 | end 56 | end 57 | 58 | prepend_before do 59 | stub_const('DummyAction', dummy_action_class) 60 | end 61 | 62 | projector { DummyAction.simple } 63 | 64 | it { expect(projector.some_method).to eq('some_result') } 65 | end 66 | ``` 67 | 68 | ## Policies 69 | 70 | You can test action policies using the `be_allowed` matcher like this: 71 | 72 | ```ruby 73 | subject { described_class.as(User.new).new } 74 | it { is_expected.to be_allowed } 75 | ``` 76 | 77 | ## Preconditions 78 | 79 | You can test action preconditions using the `satisfy_preconditions` matcher. Here's an example: 80 | 81 | ```ruby 82 | context 'correct initial state' do 83 | it { is_expected.to satisfy_preconditions } 84 | end 85 | 86 | context 'incorrect initial state' do 87 | let(:company) { build_stubbed(:company, :active) } 88 | it { is_expected.not_to satisfy_preconditions.with_message("Some validation message") } 89 | it { is_expected.not_to satisfy_preconditions.with_messages(["First validation message", "Second validation message"]) } 90 | end 91 | ``` 92 | 93 | ## Validations 94 | 95 | Validations tests are no different to Active Record models tests. 96 | 97 | ## Performing 98 | 99 | You can use the `perform!` method to run the action and test its side-effects like this: 100 | 101 | ```ruby 102 | specify { expect { perform! }.to change(User, :count).by(1) } 103 | ``` 104 | 105 | ### Testing action is performed from another action 106 | 107 | You can test that an action is performed from another action using the `perform_action` matcher. Here's an example: 108 | 109 | ```ruby 110 | it { expect { perform! }.to perform_action(MyAction) } 111 | it { expect { perform! }.to perform_action(MyAction).as(performer).with(user: user).using(:try_perform!) } 112 | ``` 113 | -------------------------------------------------------------------------------- /gemfiles/rails.6.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "granite-form", git: "https://github.com/toptal/granite-form.git" 6 | gem "actionpack", "~> 6.1.0" 7 | gem "activesupport", "~> 6.1.0" 8 | gem "activerecord", "~> 6.1.0" 9 | gem "concurrent-ruby", "< 1.3.5" 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /gemfiles/rails.7.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "granite-form", git: "https://github.com/toptal/granite-form.git" 6 | gem "actionpack", "~> 7.0.0" 7 | gem "activesupport", "~> 7.0.0" 8 | gem "activerecord", "~> 7.0.0" 9 | gem "concurrent-ruby", "< 1.3.5" 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /gemfiles/rails.7.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "granite-form", git: "https://github.com/toptal/granite-form.git" 6 | gem "actionpack", "~> 7.1.0" 7 | gem "activesupport", "~> 7.1.0" 8 | gem "activerecord", "~> 7.1.0" 9 | 10 | gemspec path: "../" 11 | -------------------------------------------------------------------------------- /gemfiles/rails.7.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "granite-form", git: "https://github.com/toptal/granite-form.git" 6 | gem "actionpack", "~> 7.2.0" 7 | gem "activesupport", "~> 7.2.0" 8 | gem "activerecord", "~> 7.2.0" 9 | 10 | gemspec path: "../" 11 | -------------------------------------------------------------------------------- /gemfiles/rails.8.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "granite-form", git: "https://github.com/toptal/granite-form.git" 6 | gem "actionpack", "~> 8.0.0" 7 | gem "activesupport", "~> 8.0.0" 8 | gem "activerecord", "~> 8.0.0" 9 | 10 | gemspec path: "../" 11 | -------------------------------------------------------------------------------- /granite.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'granite/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'granite' 7 | s.version = Granite::VERSION 8 | s.homepage = 'https://github.com/toptal/granite' 9 | s.authors = ['Toptal Engineering'] 10 | s.summary = 'Another business actions architecture for Rails apps' 11 | s.files = `git ls-files`.split("\n").grep(/\A(app|lib|config|LICENSE)/) 12 | s.license = 'MIT' 13 | s.required_ruby_version = '>= 2.6' 14 | 15 | s.add_runtime_dependency 'actionpack', '>= 6.0', '< 8.1' 16 | s.add_runtime_dependency 'activesupport', '>= 6.0', '< 8.1' 17 | s.add_runtime_dependency 'granite-form', '>= 0.3.0' 18 | s.add_runtime_dependency 'memoist', '~> 0.16' 19 | s.add_runtime_dependency 'ruby2_keywords', '~> 0.0.5' 20 | 21 | s.add_development_dependency 'activerecord' 22 | s.add_development_dependency 'appraisal' 23 | s.add_development_dependency 'bump' 24 | s.add_development_dependency 'fuubar', '~> 2.0' 25 | s.add_development_dependency 'pg', '< 2' 26 | s.add_development_dependency 'pry-byebug' 27 | s.add_development_dependency 'rspec', '~> 3.12' 28 | s.add_development_dependency 'rspec-activemodel-mocks', '~> 1.0' 29 | s.add_development_dependency 'rspec-collection_matchers', '~> 1.1' 30 | s.add_development_dependency 'rspec-its', '~> 1.2 ' 31 | s.add_development_dependency 'rspec_junit_formatter', '~> 0.2' 32 | s.add_development_dependency 'rspec-rails', '~> 6.0' 33 | s.add_development_dependency 'rubocop', '~> 1.65.1' 34 | s.add_development_dependency 'rubocop-rails', '~> 2.25.0' 35 | s.add_development_dependency 'rubocop-rspec', '~> 3.0.1' 36 | s.add_development_dependency 'rubocop-rspec_rails', '~> 2.30' 37 | s.add_development_dependency 'simplecov', '~> 0.15' 38 | 39 | s.metadata['rubygems_mfa_required'] = 'true' 40 | end 41 | -------------------------------------------------------------------------------- /lib/generators/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Generates a sample granite action. 3 | 4 | Example: 5 | `rails generate granite user/create` 6 | 7 | Will create: 8 | apq/actions/ba/user/business_action.rb 9 | apq/actions/ba/user/create.rb 10 | spec/apq/actions/ba/user/create_spec.rb 11 | 12 | `rails generate granite user/create -C` 13 | `rails generate granite user/create --collection` 14 | 15 | Will create: 16 | apq/actions/ba/user/create.rb 17 | spec/apq/actions/ba/user/create_spec.rb 18 | 19 | `rails generate granite user/create simple` 20 | 21 | Will create: 22 | apq/actions/ba/user/create/simple/ 23 | apq/actions/ba/user/business_action.rb 24 | apq/actions/ba/user/create.rb 25 | spec/apq/actions/ba/user/create_spec.rb 26 | -------------------------------------------------------------------------------- /lib/generators/granite/install_controller_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators/base' 2 | 3 | module Granite 4 | module Generators 5 | class InstallControllerGenerator < Rails::Generators::Base # :nodoc: 6 | source_root File.expand_path('../../..', __dir__) 7 | 8 | desc 'Creates a Granite::Controller for further customization' 9 | 10 | def copy_controller 11 | copy_file 'app/controllers/granite/controller.rb' 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/generators/granite_generator.rb: -------------------------------------------------------------------------------- 1 | class GraniteGenerator < Rails::Generators::NamedBase # :nodoc: 2 | source_root File.expand_path('templates', __dir__) 3 | 4 | argument :projector, type: :string, required: false 5 | class_option :collection, type: :boolean, aliases: '-C', desc: 'Generate collection action' 6 | 7 | def create_action 8 | template 'granite_action.rb.erb', "apq/actions/#{file_path}.rb" 9 | unless options.collection? 10 | template 'granite_business_action.rb.erb', 11 | "apq/actions/#{class_path.join('/')}/business_action.rb" 12 | end 13 | template 'granite_base_action.rb.erb', 'apq/actions/base_action.rb', skip: true 14 | template 'granite_action_spec.rb.erb', "spec/apq/actions/#{file_path}_spec.rb" 15 | empty_directory "apq/actions/#{file_path}/#{projector}" if projector 16 | end 17 | 18 | private 19 | 20 | def base_class_name 21 | if options.collection? 22 | 'BaseAction' 23 | else 24 | "#{class_path.join('/').camelize}::BusinessAction" 25 | end 26 | end 27 | 28 | def subject_name 29 | class_path.last 30 | end 31 | 32 | def subject_class_name 33 | subject_name.classify 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/generators/templates/granite_action.rb.erb: -------------------------------------------------------------------------------- 1 | class <%= class_name %> < <%= base_class_name %> 2 | <% if projector -%> 3 | projector :<%= projector %> 4 | 5 | <% end -%> 6 | allow_if { false } 7 | 8 | precondition do 9 | end 10 | <% if options.collection? -%> 11 | 12 | def subject 13 | @subject ||= <%= subject_class_name %>.new 14 | end 15 | <% end -%> 16 | 17 | private 18 | 19 | def execute_perform!(*) 20 | subject.save! 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/generators/templates/granite_action_spec.rb.erb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe <%= class_name %> do 4 | <% if options.collection? -%> 5 | subject(:action) { described_class.as(performer).new(attributes) } 6 | 7 | <% else -%> 8 | subject(:action) { described_class.as(performer).new(<%= subject_name %>, attributes) } 9 | 10 | let(:<%= subject_name %>) { <%= subject_class_name %>.new } 11 | <% end -%> 12 | let(:performer) { double } 13 | let(:attributes) { {} } 14 | 15 | describe 'policies' do 16 | it { is_expected.to be_allowed } 17 | 18 | context 'when user is not authorized' do 19 | it { is_expected.not_to be_allowed } 20 | end 21 | end 22 | 23 | describe 'preconditions' do 24 | it { is_expected.to satisfy_preconditions } 25 | 26 | context 'when preconditions fail' do 27 | it { is_expected.not_to satisfy_preconditions } 28 | end 29 | end 30 | 31 | describe 'validations' do 32 | end 33 | 34 | describe '#perform!' do 35 | <% if options.collection? -%> 36 | specify do 37 | expect { perform! }.to change { <%= subject_class_name %>.count }.by(1) 38 | end 39 | <% else -%> 40 | specify do 41 | expect { perform!(<%= subject_name %>) }.to change { <%= subject_name %>.reload.attributes }.to(attributes) 42 | end 43 | <% end -%> 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/generators/templates/granite_base_action.rb.erb: -------------------------------------------------------------------------------- 1 | class BaseAction < Granite::Action 2 | end 3 | -------------------------------------------------------------------------------- /lib/generators/templates/granite_business_action.rb.erb: -------------------------------------------------------------------------------- 1 | class <%= class_path.join('/').camelize %>::BusinessAction < BaseAction 2 | subject :<%= subject_name %> 3 | end 4 | -------------------------------------------------------------------------------- /lib/granite.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/dependencies' 2 | require 'action_controller' 3 | require 'ruby2_keywords' 4 | 5 | require 'granite/version' 6 | require 'granite/config' 7 | require 'granite/context' 8 | 9 | module Granite # :nodoc: 10 | def self.config 11 | Granite::Config.instance 12 | end 13 | 14 | def self.context 15 | Granite::Context.instance 16 | end 17 | 18 | def self.deprecator 19 | @deprecator ||= ActiveSupport::Deprecation.new('1.0.0', 'Granite') 20 | end 21 | 22 | singleton_class.delegate(*Granite::Config.delegated, to: :config) 23 | singleton_class.delegate(*Granite::Context.delegated, to: :context) 24 | end 25 | 26 | require 'granite/base' 27 | require 'granite/dispatcher' 28 | require 'granite/action' 29 | require 'granite/projector' 30 | require 'granite/routing' 31 | require 'granite/rails' if defined?(Rails) 32 | 33 | Granite::Form.base_concern = Granite::Base 34 | -------------------------------------------------------------------------------- /lib/granite/action.rb: -------------------------------------------------------------------------------- 1 | require 'granite/form' 2 | require 'active_record/errors' 3 | require 'active_record/validations' 4 | require 'active_support/callbacks' 5 | 6 | require 'granite/action/error' 7 | require 'granite/action/instrumentation' 8 | require 'granite/action/performing' 9 | require 'granite/action/performer' 10 | require 'granite/action/precondition' 11 | require 'granite/action/preconditions' 12 | require 'granite/action/policies' 13 | require 'granite/action/projectors' 14 | require 'granite/action/subject' 15 | require 'granite/action/translations' 16 | 17 | module Granite 18 | class Action # :nodoc: 19 | class ValidationError < Error # :nodoc: 20 | delegate :errors, to: :action 21 | 22 | def initialize(action) 23 | errors = action.errors.full_messages.join(', ') 24 | super(I18n.t(:"#{action.class.i18n_scope}.errors.messages.action_invalid", 25 | action: action.class, 26 | errors: errors, 27 | default: :'errors.messages.action_invalid'), action) 28 | end 29 | end 30 | 31 | # We are using a lot of stacked additional logic for `assign_attributes` 32 | # At least, represented and nested attributes modules in Granite::Form 33 | # are having such a method redefiniions. Both are prepended to the 34 | # Granite action, so we have to prepend our patch as well in order 35 | # to put it above all other, so it will handle the attributes first. 36 | module AssignAttributes 37 | def assign_attributes(attributes) 38 | attributes = attributes.to_unsafe_hash if attributes.respond_to?(:to_unsafe_hash) 39 | attributes = attributes.stringify_keys 40 | attributes = attributes.merge(attributes.delete(model_name.param_key)) if attributes.key?(model_name.param_key) 41 | 42 | super 43 | end 44 | end 45 | 46 | include Base 47 | include Translations 48 | include Performing 49 | include Subject 50 | include Performer 51 | include Preconditions 52 | include Policies 53 | include Projectors 54 | prepend AssignAttributes 55 | prepend Instrumentation 56 | 57 | handle_exception ActiveRecord::RecordInvalid do |e| 58 | merge_errors(e.record.errors) 59 | end 60 | 61 | handle_exception Granite::Form::ValidationError do |e| 62 | merge_errors(e.model.errors) 63 | end 64 | 65 | handle_exception Granite::Action::ValidationError do |e| 66 | merge_errors(e.action.errors) 67 | end 68 | 69 | define_model_callbacks :initialize, only: :after 70 | 71 | def initialize(*) 72 | super 73 | _run_initialize_callbacks 74 | end 75 | 76 | if ActiveModel.version < Gem::Version.new('6.1.0') 77 | def merge_errors(other_errors) 78 | errors.messages.deep_merge!(other_errors.messages) do |_, this, other| 79 | (this + other).uniq 80 | end 81 | end 82 | else 83 | def merge_errors(other_errors) 84 | other_errors.each do |error| 85 | errors.import(error) unless errors.messages[error.attribute].include?(error.message) 86 | end 87 | end 88 | end 89 | 90 | # Almost the same as Dirty `#changed?` method, but 91 | # doesn't check subject reference key 92 | def attributes_changed?(except: []) 93 | except = Array.wrap(except).push(self.class.reflect_on_association(:subject).reference_key) 94 | changed_attributes.except(*except).present? 95 | end 96 | 97 | # Check if action is allowed to execute by current performer (see {Granite.performer}) 98 | # and satisfy all defined preconditions 99 | # 100 | # @return [Boolean] whether action is performable 101 | def performable? 102 | @performable = allowed? && satisfy_preconditions? unless instance_variable_defined?(:@performable) 103 | @performable 104 | end 105 | 106 | protected 107 | 108 | def raise_validation_error(original_error = nil) 109 | raise ValidationError, self, original_error&.backtrace 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/granite/action/error.rb: -------------------------------------------------------------------------------- 1 | require 'granite/error' 2 | 3 | module Granite 4 | class Action 5 | class Error < Granite::Error # :nodoc: 6 | attr_reader :action 7 | 8 | def initialize(message, action = nil) 9 | @action = action 10 | super(message) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/granite/action/exceptions_handling.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | class Action 3 | module ExceptionsHandling # :nodoc: 4 | extend ActiveSupport::Concern 5 | 6 | included do 7 | class_attribute :_exception_handlers, instance_writer: false 8 | self._exception_handlers = {} 9 | 10 | protected :_exception_handlers 11 | end 12 | 13 | module ClassMethods # :nodoc: 14 | # Register default handler for exceptions thrown inside execute_perform! and after_commit methods. 15 | # @param klass Exception class, could be parent class too [Class] 16 | # @param block [Block] with default behavior for handling specified 17 | # type exceptions. First block argument is raised exception instance. 18 | # 19 | # @return [Hash] Registered handlers 20 | def handle_exception(klass, &block) 21 | self._exception_handlers = _exception_handlers.merge(klass => block) 22 | end 23 | end 24 | 25 | private 26 | 27 | def handled_exceptions 28 | _exception_handlers.keys 29 | end 30 | 31 | def handle_exception(exception) 32 | klass = exception.class.ancestors.detect do |ancestor| 33 | ancestor <= Exception && _exception_handlers[ancestor] 34 | end 35 | instance_exec(exception, &_exception_handlers[klass]) if klass 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/granite/action/instrumentation.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | class Action 3 | module Instrumentation # :nodoc: 4 | def perform!(*, **) 5 | instrument_perform(:perform!) { super } 6 | end 7 | 8 | def perform(*, **) 9 | instrument_perform(:perform) { super } 10 | end 11 | 12 | def try_perform!(*, **) 13 | instrument_perform(:try_perform!) { super } 14 | end 15 | 16 | private 17 | 18 | def instrument_perform(using, &block) 19 | ActiveSupport::Notifications.instrument('granite.perform_action', action: self, using: using, &block) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/granite/action/performer.rb: -------------------------------------------------------------------------------- 1 | require 'granite/context_proxy' 2 | 3 | module Granite 4 | class Action 5 | # Performer module is responsible for setting performer for action. 6 | # 7 | module Performer 8 | extend ActiveSupport::Concern 9 | 10 | included do 11 | include ContextProxy 12 | attr_reader :ctx 13 | end 14 | 15 | def initialize(*args) 16 | @ctx = self.class.proxy_context 17 | super 18 | end 19 | 20 | delegate :performer, to: :ctx, allow_nil: true 21 | delegate :id, to: :performer, prefix: true, allow_nil: true 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/granite/action/performing.rb: -------------------------------------------------------------------------------- 1 | require 'granite/action/exceptions_handling' 2 | require 'granite/action/transaction' 3 | require 'granite/action/error' 4 | 5 | module Granite 6 | class Action 7 | # Performing module used for defining perform procedure and error 8 | # handling. Perform procedure is defined as block, which is 9 | # executed in action instance context so all attributes are 10 | # available there. Actions by default are performed in silent way 11 | # (no validation exception raised), to raise exceptions, call bang 12 | # method {Granite::Action::Performing#perform!} 13 | # 14 | # Defined exceptions handlers are also executed in action 15 | # instance context, but additionally get raised exception as 16 | # parameter. 17 | # 18 | module Performing 19 | extend ActiveSupport::Concern 20 | 21 | include ExceptionsHandling 22 | include Transaction 23 | 24 | included do 25 | define_callbacks :execute_perform 26 | end 27 | 28 | module ClassMethods # :nodoc: 29 | def perform(*) 30 | raise 'Perform block declaration was removed! Please declare `private def execute_perform!(*)` method' 31 | end 32 | end 33 | 34 | # Check preconditions and validations for action and associated objects, then 35 | # in case of valid action run defined procedure. Procedure is wrapped with 36 | # database transaction. Returns the result of execute_perform! method execution 37 | # or true if method execution returned false or nil 38 | # 39 | # @param context [Symbol] can be optionally provided to define which 40 | # validations to test against (the context is defined on validations 41 | # using `:on`) 42 | # @return [Object] result of execute_perform! method execution or false in case of errors 43 | def perform(context: nil, **options) 44 | transaction do 45 | raise Rollback unless valid?(context) 46 | 47 | perform_action(**options) 48 | end 49 | end 50 | 51 | # Check precondition and validations for action and associated objects, then 52 | # raise exception in case of validation errors. In other case run defined procedure. 53 | # Procedure is wraped with database transaction. After procedure execution check for 54 | # errors, and raise exception if any. Returns the result of execute_perform! method execution 55 | # or true if block execution returned false or nil 56 | # 57 | # @param context [Symbol] can be optionally provided to define which 58 | # validations to test against (the context is defined on validations 59 | # using `:on`) 60 | # @return [Object] result of execute_perform! method execution 61 | # @raise [Granite::Action::ValidationError] Action or associated objects are invalid 62 | # @raise [NotImplementedError] execute_perform! method was not defined yet 63 | def perform!(context: nil, **options) 64 | transaction do 65 | validate!(context) 66 | perform_action!(**options) 67 | end 68 | end 69 | 70 | # Performs action if preconditions are satisfied. 71 | # 72 | # @param context [Symbol] can be optionally provided to define which 73 | # validations to test against (the context is defined on validations 74 | # using `:on`) 75 | # @return [Object] result of execute_perform! method execution 76 | # @raise [Granite::Action::ValidationError] Action or associated objects are invalid 77 | # @raise [NotImplementedError] execute_perform! method was not defined yet 78 | def try_perform!(context: nil, **options) 79 | return unless satisfy_preconditions?(cache_result: true) 80 | 81 | transaction do 82 | validate!(context) 83 | perform_action!(**options) 84 | end 85 | end 86 | 87 | # Checks if action was successfully performed or not 88 | # 89 | # @return [Boolean] whether action was successfully performed or not 90 | def performed? 91 | @_action_performed.present? 92 | end 93 | 94 | private 95 | 96 | def perform_action(raise_errors: false, **options) 97 | result = run_callbacks(:execute_perform) do 98 | execute_perform!(**options) 99 | end 100 | @_action_performed = true 101 | result || true 102 | rescue *handled_exceptions => e 103 | handle_exception(e) 104 | raise_validation_error(e) if raise_errors 105 | raise Rollback 106 | end 107 | 108 | def perform_action!(**options) 109 | perform_action(raise_errors: true, **options) 110 | end 111 | 112 | def execute_perform!(**_options) 113 | raise NotImplementedError, "BA perform body MUST be defined for #{self}" 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/granite/action/policies.rb: -------------------------------------------------------------------------------- 1 | require 'granite/action/error' 2 | require 'granite/action/policies/any_strategy' 3 | require 'granite/action/policies/always_allow_strategy' 4 | require 'granite/action/policies/required_performer_strategy' 5 | 6 | module Granite 7 | class Action 8 | class NotAllowedError < Error # :nodoc: 9 | def initialize(action) 10 | performer_id = "##{action.performer.id}" if action.performer.respond_to?(:id) && action.performer.id.present? 11 | 12 | super("#{action.class} action is not allowed " \ 13 | "for #{action.performer.class}#{performer_id}", action) 14 | end 15 | end 16 | 17 | # Policies module used for abilities definition. Basically 18 | # policies are defined as blocks which are executed in action 19 | # instance context, so performer, object and all the attributes 20 | # are available inside the block. 21 | # 22 | # By default action is allowed to be performed only by default performer. 23 | # 24 | module Policies 25 | extend ActiveSupport::Concern 26 | 27 | included do 28 | class_attribute :_policies, :_policies_strategy, instance_writer: false 29 | self._policies = [] 30 | self._policies_strategy = AnyStrategy 31 | end 32 | 33 | module ClassMethods # :nodoc: 34 | # The simplies policy. Takes block and executes it returning 35 | # boolean result. Multiple policies are reduced with || 36 | # 37 | # class Action < Granite::Action 38 | # allow_if { performer.is_a?(Recruiter) } 39 | # allow_if { performer.is_a?(AdvancedRecruiter) } 40 | # end 41 | # 42 | # The first argument in block is a current action performer, 43 | # so it is possible to use a short-cut performer methods: 44 | # 45 | # class Action < Granite::Action 46 | # allow_if(&:staff?) 47 | # end 48 | # 49 | def allow_if(&block) 50 | self._policies += [block] 51 | end 52 | 53 | def allow_self 54 | allow_if { performer == subject } 55 | end 56 | end 57 | 58 | def try_perform!(*, **) 59 | authorize! 60 | super 61 | end 62 | 63 | def perform(*, **) 64 | authorize! 65 | super 66 | end 67 | 68 | def perform!(*, **) 69 | authorize! 70 | super 71 | end 72 | 73 | # Returns true if any of defined policies returns true 74 | # 75 | def allowed? 76 | @allowed = _policies_strategy.allowed?(self) unless instance_variable_defined?(:@allowed) 77 | @allowed 78 | end 79 | 80 | # Raises Granite::Action::NotAllowedError if action is not allowed 81 | # 82 | def authorize! 83 | raise Granite::Action::NotAllowedError, self unless allowed? 84 | 85 | self 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/granite/action/policies/always_allow_strategy.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | class Action 3 | module Policies 4 | # A Granite policies strategy which allows an action to be performed unconditionally. 5 | # No defined policies are evaluated. 6 | class AlwaysAllowStrategy 7 | def self.allowed?(_action) 8 | true 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/granite/action/policies/any_strategy.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | class Action 3 | module Policies 4 | # Granite BA policy which allows action to be performed if at least one defined policy evaluates to true 5 | class AnyStrategy 6 | def self.allowed?(action) 7 | action._policies.any? { |policy| action.instance_exec(action.performer, &policy) } 8 | end 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/granite/action/policies/required_performer_strategy.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | class Action 3 | module Policies 4 | # A Granite policies strategy which requires a performer to be present 5 | # 6 | # and at least one defined policy to be evaluated to true 7 | class RequiredPerformerStrategy < AnyStrategy 8 | def self.allowed?(action) 9 | action.performer.present? && super 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/granite/action/precondition.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | class Action 3 | class Precondition < BasicObject # :nodoc: 4 | UNDEFINED = ::Object.new.freeze 5 | 6 | def self.description(text = UNDEFINED) 7 | case text 8 | when UNDEFINED 9 | @description 10 | else 11 | @description = text 12 | end 13 | end 14 | 15 | def initialize(context) 16 | @context = context 17 | end 18 | 19 | def call(*) 20 | raise NotImplementedError, "#call method must be implemented for #{self.class}" 21 | end 22 | 23 | def method_missing(method_name, *args, &blk) 24 | super unless @context.respond_to?(method_name) 25 | 26 | @context.__send__(method_name, *args, &blk) 27 | end 28 | 29 | def respond_to_missing?(method_name, _include_private = false) 30 | @context.respond_to?(method_name) 31 | end 32 | 33 | private 34 | 35 | attr_reader :context 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/granite/action/preconditions.rb: -------------------------------------------------------------------------------- 1 | require 'granite/action/preconditions/base_precondition' 2 | require 'granite/action/preconditions/embedded_precondition' 3 | require 'granite/action/preconditions/object_precondition' 4 | 5 | module Granite 6 | class Action 7 | # Conditions module is used to define preconditions for actions. 8 | # Each precondition is also defined as validation, so it always run 9 | # before action execution. Precondition name is by default 10 | # I18n key for +:base+ error, if precondition fails. Along with 11 | # preconditions question methods with the same names are created. 12 | # 13 | module Preconditions 14 | extend ActiveSupport::Concern 15 | 16 | class PreconditionsCollection # :nodoc: 17 | include Enumerable 18 | 19 | delegate :each, to: :@preconditions 20 | 21 | def initialize(*preconditions) 22 | @preconditions = preconditions.flatten 23 | end 24 | 25 | def +(other) 26 | self.class.new(*@preconditions, other) 27 | end 28 | 29 | def execute!(context) 30 | @preconditions.each { |precondition| precondition.execute!(context) } 31 | end 32 | end 33 | 34 | included do 35 | class_attribute :_preconditions, instance_writer: false 36 | self._preconditions = PreconditionsCollection.new 37 | end 38 | 39 | module ClassMethods # :nodoc: 40 | # Define preconditions for current action. 41 | # 42 | # @param options [Hash] hash with 43 | # @option message [String, Symbol] error message 44 | # @option group [Symbol] procondition group(s) 45 | # @param block [Block] which returns truthy value when precondition 46 | # should pass. 47 | def precondition(*args, &block) 48 | options = args.extract_options! 49 | if block 50 | add_precondition(BasePrecondition, options, &block) 51 | elsif args.first.is_a?(Class) 52 | add_precondition(ObjectPrecondition, *args, options) 53 | else 54 | add_preconditions_hash(*args, **options) 55 | end 56 | end 57 | 58 | private 59 | 60 | def klass(key) 61 | key = key.to_s.camelize 62 | Granite.precondition_namespaces.reduce(nil) do |memo, ns| 63 | memo || "#{ns.to_s.camelize}::#{key}Precondition".safe_constantize 64 | end || raise(NameError, "No precondition class for #{key}Precondition") 65 | end 66 | 67 | def add_preconditions_hash(*args, **options) 68 | common_options = options.extract!(:if, :unless, :desc, :description) 69 | args.each do |type| 70 | precondition common_options.merge(type => {}) 71 | end 72 | options.each do |key, value| 73 | value = Array.wrap(value) 74 | precondition_options = value.extract_options! 75 | add_precondition(klass(key), *value, precondition_options.merge!(common_options)) 76 | end 77 | end 78 | 79 | def add_precondition(klass, *args, &block) 80 | self._preconditions += klass.new(*args, &block) 81 | end 82 | end 83 | 84 | attr_reader :failed_preconditions 85 | 86 | def initialize(*) 87 | @failed_preconditions = [] 88 | @preconditions_run = nil 89 | super 90 | end 91 | 92 | # Check if all preconditions are satisfied 93 | # 94 | # @return [Boolean] wheter all preconditions are satisfied 95 | def satisfy_preconditions?(cache_result: false) 96 | errors.clear 97 | failed_preconditions.clear 98 | run_preconditions!(cache_result: cache_result) 99 | end 100 | 101 | # Adds passed error message and options to `errors` object 102 | def decline_with(*args, **kwargs) 103 | errors.add(:base, *args, **kwargs) 104 | failed_preconditions << args.first 105 | end 106 | 107 | private 108 | 109 | def run_preconditions!(cache_result: false) 110 | _preconditions.execute!(self) if @preconditions_run.nil? 111 | @preconditions_run = true if cache_result 112 | errors.empty? 113 | end 114 | 115 | def run_validations! 116 | run_preconditions! && super 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/granite/action/preconditions/base_precondition.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | class Action 3 | module Preconditions 4 | class BasePrecondition # :nodoc: 5 | def initialize(*args, &block) 6 | @options = args.extract_options! 7 | @args = args 8 | @block = block 9 | end 10 | 11 | def execute!(context) 12 | _execute(context) if context.conditions_satisfied?(**@options) 13 | end 14 | 15 | private 16 | 17 | def _execute(context) 18 | context.instance_exec(&@block) 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/granite/action/preconditions/embedded_precondition.rb: -------------------------------------------------------------------------------- 1 | require 'granite/action/preconditions/base_precondition' 2 | 3 | module Granite 4 | class Action 5 | module Preconditions 6 | # Checks related business actions for precondition errors and adds them to current action. 7 | # 8 | # memoize def child_action 9 | # ... 10 | # end 11 | # precondition embedded: :child_action 12 | # 13 | # memoize def child_action 14 | # ... 15 | # end 16 | # memoize def child_actions 17 | # ... 18 | # end 19 | # precondition embedded: [:child_action, :child_actions] 20 | # 21 | class EmbeddedPrecondition < BasePrecondition 22 | private 23 | 24 | def _execute(context) 25 | associations = Array.wrap(@args.first) 26 | associations.each do |name| 27 | actions = Array.wrap(context.__send__(name)) 28 | actions.each do |action| 29 | decline_action(context, action) 30 | end 31 | end 32 | end 33 | 34 | def decline_action(context, action) 35 | return if action.satisfy_preconditions? 36 | 37 | action.errors[:base].each { |error| context.errors.add(:base, error) } 38 | action.failed_preconditions.each { |error| context.failed_preconditions << error } 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/granite/action/preconditions/object_precondition.rb: -------------------------------------------------------------------------------- 1 | require 'granite/action/preconditions/base_precondition' 2 | 3 | module Granite 4 | class Action 5 | module Preconditions 6 | class ObjectPrecondition < BasePrecondition # :nodoc: 7 | private 8 | 9 | def _execute(context) 10 | @args.first.new(context).call(**@options.except(:if, :unless)) 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/granite/action/projectors.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | class Action 3 | module Projectors # :nodoc: 4 | extend ActiveSupport::Concern 5 | 6 | class ProjectorsCollection # :nodoc: 7 | def initialize(action_class) 8 | @action_class = action_class 9 | @storage = {} 10 | @cache = {} 11 | end 12 | 13 | def fetch(name) 14 | @cache[name.to_sym] ||= setup_projector(name) 15 | end 16 | 17 | def store(name, options, &block) 18 | old_options, old_blocks = fetch_options_and_blocks(name) 19 | @storage[name.to_sym] = [ 20 | old_options.merge(options || {}), 21 | old_blocks + [block].compact 22 | ] 23 | end 24 | 25 | def names 26 | @storage.keys | (@action_class.superclass < Granite::Action ? @action_class.superclass._projectors.names : []) 27 | end 28 | 29 | private 30 | 31 | def setup_projector(name) 32 | options, blocks = fetch_options_and_blocks(name) 33 | 34 | projector_name = "#{name}_projector".classify 35 | controller_name = "#{name}_controller".classify 36 | 37 | projector = Class.new(projector_superclass(name, projector_name, options)) 38 | projector.action_class = @action_class 39 | 40 | redefine_const(projector_name, projector) 41 | redefine_const(controller_name, projector.controller_class) 42 | 43 | blocks.each { |block| projector.class_eval(&block) } 44 | 45 | projector 46 | end 47 | 48 | def redefine_const(name, klass) 49 | if @action_class.const_defined?(name, false) 50 | @action_class.__send__(:remove_const, name) 51 | # TODO: this remove is confusing, would be better to raise? - ask @pyromaniac 52 | end 53 | @action_class.const_set(name, klass) 54 | end 55 | 56 | def fetch_options_and_blocks(name) 57 | name = name.to_sym 58 | options, blocks = @storage[name.to_sym] 59 | options ||= {} 60 | blocks ||= [] 61 | 62 | [options, blocks] 63 | end 64 | 65 | def projector_superclass(name, projector_name, options) 66 | superclass = options[:class_name].presence.try(:constantize) 67 | superclass ||= @action_class.superclass._projectors.fetch(name) if @action_class.superclass < Granite::Action 68 | 69 | superclass || projector_name.safe_constantize || Granite::Projector 70 | end 71 | end 72 | 73 | module ClassMethods # :nodoc: 74 | def _projectors 75 | @_projectors ||= ProjectorsCollection.new(self) 76 | end 77 | 78 | def projector_names 79 | _projectors.names 80 | end 81 | 82 | def projector(name, options = {}, &block) 83 | _projectors.store(name, options, &block) 84 | 85 | class_eval <<-METHOD, __FILE__, __LINE__ + 1 86 | def self.#{name} # def self.foo 87 | _projectors.fetch(:#{name}) # _projectors.fetch(:foo) 88 | end # end 89 | # 90 | def #{name} # def foo 91 | @#{name} ||= self.class._projectors.fetch(:#{name}).new(self) # @foo ||= self.class._projectors.fetch(:foo).new(self) 92 | end # end 93 | METHOD 94 | end 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/granite/action/subject.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | class Action 3 | class SubjectNotFoundError < ArgumentError # :nodoc: 4 | def initialize(action_class) 5 | super("Unable to initialize #{action_class} without subject provided") 6 | end 7 | end 8 | 9 | class SubjectTypeMismatchError < ArgumentError # :nodoc: 10 | def initialize(action_class, candidate, expected) 11 | super("Unable to initialize #{action_class} with #{candidate} as subject, expecting instance of #{expected}") 12 | end 13 | end 14 | 15 | module Subject # :nodoc: 16 | extend ActiveSupport::Concern 17 | 18 | included do 19 | class_attribute :_subject 20 | end 21 | 22 | module ClassMethods # :nodoc: 23 | def subject(name, *args, &block) 24 | reflection = reflect_on_association(name) 25 | reflection ||= references_one name, *args, &block 26 | 27 | alias_association :subject, reflection.name 28 | alias_attribute :id, reflection.reference_key 29 | 30 | self._subject = name 31 | end 32 | 33 | def subject? 34 | _subject.present? 35 | end 36 | 37 | def subject_reflection 38 | reflect_on_association(_subject) 39 | end 40 | end 41 | 42 | def initialize(*args) 43 | return super unless self.class.subject? # rubocop:disable Lint/ReturnInVoidContext 44 | 45 | reflection = self.class.subject_reflection 46 | attributes = extract_initialize_attributes(args) 47 | 48 | subject_attributes = extract_subject_attributes!(attributes, reflection) 49 | assign_subject(args, subject_attributes, reflection) 50 | 51 | super(attributes) 52 | end 53 | 54 | private 55 | 56 | def extract_initialize_attributes(args) 57 | if args.last.respond_to?(:to_unsafe_hash) 58 | args.pop.to_unsafe_hash 59 | else 60 | args.extract_options! 61 | end.symbolize_keys 62 | end 63 | 64 | def assign_subject(args, attributes, reflection) 65 | assign_attributes(attributes) 66 | 67 | self.subject = args.first unless args.empty? 68 | raise SubjectNotFoundError, self.class unless subject 69 | rescue Granite::Form::AssociationTypeMismatch 70 | raise SubjectTypeMismatchError.new(self.class, args.first.class.name, reflection.klass) 71 | end 72 | 73 | def extract_subject_attributes!(attributes, reflection) 74 | attributes.extract!(:subject, :id, reflection.name, reflection.reference_key) 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/granite/action/transaction.rb: -------------------------------------------------------------------------------- 1 | require 'granite/action/transaction_manager' 2 | 3 | module Granite 4 | class Action 5 | module Transaction # :nodoc: 6 | extend ActiveSupport::Concern 7 | 8 | included do 9 | define_model_callbacks :commit, only: :after 10 | singleton_class.delegate :transaction, to: :'Granite::Action::TransactionManager' 11 | end 12 | 13 | def run_callbacks(event) 14 | if event.to_s == 'commit' 15 | begin 16 | super 17 | rescue *handled_exceptions => e 18 | handle_exception(e) 19 | end 20 | else 21 | super 22 | end 23 | end 24 | 25 | private 26 | 27 | attr_accessor :granite_in_transaction 28 | 29 | def transaction(&block) 30 | if granite_in_transaction 31 | yield 32 | else 33 | run_in_transaction(&block) 34 | end 35 | end 36 | 37 | def run_in_transaction 38 | self.granite_in_transaction = true 39 | 40 | TransactionManager.transaction do 41 | TransactionManager.after_commit(self) 42 | yield 43 | end 44 | ensure 45 | self.granite_in_transaction = false 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/granite/action/transaction_manager.rb: -------------------------------------------------------------------------------- 1 | require 'granite/action/transaction_manager/transactions_stack' 2 | 3 | module Granite 4 | class Action 5 | class Rollback < defined?(ActiveRecord) ? ActiveRecord::Rollback : StandardError 6 | end 7 | 8 | module TransactionManager # :nodoc: 9 | class << self 10 | # Runs a block in a transaction 11 | # It will open a new transaction or append a block to the current one if it exists 12 | # @return [Object] result of a block 13 | def transaction(&block) 14 | run_in_transaction(&block) || false 15 | ensure 16 | finish_root_transaction if transactions_stack.depth.zero? 17 | end 18 | 19 | # Adds a block or listener object to be executed after finishing the current transaction. 20 | # Callbacks are reset after each transaction. 21 | # @param [Object] listener an object which will receive `run_callbacks(:commit)` after transaction committed 22 | # @param [Proc] block a block which will be called after transaction committed 23 | def after_commit(listener = nil, &block) 24 | callback = listener || block 25 | 26 | raise 'Block or object is required to register after_commit hook!' unless callback 27 | 28 | transactions_stack.add_callback callback 29 | end 30 | 31 | private 32 | 33 | TRANSACTIONS_STACK_KEY = :granite_transaction_manager_transactions_stack 34 | 35 | def transactions_stack 36 | Thread.current[TRANSACTIONS_STACK_KEY] ||= TransactionsStack.new 37 | end 38 | 39 | def transactions_stack=(value) 40 | Thread.current[TRANSACTIONS_STACK_KEY] = value 41 | end 42 | 43 | def run_in_transaction(&block) 44 | if defined?(ActiveRecord::Base) 45 | ActiveRecord::Base.transaction(requires_new: true) do 46 | transactions_stack.transaction(&block) 47 | end 48 | else 49 | transactions_stack.transaction(&block) 50 | end 51 | end 52 | 53 | def finish_root_transaction 54 | callbacks = transactions_stack.callbacks 55 | 56 | self.transactions_stack = nil 57 | 58 | trigger_after_commit_callbacks(callbacks) 59 | end 60 | 61 | def trigger_after_commit_callbacks(callbacks) 62 | collected_errors = [] 63 | 64 | callbacks.reverse_each do |callback| 65 | callback.respond_to?(:_run_commit_callbacks) ? callback._run_commit_callbacks : callback.call 66 | rescue StandardError => e 67 | collected_errors << e 68 | end 69 | 70 | return unless collected_errors.any? 71 | 72 | log_errors(collected_errors[1..]) 73 | raise collected_errors.first 74 | end 75 | 76 | def log_errors(errors) 77 | errors.each do |error| 78 | message = "Unhandled error in after_commit callback: #{error.inspect}\n#{error.backtrace.join("\n")}" 79 | Granite::Form.config.logger.error message 80 | end 81 | end 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/granite/action/transaction_manager/transactions_stack.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | class Action 3 | module TransactionManager 4 | # A class to manage transaction callbacks stack. 5 | class TransactionsStack 6 | attr_reader :depth 7 | 8 | def initialize 9 | @callbacks = [] 10 | @depth = 0 11 | end 12 | 13 | def transaction 14 | start_new! 15 | result = yield 16 | finish_current! 17 | result 18 | rescue StandardError, ScriptError 19 | rollback_current! 20 | raise 21 | end 22 | 23 | def add_callback(callback) 24 | raise 'Start a transaction before you add callbacks on it' if depth.zero? 25 | 26 | @callbacks.last << callback 27 | end 28 | 29 | def callbacks 30 | @callbacks.flatten 31 | end 32 | 33 | private 34 | 35 | def start_new! 36 | @depth += 1 37 | @callbacks << [] 38 | end 39 | 40 | def finish_current! 41 | finish_current(true) 42 | end 43 | 44 | def rollback_current! 45 | finish_current(false) 46 | end 47 | 48 | def finish_current(result) 49 | raise ArgumentError, 'No current transaction' if @depth.zero? 50 | 51 | @depth -= 1 52 | 53 | if result 54 | current = @callbacks.pop 55 | previous = @callbacks.pop 56 | @callbacks << [previous, current].flatten.compact 57 | else 58 | @callbacks.pop 59 | end 60 | end 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/granite/action/translations.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | class Action 3 | module Translations # :nodoc: 4 | extend ActiveSupport::Concern 5 | 6 | module ClassMethods # :nodoc: 7 | def i18n_scope 8 | :granite_action 9 | end 10 | 11 | def i18n_scopes 12 | lookup_ancestors.flat_map do |klass| 13 | :"#{klass.i18n_scope}.#{klass.model_name.i18n_key}" 14 | end + [nil] 15 | end 16 | end 17 | 18 | def translate(*args, **options) 19 | key, options = Granite::Translations.scope_translation_args(self.class.i18n_scopes, *args, **options) 20 | I18n.t(key, **options) 21 | end 22 | alias t translate 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/granite/assign_data.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | module AssignData # :nodoc: 3 | DataAssignment = Struct.new(:method, :options) # rubocop:disable Lint/StructNewOverride 4 | 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | class_attribute :data_assignments 9 | self.data_assignments = [] 10 | 11 | alias_method :only_run_validations!, :run_validations! 12 | protected :only_run_validations! 13 | 14 | assign_data :sync_attributes 15 | end 16 | 17 | module ClassMethods # :nodoc: 18 | # Defines a callback to call when assigning data from business action to model. 19 | # @param methods [Array] list of methods to call 20 | # @param block [Proc] a block to call 21 | # @option options [Symbol, Proc, Object] :if call methods/block if this condition evaluates to true 22 | # @option options [Symbol, Proc, Object] :unless call method/block unless this condition evaluates to true 23 | def assign_data(*methods, **options, &block) 24 | self.data_assignments += methods.map { |method| DataAssignment.new(method, options) } 25 | self.data_assignments += [DataAssignment.new(block, options)] if block 26 | end 27 | end 28 | 29 | protected 30 | 31 | def assign_data 32 | data_assignments.each { |assignment| evaluate(assignment.method) if conditions_satisfied?(**assignment.options) } 33 | end 34 | 35 | private 36 | 37 | def run_validations! 38 | assign_data 39 | super 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/granite/base.rb: -------------------------------------------------------------------------------- 1 | require 'granite/form/model' 2 | require 'granite/form/model/primary' 3 | require 'granite/form/model/associations' 4 | 5 | require 'granite/translations' 6 | require 'granite/assign_data' 7 | 8 | module Granite 9 | # Base included in Granite::Action, but also used by Granite::Form when building data objects (e.g. when using 10 | # embeds_many) 11 | module Base 12 | extend ActiveSupport::Concern 13 | extend ActiveModel::Callbacks 14 | 15 | include Granite::Form::Model 16 | include Granite::Form::Model::Representation 17 | include Granite::Form::Model::Dirty 18 | include Granite::Form::Model::Associations 19 | include Granite::Form::Model::Primary 20 | include ActiveModel::Validations::Callbacks 21 | 22 | include Granite::AssignData 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/granite/config.rb: -------------------------------------------------------------------------------- 1 | require 'singleton' 2 | require 'active_support/core_ext/object/try' 3 | 4 | module Granite 5 | class Config # :nodoc: 6 | include Singleton 7 | 8 | attr_accessor :base_controller 9 | attr_writer :precondition_namespaces 10 | 11 | def base_controller_class 12 | base_controller&.constantize || ActionController::Base 13 | end 14 | 15 | def precondition_namespaces 16 | @precondition_namespaces ||= %w[Granite::Action::Preconditions] 17 | end 18 | 19 | def self.delegated 20 | public_instance_methods - superclass.public_instance_methods - Singleton.public_instance_methods 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/granite/context.rb: -------------------------------------------------------------------------------- 1 | require 'singleton' 2 | 3 | module Granite 4 | class Context # :nodoc: 5 | include Singleton 6 | 7 | def view_context 8 | Thread.current[:granite_view_context] 9 | end 10 | 11 | def view_context=(context) 12 | Thread.current[:granite_view_context] = context 13 | end 14 | 15 | def with_view_context(context) 16 | old_view_context = view_context 17 | self.view_context = context 18 | 19 | yield 20 | ensure 21 | self.view_context = old_view_context 22 | end 23 | 24 | def self.delegated 25 | public_instance_methods - superclass.public_instance_methods - Singleton.public_instance_methods 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/granite/context_proxy.rb: -------------------------------------------------------------------------------- 1 | require 'granite/context_proxy/data' 2 | require 'granite/context_proxy/proxy' 3 | 4 | module Granite 5 | # This concern contains class methods used for actions and projectors 6 | # 7 | module ContextProxy 8 | extend ActiveSupport::Concern 9 | 10 | module ClassMethods # :nodoc: 11 | PROXY_CONTEXT_KEY = :granite_proxy_context 12 | 13 | def with(data) 14 | Proxy.new(self, Data.wrap(data)) 15 | end 16 | 17 | def as(performer) 18 | with(performer: performer) 19 | end 20 | 21 | def with_context(context) 22 | old_context = proxy_context 23 | Thread.current[PROXY_CONTEXT_KEY] = context 24 | yield 25 | ensure 26 | Thread.current[PROXY_CONTEXT_KEY] = old_context 27 | end 28 | 29 | def proxy_context 30 | Thread.current[PROXY_CONTEXT_KEY] 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/granite/context_proxy/data.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | module ContextProxy 3 | # Contains all the arbitrary data that is passed to BA with `with` 4 | class Data 5 | attr_reader :performer 6 | 7 | def self.wrap(data) 8 | if data.is_a?(self) 9 | data 10 | else 11 | new(**data || {}) 12 | end 13 | end 14 | 15 | def initialize(performer: nil) 16 | @performer = performer 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/granite/context_proxy/proxy.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | module ContextProxy 3 | # Proxy which wraps the following method calls with BA context. 4 | # 5 | class Proxy 6 | def initialize(klass, context) 7 | @klass = klass 8 | @context = context 9 | end 10 | 11 | def inspect 12 | "<#{@klass}ContextProxy #{@context}>" 13 | end 14 | 15 | ruby2_keywords def method_missing(method, *args, &block) 16 | if @klass.respond_to?(method) 17 | @klass.with_context(@context) do 18 | @klass.public_send(method, *args, &block) 19 | end 20 | else 21 | super 22 | end 23 | end 24 | 25 | def respond_to_missing?(*args) 26 | @klass.respond_to?(*args) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/granite/dispatcher.rb: -------------------------------------------------------------------------------- 1 | require 'memoist' 2 | require 'action_controller/metal/exceptions' 3 | 4 | module Granite 5 | class Dispatcher # :nodoc: 6 | extend Memoist 7 | 8 | # Make dispatcher object pristine, clean memoist cache. 9 | def reset! 10 | unmemoize_all 11 | end 12 | 13 | def call(*) 14 | # Pretend to be a Rack app, however we are still dispatcher, so this method should never be called 15 | # see lib/granite/routing/mapping.rb for more info. 16 | raise 'Dispatcher can\'t be used as a Rack app.' 17 | end 18 | 19 | def serve(req) 20 | controller, action = detect_controller_class_and_action_name(req) 21 | controller.action(action).call(req.env) 22 | end 23 | 24 | def constraints 25 | [->(req) { detect_controller_class_and_action_name(req).all?(&:present?) }] 26 | end 27 | 28 | def controller(params, *_args) 29 | projector(*params.values_at(:granite_action, :granite_projector))&.controller_class 30 | end 31 | 32 | def prepare_params!(params, *_args) 33 | params 34 | end 35 | 36 | private 37 | 38 | def detect_controller_class_and_action_name(req) 39 | [ 40 | controller(req.params), 41 | action_name( 42 | req.request_method_symbol, 43 | *req.params.values_at(:granite_action, :granite_projector, :projector_action) 44 | ) 45 | ] 46 | end 47 | 48 | memoize def action_name(request_method_symbol, granite_action, granite_projector, projector_action) 49 | projector = projector(granite_action, granite_projector) 50 | return unless projector 51 | 52 | projector.action_for(request_method_symbol, projector_action.to_s) 53 | end 54 | 55 | memoize def projector(granite_action, granite_projector) 56 | action = business_action(granite_action) 57 | 58 | action.public_send(granite_projector) if action.respond_to?(granite_projector) 59 | end 60 | 61 | memoize def business_action(granite_action) 62 | granite_action.camelize.safe_constantize || 63 | raise(ActionController::RoutingError, 64 | "Granite action '#{granite_action}' is mounted but class '#{granite_action.camelize}' can't be found") 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/granite/error.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | class Error < StandardError 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/granite/projector.rb: -------------------------------------------------------------------------------- 1 | require 'granite/projector/controller_actions' 2 | require 'granite/projector/error' 3 | require 'granite/projector/helpers' 4 | require 'granite/projector/translations' 5 | require 'granite/context_proxy' 6 | 7 | module Granite 8 | class Projector # :nodoc: 9 | include ContextProxy 10 | include ControllerActions 11 | include Helpers 12 | include Translations 13 | 14 | singleton_class.__send__(:attr_accessor, :action_class) 15 | delegate :action_class, :projector_name, :action_name, to: 'self.class' 16 | attr_reader :action 17 | 18 | def self.controller_class 19 | return Granite::Controller unless superclass.respond_to?(:controller_class) 20 | 21 | @controller_class ||= Class.new(superclass.controller_class).tap do |klass| 22 | klass.projector_class = self 23 | end 24 | end 25 | 26 | def self.projector_path 27 | @projector_path ||= name.remove(/Projector$/).underscore 28 | end 29 | 30 | def self.projector_name 31 | @projector_name ||= name.demodulize.remove(/Projector$/).underscore 32 | end 33 | 34 | def self.action_name 35 | @action_name ||= action_class.name.underscore 36 | end 37 | 38 | def initialize(*args) 39 | @action = if args.first.is_a?(Granite::Action) # Temporary solutions for backwards compatibility. 40 | args.first 41 | else 42 | build_action(*args) 43 | end 44 | end 45 | 46 | private 47 | 48 | def build_action(*args) 49 | action_class.with(self.class.proxy_context).new(*args) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/granite/projector/controller_actions.rb: -------------------------------------------------------------------------------- 1 | require 'action_dispatch/routing' 2 | 3 | module Granite 4 | class Projector 5 | module ControllerActions # :nodoc: 6 | extend ActiveSupport::Concern 7 | 8 | included do 9 | class_attribute :controller_actions 10 | self.controller_actions = {} 11 | 12 | ActionDispatch::Routing::HTTP_METHODS.each do |method| 13 | define_singleton_method method do |name, options = {}, &block| 14 | action(name, options.merge(method: method), &block) 15 | end 16 | end 17 | end 18 | 19 | module ClassMethods # :nodoc: 20 | def action(name, options = {}, &block) # rubocop:disable Metrics/MethodLength 21 | if block 22 | self.controller_actions = controller_actions.merge(name.to_sym => options) 23 | controller_class.__send__(:define_method, name, &block) 24 | class_eval <<-METHOD, __FILE__, __LINE__ + 1 25 | def #{name}_url(options = {}) # def foo_url(options = {} 26 | action_url(:#{name}, **options.symbolize_keys) # action_url(:foo, **options.symbolize_keys) 27 | end # end 28 | # 29 | def #{name}_path(options = {}) # def foo_path(options = {}) 30 | action_path(:#{name}, **options.symbolize_keys) # action_path(:foo, **options.symbolize_keys) 31 | end # end 32 | METHOD 33 | else 34 | controller_actions[name.to_sym] 35 | end 36 | end 37 | 38 | def action_for(http_method, action) 39 | controller_actions.find do |controller_action, controller_action_options| 40 | controller_action_options.fetch(:as, controller_action).to_s == action && 41 | Array(controller_action_options.fetch(:method)).include?(http_method) 42 | end&.first 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/granite/projector/error.rb: -------------------------------------------------------------------------------- 1 | require 'granite/error' 2 | 3 | module Granite 4 | class Projector 5 | class Error < Granite::Error # :nodoc: 6 | attr_reader :projector 7 | 8 | def initialize(message, projector = nil) 9 | @projector = projector 10 | super(message) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/granite/projector/helpers.rb: -------------------------------------------------------------------------------- 1 | require 'granite/projector/error' 2 | 3 | module Granite 4 | class Projector 5 | class ActionNotMountedError < Error # :nodoc: 6 | def initialize(projector) 7 | super("Seems like #{projector.class} was not mounted. \ 8 | Do you have #{projector.action_name}##{projector.projector_name} declared in routes?", projector) 9 | end 10 | end 11 | 12 | module Helpers # :nodoc: 13 | extend ActiveSupport::Concern 14 | 15 | def view_context 16 | Granite.view_context 17 | end 18 | alias h view_context 19 | 20 | def action_url(action, **options) 21 | action_path = controller_actions[action.to_sym].fetch(:as, action) 22 | params = required_params.merge(projector_action: action_path) 23 | 24 | Rails.application.routes.url_for( 25 | options.reverse_merge(url_options).merge!(params), 26 | corresponding_route.name 27 | ) 28 | end 29 | 30 | def action_path(action, **options) 31 | action_url(action, **options, only_path: true) 32 | end 33 | 34 | private 35 | 36 | def required_params 37 | corresponding_route.required_parts 38 | .to_h { |name| [name, action.public_send(name)] } 39 | end 40 | 41 | def corresponding_route 42 | @corresponding_route ||= fetch_corresponding_route 43 | end 44 | 45 | def route_id 46 | [action_name, projector_name] 47 | end 48 | 49 | def url_options 50 | h&.url_options || {} 51 | end 52 | 53 | def fetch_corresponding_route 54 | Rails.application.routes.routes.granite_cache[*route_id] || raise(ActionNotMountedError, self) 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/granite/projector/translations.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | class Projector 3 | module Translations # :nodoc: 4 | include ActionView::Helpers::TranslationHelper 5 | 6 | def i18n_scopes 7 | Granite::Translations.combine_paths(action_class.i18n_scopes, [:"#{projector_name}"]) 8 | end 9 | 10 | def translate(*args, **options) 11 | key, options = Granite::Translations.scope_translation_args(i18n_scopes, *args, **options) 12 | super(key, **options) 13 | end 14 | 15 | alias t translate 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/granite/rails.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | # Core module for Rails extension. Implements some framework initialization, 3 | # like setting up proper load paths. 4 | class Railtie < ::Rails::Engine 5 | isolate_namespace Granite 6 | 7 | initializer 'granite.business_actions_paths', before: :set_autoload_paths do |app| 8 | app.config.paths.add 'apq', eager_load: true, glob: '{actions,projectors}{,/concerns}' 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/granite/routing.rb: -------------------------------------------------------------------------------- 1 | require 'action_dispatch' 2 | require 'granite/routing/caching' 3 | require 'granite/routing/mapping' 4 | require 'granite/routing/mapper' 5 | -------------------------------------------------------------------------------- /lib/granite/routing/cache.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | module Routing 3 | class Cache # :nodoc: 4 | attr_reader :routes 5 | 6 | def initialize(routes) 7 | @routes = routes 8 | end 9 | 10 | def [](action, projector) 11 | projector = projector.to_s 12 | Array(grouped_routes[action.to_s]).detect do |route| 13 | route.required_defaults[:granite_projector] == projector 14 | end 15 | end 16 | 17 | private 18 | 19 | def grouped_routes 20 | @grouped_routes ||= routes.group_by { |r| r.required_defaults[:granite_action] } 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/granite/routing/caching.rb: -------------------------------------------------------------------------------- 1 | require 'granite/routing/cache' 2 | 3 | module Granite 4 | module Routing 5 | module Caching # :nodoc: 6 | def granite_cache 7 | @granite_cache ||= Cache.new(self) 8 | end 9 | 10 | def clear_cache! 11 | @granite_cache = nil 12 | super 13 | end 14 | end 15 | end 16 | end 17 | 18 | ActionDispatch::Journey::Routes.prepend Granite::Routing::Caching 19 | -------------------------------------------------------------------------------- /lib/granite/routing/declarer.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | module Routing 3 | module Declarer # :nodoc: 4 | class << self 5 | def declare(routing, route, **options) 6 | routing.match route.path, 7 | via: :all, 8 | **options, 9 | to: dispatcher, 10 | as: route.as, 11 | granite_action: route.action_path, 12 | granite_projector: route.projector_name 13 | end 14 | 15 | def dispatcher 16 | @dispatcher ||= Dispatcher.new 17 | end 18 | 19 | def reset_dispatcher 20 | dispatcher.reset! 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/granite/routing/mapper.rb: -------------------------------------------------------------------------------- 1 | require 'granite/routing/declarer' 2 | require 'granite/routing/route' 3 | 4 | module Granite 5 | module Routing 6 | module Mapper # :nodoc: 7 | def granite(projector_path, **options) 8 | route = Route.new(projector_path, **options.extract!(:path, :as, :projector_prefix)) 9 | Declarer.declare(self, route, **options) 10 | end 11 | end 12 | end 13 | end 14 | 15 | ActionDispatch::Routing::Mapper.include Granite::Routing::Mapper 16 | -------------------------------------------------------------------------------- /lib/granite/routing/mapping.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | module Routing 3 | module Mapping # :nodoc: 4 | # Override the `ActionDispatch::Routing::Mapper::Mapping#app` method to 5 | # be able to mount custom Dispatcher objects. Otherwise, the only way to 6 | # point a dispatcher to business actions is to mount it as a Rack app 7 | # but we want to use regular Rails flow. 8 | def app(*) 9 | if to.is_a?(Granite::Dispatcher) 10 | ActionDispatch::Routing::Mapper::Constraints.new( 11 | to, 12 | to.constraints, 13 | ActionDispatch::Routing::Mapper::Constraints::SERVE 14 | ) 15 | else 16 | super 17 | end 18 | end 19 | end 20 | end 21 | end 22 | 23 | ActionDispatch::Routing::Mapper::Mapping.prepend Granite::Routing::Mapping 24 | -------------------------------------------------------------------------------- /lib/granite/routing/route.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | module Routing 3 | class Route # :nodoc: 4 | attr_reader :projector_path, :action_path, :projector_name 5 | 6 | def initialize(projector_path, path: nil, as: nil, projector_prefix: false) 7 | @projector_path = projector_path 8 | @action_path, @projector_name = projector_path.split('#') 9 | @path = path 10 | @as = as 11 | 12 | @action_name = @action_path.split('/').last 13 | @action_name = "#{@projector_name}_#{@action_name}" if projector_prefix 14 | end 15 | 16 | def path 17 | "#{@path || action_name}(/:projector_action)" 18 | end 19 | 20 | def as 21 | @as || action_name 22 | end 23 | 24 | private 25 | 26 | attr_reader :action_name 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/granite/rspec.rb: -------------------------------------------------------------------------------- 1 | require 'granite/rspec/action_helpers' 2 | require 'granite/rspec/have_projector' 3 | require 'granite/rspec/perform_action' 4 | require 'granite/rspec/projector_helpers' 5 | require 'granite/rspec/raise_validation_error' 6 | require 'granite/rspec/satisfy_preconditions' 7 | -------------------------------------------------------------------------------- /lib/granite/rspec/action_helpers.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | module ActionHelpers # :nodoc: 3 | extend ActiveSupport::Concern 4 | 5 | delegate :perform!, to: :subject 6 | end 7 | end 8 | 9 | RSpec.configuration.define_derived_metadata(file_path: %r{spec/apq/actions/}) do |metadata| 10 | metadata[:type] ||= :granite_action 11 | end 12 | RSpec.configuration.include Granite::ActionHelpers, type: :granite_action 13 | -------------------------------------------------------------------------------- /lib/granite/rspec/have_projector.rb: -------------------------------------------------------------------------------- 1 | # @scope Business Actions 2 | # 3 | # Checks if the business action has the expected projector 4 | # 5 | # Example: 6 | # 7 | # ```ruby 8 | # is_expected.to have_projector(:simple) 9 | # ``` 10 | RSpec::Matchers.define :have_projector do |expected_projector| 11 | match do |action| 12 | @expected_projector = expected_projector 13 | @action_class = action.class 14 | @action_class._projectors.names.include?(expected_projector) 15 | end 16 | 17 | failure_message do 18 | "expected #{@action_class.name} to have a projector named #{@expected_projector}" 19 | end 20 | 21 | failure_message_when_negated do 22 | "expected #{@action_class.name} not to have a projector named #{@expected_projector}" 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/granite/rspec/perform_action.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :perform_action do |klass| # rubocop:disable Metrics/BlockLength 2 | chain :using do |using| 3 | @using = using 4 | end 5 | 6 | chain :as do |performer| 7 | @performer = performer 8 | end 9 | 10 | chain :with do |attributes| 11 | @attributes = attributes 12 | end 13 | 14 | match do |block| 15 | @klass = klass 16 | @using ||= :perform! 17 | 18 | @payloads = [] 19 | subscriber = ActiveSupport::Notifications.subscribe('granite.perform_action') do |_, _, _, _, payload| 20 | @payloads << payload 21 | end 22 | 23 | block.call 24 | 25 | ActiveSupport::Notifications.unsubscribe(subscriber) 26 | 27 | @payloads.detect { |payload| action_matches?(payload[:action]) && payload[:using] == @using } 28 | end 29 | 30 | failure_message do 31 | output = "expected to call #{performed_entity}" 32 | add_performer_message(output, @performer) if defined?(@performer) 33 | add_attributes_message(output, @attributes) if defined?(@attributes) 34 | 35 | similar_payloads = @payloads.select { |payload| class_matches?(payload[:action]) && payload[:using] == @using } 36 | if similar_payloads.present? 37 | output << "\nreceived calls to #{performed_entity}:" 38 | similar_payloads.each { |payload| add_message_from_payload(output, payload) } 39 | end 40 | 41 | output 42 | end 43 | 44 | failure_message_when_negated do 45 | "expected not to call #{performed_entity}" 46 | end 47 | 48 | supports_block_expectations 49 | 50 | private 51 | 52 | def add_message_from_payload(output, payload) 53 | action = payload[:action] 54 | add_performer_message(output, action.performer) if defined?(@performer) 55 | add_attributes_message(output, actual_attributes(action)) if defined?(@attributes) 56 | end 57 | 58 | def add_performer_message(output, performer) 59 | output << "\n AS #{performer.inspect}" 60 | end 61 | 62 | def add_attributes_message(output, attributes) 63 | output << "\n WITH #{attributes.inspect}" 64 | end 65 | 66 | def performed_entity 67 | "#{@klass}##{@using}" 68 | end 69 | 70 | def actual_attributes(action) 71 | @attributes.keys.to_h { |attr| [attr, action.public_send(attr)] } 72 | end 73 | 74 | def action_matches?(action) 75 | class_matches?(action) && performer_matches?(action) && attributes_match?(action) 76 | end 77 | 78 | def class_matches?(action) 79 | action.is_a?(@klass) 80 | end 81 | 82 | def performer_matches?(action) 83 | !defined?(@performer) || action.performer == @performer 84 | end 85 | 86 | def attributes_match?(action) 87 | !defined?(@attributes) || match(@attributes).matches?(actual_attributes(action)) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/granite/rspec/projector_helpers.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | module ProjectorHelpers # :nodoc: 3 | extend ActiveSupport::Concern 4 | include RSpec::Rails::ControllerExampleGroup 5 | 6 | included do 7 | before { Granite::Routing::Declarer.dispatcher.unmemoize_all } 8 | end 9 | 10 | module ClassMethods # :nodoc: 11 | def draw_routes(&block) 12 | before(:all) do 13 | routes = Rails.application.routes 14 | routes.disable_clear_and_finalize = true 15 | routes.draw(&block) 16 | end 17 | 18 | after(:all) do 19 | Rails.application.routes.disable_clear_and_finalize = false 20 | Rails.application.reload_routes! 21 | Rails.application.routes.routes.clear_cache! 22 | end 23 | end 24 | 25 | def projector(&block) 26 | setup_controller 27 | setup_view_context 28 | let(:projector_class, &block) 29 | let(:projector) { controller.projector } 30 | end 31 | 32 | private 33 | 34 | def setup_controller 35 | define_method :setup_controller_request_and_response do 36 | @controller ||= projector_class.controller_class.new 37 | super() 38 | end 39 | end 40 | 41 | def setup_view_context 42 | before { Granite.view_context = controller.view_context } 43 | after { Granite.view_context = nil } 44 | end 45 | end 46 | 47 | # Overrides ActionController::TestCase::Behavior#process to include granite_action and granite_projector 48 | def process(action, **options) 49 | projector_params = { granite_action: projector_class.action_name, 50 | granite_projector: projector_class.projector_name } 51 | super(action, **options, params: projector_params.reverse_merge(options[:params] || {})) 52 | end 53 | end 54 | end 55 | 56 | RSpec.configuration.define_derived_metadata(file_path: %r{spec/apq/projectors/}) do |metadata| 57 | metadata[:type] ||= :granite_projector 58 | end 59 | RSpec.configuration.include Granite::ProjectorHelpers, type: :granite_projector 60 | -------------------------------------------------------------------------------- /lib/granite/rspec/raise_validation_error.rb: -------------------------------------------------------------------------------- 1 | # @scope BusinessActions 2 | # 3 | # Checks if code in block raises `Granite::Action::ValidationError`. 4 | # 5 | # Modifiers: 6 | # * `on_attribute(attribute)` -- error relates to attribute specified; 7 | # * `of_type` -- checks the error has a message with the specified symbol; 8 | # 9 | # Examples: 10 | # 11 | # ```ruby 12 | # expect { code }.to raise_validation_error.of_type(:some_error_key) 13 | # expect { code }.to raise_validation_error.on_attribute(:skill_sets).of_type(:some_other_key) 14 | # ``` 15 | # 16 | RSpec::Matchers.define :raise_validation_error do 17 | chain :on_attribute do |attribute| 18 | @attribute = attribute 19 | end 20 | 21 | chain :of_type do |error_type| 22 | @error_type = error_type 23 | end 24 | 25 | match do |block| 26 | block.call 27 | false 28 | rescue Granite::Action::ValidationError => e 29 | @details = e.errors.details 30 | @details_being_checked = @details[@attribute || :base] 31 | @result = @details_being_checked&.any? { |x| x[:error] == @error_type } 32 | end 33 | 34 | description do 35 | expected = "raise validation error on attribute :#{@attribute || :base}" 36 | expected << " of type #{@error_type.inspect}" if @error_type 37 | expected << ", but raised #{@details.inspect}" unless @result 38 | expected 39 | end 40 | 41 | failure_message { "expected to #{description}" } 42 | 43 | failure_message_when_negated { "expected not to #{description}" } 44 | 45 | supports_block_expectations 46 | end 47 | -------------------------------------------------------------------------------- /lib/granite/rspec/satisfy_preconditions.rb: -------------------------------------------------------------------------------- 1 | # @scope Business Actions 2 | # 3 | # Checks if business action satisfies preconditions in current state. 4 | # 5 | # Modifiers: 6 | # * `with_message(message)` (and `with_messages(list, of, messages)`) -- 7 | # only for negated matchers, checks messages of preconditions not satisfied; 8 | # * `with_message_of_kind(:message_kind)` (and `with_messages_of_kinds(:list, :of, :messages)`) -- 9 | # only for negated matchers, checks messages of preconditions not satisfied; 10 | # * `exactly` (secondary modifier for `with_message`/`with_messages`) -- 11 | # if set, checks if only those messages are set in errors; otherwise 12 | # those messages should present, but others could too. 13 | # 14 | # Examples: 15 | # 16 | # ```ruby 17 | # # assuming subject is business action 18 | # it { is_expected.to satisfy_preconditions } 19 | # it { is_expected.not_to satisfy_preconditions.with_message('Form has not been signed') } 20 | # it { is_expected.not_to satisfy_preconditions.with_messages(/^Form has not been signed by/', 'Signature required') } 21 | # it { is_expected.not_to satisfy_preconditions.with_message_of_kind(:portfolio_needed) } 22 | # it { is_expected.not_to satisfy_preconditions.with_messages_of_kinds(:portfolio_needed, :relevant_education_needed) } 23 | # ``` 24 | # 25 | RSpec::Matchers.define :satisfy_preconditions do # rubocop:disable Metrics/BlockLength 26 | chain(:with_message) do |message| 27 | @expected_messages = [message] 28 | end 29 | 30 | chain(:with_messages) do |*messages| 31 | @expected_messages = messages.flatten 32 | end 33 | 34 | chain(:with_message_of_kind) do |kind| 35 | @expected_kind_of_messages = [kind] 36 | end 37 | 38 | chain(:with_messages_of_kinds) do |*kinds| 39 | @expected_kind_of_messages = kinds.flatten 40 | end 41 | 42 | chain(:exactly) do 43 | @exactly = true 44 | end 45 | 46 | match do |object| 47 | raise '"with_messages" method chain is not supported for positive matcher' if @expected_messages 48 | 49 | object.satisfy_preconditions? 50 | end 51 | 52 | match_when_negated do |object| 53 | result = !object.satisfy_preconditions? 54 | if @expected_messages 55 | errors = object.errors[:base] 56 | 57 | result &&= @expected_messages.all? { |expected| errors.any? { |error| compare(error, expected) } } 58 | 59 | result &&= errors.none? { |error| @expected_messages.none? { |expected| compare(error, expected) } } if @exactly 60 | elsif @expected_kind_of_messages 61 | error_kinds = object.errors.details[:base].map(&:values).flatten 62 | result &&= (@expected_kind_of_messages - error_kinds).empty? 63 | end 64 | 65 | result 66 | end 67 | 68 | failure_message do |object| 69 | "expected #{object} to satisfy preconditions but got following errors:\n #{object.errors[:base].inspect}" 70 | end 71 | 72 | failure_message_when_negated do |object| 73 | message = "expected #{object} not to satisfy preconditions" 74 | message + if @expected_messages 75 | expected_messages_error(object, @exactly, @expected_messages) 76 | elsif @expected_kind_of_messages 77 | expected_kind_of_messages_error(object, @expected_kind_of_messages) 78 | else 79 | ' but preconditions were satisfied' 80 | end.to_s 81 | end 82 | 83 | def expected_messages_error(object, exactly, expected_messages, message = '') 84 | actual_errors = object.errors[:base] 85 | message += ' exactly' if exactly 86 | message += " with error messages #{expected_messages}" 87 | message + " but got following error messages:\n #{actual_errors.inspect}" 88 | end 89 | 90 | def expected_kind_of_messages_error(object, expected_kind_of_messages, message = '') 91 | actual_kind_of_errors = object.errors.details[:base].map(&:keys).flatten 92 | message += " with error messages of kind #{expected_kind_of_messages}" 93 | message + " but got following kind of error messages:\n #{actual_kind_of_errors.inspect}" 94 | end 95 | 96 | def compare(actual, expected) 97 | if RSpec::Matchers.is_a_matcher?(expected) 98 | expected.matches?(actual) 99 | elsif expected.is_a?(String) 100 | actual == expected 101 | else 102 | actual.match?(expected) 103 | end 104 | end 105 | end 106 | 107 | RSpec::Matchers.define_negated_matcher :fail_preconditions, :satisfy_preconditions 108 | -------------------------------------------------------------------------------- /lib/granite/translations.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | class Translations # :nodoc: 3 | class << self 4 | def combine_paths(paths1, paths2) 5 | paths1.flat_map do |path1| 6 | paths2.map { |path2| [*path1, *path2].join('.') } 7 | end 8 | end 9 | 10 | def scope_translation_args(scopes, key, *, **options) 11 | lookups = expand_relative_key(scopes, key) + Array(options[:default]) 12 | 13 | key = lookups.shift 14 | options[:default] = lookups 15 | 16 | [key, options] 17 | end 18 | 19 | private 20 | 21 | def expand_relative_key(scopes, key) 22 | return [key] unless key.is_a?(String) && key.start_with?('.') 23 | 24 | combine_paths(scopes, [key.sub(/^\./, '')]).map(&:to_sym) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/granite/version.rb: -------------------------------------------------------------------------------- 1 | module Granite 2 | VERSION = '0.17.5'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /lib/rubocop-granite.rb: -------------------------------------------------------------------------------- 1 | require 'rubocop' 2 | 3 | require_relative 'rubocop/granite' 4 | require_relative 'rubocop/granite/inject' 5 | 6 | RuboCop::Granite::Inject.defaults! 7 | -------------------------------------------------------------------------------- /lib/rubocop/granite.rb: -------------------------------------------------------------------------------- 1 | module RuboCop 2 | module Granite # :nodoc: 3 | PROJECT_ROOT = Pathname.new(__dir__).parent.parent.expand_path.freeze 4 | CONFIG_DEFAULT = PROJECT_ROOT.join('config', 'rubocop-default.yml').freeze 5 | CONFIG = YAML.safe_load(CONFIG_DEFAULT.read).freeze 6 | 7 | private_constant(:CONFIG_DEFAULT, :PROJECT_ROOT) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/rubocop/granite/inject.rb: -------------------------------------------------------------------------------- 1 | # The original code is from https://github.com/rubocop-hq/rubocop-rspec/blob/master/lib/rubocop/rspec/inject.rb 2 | # See https://github.com/rubocop-hq/rubocop-rspec/blob/master/MIT-LICENSE.md 3 | module RuboCop 4 | module Granite 5 | # Because RuboCop doesn't yet support plugins, we have to monkey patch in a 6 | # bit of our configuration. 7 | module Inject 8 | def self.defaults! 9 | path = CONFIG_DEFAULT.to_s 10 | hash = ConfigLoader.load_file(path) 11 | config = Config.new(hash, path).tap(&:make_excludes_absolute) 12 | Rails.logger.debug { "configuration from #{path}" } if ConfigLoader.debug? 13 | config = ConfigLoader.merge_with_default(config, path) 14 | ConfigLoader.instance_variable_set(:@default_configuration, config) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Granite Manual 2 | repo_url: https://github.com/toptal/granite 3 | edit_uri: edit/master/docs/ 4 | google_analytics: ['UA-115649289-1', 'auto'] 5 | theme: 6 | name: material 7 | logo: img/logo.png 8 | favicon: img/favicon.ico 9 | palette: 10 | primary: indigo 11 | accent: pink 12 | markdown_extensions: 13 | - admonition 14 | - pymdownx.highlight: 15 | use_pygments: true 16 | - pymdownx.superfences 17 | - toc: 18 | permalink: true 19 | copyright: 'Copyright © 2013 - 2018 Toptal' 20 | nav: 21 | - Home: index.md 22 | - Projectors: projectors.md 23 | - Testing: testing.md 24 | - Tutorial: tutorial.md 25 | -------------------------------------------------------------------------------- /spec/app/controllers/granite/controller/translations_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Controller::Translations, type: :granite_projector do 2 | prepend_before do 3 | stub_class(:dummy_projector, Granite::Projector) 4 | stub_class(:dummy_action, Granite::Action) do 5 | projector :dummy 6 | end 7 | end 8 | 9 | projector { DummyAction.dummy } 10 | 11 | describe '#i18n_scopes' do 12 | it do 13 | expect(controller.i18n_scopes).to eq %w[granite_action.dummy_action.dummy granite_action.granite/action.dummy 14 | dummy] 15 | end 16 | 17 | context 'when action name is :result' do 18 | before { controller.action_name = :result } 19 | 20 | it do 21 | expect(controller.i18n_scopes).to eq %w[granite_action.dummy_action.dummy.result 22 | granite_action.dummy_action.dummy granite_action.granite/action.dummy.result granite_action.granite/action.dummy dummy.result dummy] # rubocop:disable Layout/LineLength 23 | end 24 | end 25 | end 26 | 27 | describe '#translate' do 28 | it do 29 | expect(controller.translate('.key')).to eq 'dummy action dummy projector key' 30 | expect(controller.translate('.other_key')).to eq 'dummy projector other key' 31 | expect(controller.view_context.translate('.key')).to eq 'dummy action dummy projector key' 32 | 33 | expect(controller.view_context.translate(:no_such_key)).to eq 'No Such Key' # rubocop:disable Layout/LineLength 34 | end 35 | 36 | context 'when action name is :result' do 37 | before { controller.action_name = :result } 38 | 39 | it do 40 | expect(controller.translate('.key')).to eq 'dummy action dummy projector result key' 41 | expect(controller.view_context.translate('.key')).to eq 'dummy action dummy projector result key' 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/fixtures/action_example.rb: -------------------------------------------------------------------------------- 1 | class User::Create < User::BusinessAction 2 | allow_if { false } 3 | 4 | precondition do 5 | end 6 | 7 | private 8 | 9 | def execute_perform!(*) 10 | subject.save! 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/fixtures/action_spec_example.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe User::Create do 4 | subject(:action) { described_class.as(performer).new(user, attributes) } 5 | 6 | let(:user) { User.new } 7 | let(:performer) { double } 8 | let(:attributes) { {} } 9 | 10 | describe 'policies' do 11 | it { is_expected.to be_allowed } 12 | 13 | context 'when user is not authorized' do 14 | it { is_expected.not_to be_allowed } 15 | end 16 | end 17 | 18 | describe 'preconditions' do 19 | it { is_expected.to satisfy_preconditions } 20 | 21 | context 'when preconditions fail' do 22 | it { is_expected.not_to satisfy_preconditions } 23 | end 24 | end 25 | 26 | describe 'validations' do 27 | end 28 | 29 | describe '#perform!' do 30 | specify do 31 | expect { perform!(user) }.to change { user.reload.attributes }.to(attributes) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/fixtures/base_action_example.rb: -------------------------------------------------------------------------------- 1 | class User::BusinessAction < BaseAction 2 | subject :user 3 | end 4 | -------------------------------------------------------------------------------- /spec/fixtures/collection_action_example.rb: -------------------------------------------------------------------------------- 1 | class User::Create < BaseAction 2 | allow_if { false } 3 | 4 | precondition do 5 | end 6 | 7 | def subject 8 | @subject ||= User.new 9 | end 10 | 11 | private 12 | 13 | def execute_perform!(*) 14 | subject.save! 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/fixtures/collection_action_spec_example.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe User::Create do 4 | subject(:action) { described_class.as(performer).new(attributes) } 5 | 6 | let(:performer) { double } 7 | let(:attributes) { {} } 8 | 9 | describe 'policies' do 10 | it { is_expected.to be_allowed } 11 | 12 | context 'when user is not authorized' do 13 | it { is_expected.not_to be_allowed } 14 | end 15 | end 16 | 17 | describe 'preconditions' do 18 | it { is_expected.to satisfy_preconditions } 19 | 20 | context 'when preconditions fail' do 21 | it { is_expected.not_to satisfy_preconditions } 22 | end 23 | end 24 | 25 | describe 'validations' do 26 | end 27 | 28 | describe '#perform!' do 29 | specify do 30 | expect { perform! }.to change { User.count }.by(1) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/fixtures/simple_action_example.rb: -------------------------------------------------------------------------------- 1 | class User::Create < User::BusinessAction 2 | projector :simple 3 | 4 | allow_if { false } 5 | 6 | precondition do 7 | end 8 | 9 | private 10 | 11 | def execute_perform!(*) 12 | subject.save! 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/lib/generators/granite_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | if Rails.version > '7.1' 3 | require 'rails/generators/testing/behavior' 4 | else 5 | require 'rails/generators/testing/behaviour' 6 | end 7 | require_relative '../../../lib/generators/granite_generator' 8 | 9 | RSpec.describe GraniteGenerator do 10 | include RSpec::Rails::RailsExampleGroup 11 | if Rails.version > '7.1' 12 | include Rails::Generators::Testing::Behavior 13 | else 14 | include Rails::Generators::Testing::Behaviour 15 | end 16 | include FileUtils 17 | 18 | tests described_class 19 | destination File.join(Dir.tmpdir, 'granite') 20 | 21 | before { prepare_destination } 22 | 23 | def destination_path(generated_name) 24 | Pathname(destination_root).join(generated_name) 25 | end 26 | 27 | def expect_same_content(generated_name, example_name) 28 | example_path = Pathname("../../../fixtures/#{example_name}_example.rb").expand_path(__FILE__) 29 | generated_path = destination_path(generated_name) 30 | 31 | expect(generated_path).to be_file 32 | expect(generated_path.read).to eq(example_path.read) 33 | end 34 | 35 | def entries 36 | destination_path('apq/actions/user/').entries.map(&:to_s) - %w[. ..] 37 | end 38 | 39 | specify do 40 | run_generator %w[user/create] 41 | expect(entries).to match_array(%w[create.rb business_action.rb]) 42 | expect_same_content('apq/actions/user/business_action.rb', 'base_action') 43 | expect_same_content('apq/actions/user/create.rb', 'action') 44 | expect_same_content('spec/apq/actions/user/create_spec.rb', 'action_spec') 45 | end 46 | 47 | specify do 48 | run_generator %w[user/create -C] 49 | expect(entries).to match_array(%w[create.rb]) 50 | expect_same_content('apq/actions/user/create.rb', 'collection_action') 51 | expect_same_content('spec/apq/actions/user/create_spec.rb', 'collection_action_spec') 52 | end 53 | 54 | specify do 55 | run_generator %w[user/create simple] 56 | expect(entries).to match_array(%w[create create.rb business_action.rb]) 57 | expect(destination_path('apq/actions/user/create/simple/')).to be_directory 58 | expect_same_content('apq/actions/user/create.rb', 'simple_action') 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/lib/granite/action/instrumentation_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::Instrumentation do 2 | def collect_payloads 3 | [].tap do |payloads| 4 | subscriber = ActiveSupport::Notifications.subscribe('granite.perform_action') do |_, _, _, _, payload| 5 | payloads << payload 6 | end 7 | 8 | yield 9 | 10 | ActiveSupport::Notifications.unsubscribe(subscriber) 11 | end 12 | end 13 | 14 | subject(:action) { DummyAction.new } 15 | 16 | before do 17 | stub_class(:DummyAction, Granite::Action) do 18 | allow_if { true } 19 | 20 | def execute_perform!(*); end 21 | end 22 | end 23 | 24 | it { expect(collect_payloads { action.perform! }).to eq([{ action: action, using: :perform! }]) } 25 | it { expect(collect_payloads { action.perform }).to eq([{ action: action, using: :perform }]) } 26 | it { expect(collect_payloads { action.try_perform! }).to eq([{ action: action, using: :try_perform! }]) } 27 | end 28 | -------------------------------------------------------------------------------- /spec/lib/granite/action/performer_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::Performer do 2 | let(:first_performer) { instance_double(User, id: 5) } 3 | let(:second_performer) { instance_double(User, id: 10) } 4 | 5 | before do 6 | stub_class(:projector, Granite::Projector) 7 | stub_class(:action, Granite::Action) do 8 | projector :projector 9 | 10 | def self.batch(count) 11 | Array.new(count).map { new } 12 | end 13 | end 14 | end 15 | 16 | describe '#ctx' do 17 | specify { expect(Action.new.ctx).to be_nil } 18 | specify { expect(Action.with(performer: :value).new.ctx).to have_attributes(performer: :value) } 19 | specify { expect(Action.as(first_performer).new.ctx).to have_attributes(performer: first_performer) } 20 | 21 | specify 'proxy works for deeper initialization' do 22 | expect(Action.with(performer: :value).batch(2).map(&:ctx)) 23 | .to contain_exactly(have_attributes(performer: :value), have_attributes(performer: :value)) 24 | end 25 | end 26 | 27 | describe '#performer' do 28 | specify { expect(Action.new.performer).to be_nil } 29 | specify { expect(Action.as(first_performer).new.performer).to eq(first_performer) } 30 | 31 | specify 'proxy works for deeper initialization' do 32 | expect(Action.as(first_performer).batch(2).map(&:performer)).to eq([first_performer, first_performer]) 33 | end 34 | end 35 | 36 | describe '#performer_id' do 37 | specify { expect(Action.new.performer_id).to be_nil } 38 | specify { expect(Action.as(first_performer).new.performer_id).to eq(first_performer.id) } 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/lib/granite/action/policies/always_allow_strategy_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::Policies::AlwaysAllowStrategy do 2 | describe '.allowed?' do 3 | subject { described_class.allowed?(action) } 4 | 5 | let(:action) { instance_double(Granite::Action) } 6 | 7 | it { is_expected.to be(true) } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/lib/granite/action/policies/any_strategy_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::Policies::AnyStrategy do 2 | describe '.allowed?' do 3 | subject { described_class.allowed?(action) } 4 | 5 | let(:action) { instance_double(Granite::Action, _policies: policies, performer: nil) } 6 | let(:policies) { [] } 7 | 8 | context 'when action has no policies defined' do 9 | it { is_expected.to be(false) } 10 | end 11 | 12 | context 'when action has at least one "true" policy' do 13 | let(:policies) { [proc { false }, proc { true }, proc { false }] } 14 | 15 | it { is_expected.to be(true) } 16 | end 17 | 18 | context 'when action has all policies evaled to true' do 19 | let(:policies) { [proc { true }, proc { true }] } 20 | 21 | it { is_expected.to be(true) } 22 | end 23 | 24 | context 'when action has all policies evaled to false' do 25 | let(:policies) { [proc { false }, proc { false }] } 26 | 27 | it { is_expected.to be(false) } 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/lib/granite/action/policies/required_performer_strategy_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::Policies::RequiredPerformerStrategy do 2 | describe '.allowed?' do 3 | subject { described_class.allowed?(action) } 4 | 5 | let(:action) { instance_double(Granite::Action, _policies: [proc { true }], performer: performer) } 6 | let(:performer) { nil } 7 | 8 | context 'when performer is present' do 9 | let(:performer) { 'performer' } 10 | 11 | it { is_expected.to be(true) } 12 | end 13 | 14 | context 'when performer is not persent' do 15 | it { is_expected.to be(false) } 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/lib/granite/action/policies_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::Policies do 2 | before do 3 | stub_class(:action, Granite::Action) do 4 | allow_if { performer.is_a?(Student) } 5 | 6 | private 7 | 8 | def execute_perform!(*); end 9 | end 10 | end 11 | 12 | describe '#perform' do 13 | specify { expect(Action.as(Student.new).new.perform).to be(true) } 14 | 15 | specify do 16 | expect { Action.as(Teacher.new).new.perform }.to raise_error( 17 | Granite::Action::NotAllowedError, 18 | 'Action action is not allowed for Teacher' 19 | ) 20 | end 21 | 22 | context 'with performer.id' do 23 | let(:teacher) do 24 | Teacher.new.tap do |teacher| 25 | teacher.define_singleton_method(:id) { 24 } 26 | end 27 | end 28 | 29 | specify do 30 | expect { Action.as(teacher).new.perform }.to raise_error( 31 | Granite::Action::NotAllowedError, 32 | 'Action action is not allowed for Teacher#24' 33 | ) 34 | end 35 | end 36 | 37 | context 'with allow_self' do 38 | before do 39 | stub_class(:action, Granite::Action) do 40 | allow_self 41 | 42 | subject :student 43 | 44 | private 45 | 46 | def execute_perform!(*); end 47 | end 48 | end 49 | 50 | let(:student) { Student.new } 51 | 52 | specify { expect(Action.as(student).new(student).perform).to be(true) } 53 | specify { expect { Action.as(Student.new).new(student).perform }.to raise_error Granite::Action::NotAllowedError } 54 | specify { expect { Action.as(Teacher.new).new(student).perform }.to raise_error Granite::Action::NotAllowedError } 55 | end 56 | end 57 | 58 | describe '#perform!' do 59 | specify { expect { Action.as(Student.new).new.perform! }.not_to raise_error } 60 | specify { expect { Action.as(Teacher.new).new.perform! }.to raise_error Granite::Action::NotAllowedError } 61 | end 62 | 63 | describe '#try_perform!' do 64 | before do 65 | stub_class(:action, Granite::Action) do 66 | allow_if { performer.is_a?(Student) } 67 | 68 | precondition do 69 | decline_with('error') 70 | end 71 | 72 | private 73 | 74 | def execute_perform!(*); end 75 | end 76 | end 77 | 78 | specify { expect { Action.as(Student.new).new.try_perform! }.not_to raise_error } 79 | specify { expect { Action.as(Teacher.new).new.try_perform! }.to raise_error Granite::Action::NotAllowedError } 80 | end 81 | 82 | describe 'default policies strategy' do 83 | it 'is AnyStrategy' do 84 | expect(Granite::Action._policies_strategy).to eq Granite::Action::Policies::AnyStrategy 85 | end 86 | end 87 | 88 | describe '#allowed?' do 89 | before do 90 | stub_class(:custom_strategy) 91 | stub_class(:action, Granite::Action) do 92 | self._policies_strategy = CustomStrategy 93 | end 94 | end 95 | 96 | let(:action) { Action.new } 97 | 98 | it 'delegates to policies_strategy' do 99 | allow(CustomStrategy).to receive(:allowed?).and_return('strategy_result') 100 | expect(action.allowed?).to eq 'strategy_result' 101 | end 102 | 103 | it 'memoizes result' do 104 | expect(CustomStrategy).to receive(:allowed?).once 105 | action.allowed? 106 | action.allowed? 107 | end 108 | end 109 | 110 | describe '#authorize!' do 111 | specify { expect { Action.as(Teacher.new).new.authorize! }.to raise_error Granite::Action::NotAllowedError } 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /spec/lib/granite/action/precondition_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::Precondition do 2 | before do 3 | stub_class(:test_precondition, described_class) do 4 | description 'Test description' 5 | 6 | def call(expected_title:, **) 7 | respond_to_missing?(:title) && expected_title == title 8 | end 9 | end 10 | 11 | stub_class(:action, Granite::Action) do 12 | attribute :title, String 13 | end 14 | end 15 | 16 | describe '.description' do 17 | specify { expect(TestPrecondition.description).to eq('Test description') } 18 | end 19 | 20 | describe '#call' do 21 | let(:action) { Action.new(title: 'Ruby') } 22 | let(:precondition) { TestPrecondition.new(action) } 23 | 24 | specify do 25 | expect(action).to receive(:title).and_call_original 26 | 27 | expect(precondition.call(expected_title: 'Ruby')).to be(true) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/lib/granite/action/preconditions/base_precondition_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::Preconditions::BasePrecondition do 2 | include_context 'with student data' 3 | 4 | let(:passed_student_action) { Action.new(subject: passed_student) } 5 | let(:failed_student_action) { Action.new(subject: failed_student) } 6 | 7 | context 'with simple declaration' do 8 | before do 9 | stub_class(:action, Granite::Action) do 10 | subject :student 11 | 12 | precondition do 13 | decline_with 'DECLINED' unless student.status == 'passed' 14 | end 15 | end 16 | end 17 | 18 | describe '#satisfy_preconditions?' do 19 | specify { expect(passed_student_action).to satisfy_preconditions } 20 | specify { expect(failed_student_action).not_to satisfy_preconditions } 21 | end 22 | end 23 | 24 | context 'with :if option' do 25 | before do 26 | stub_class(:action, Granite::Action) do 27 | subject :student 28 | 29 | precondition if: -> { student.status == 'passed' } do 30 | decline_with 'DECLINED!' 31 | end 32 | end 33 | end 34 | 35 | describe '#satisfy_preconditions?' do 36 | specify { expect(passed_student_action).not_to satisfy_preconditions } 37 | specify { expect(failed_student_action).to satisfy_preconditions } 38 | end 39 | end 40 | 41 | context 'with :unless option' do 42 | before do 43 | stub_class(:action, Granite::Action) do 44 | subject :student 45 | 46 | precondition unless: -> { student.status == 'passed' } do 47 | decline_with 'DECLINED!' 48 | end 49 | end 50 | end 51 | 52 | describe '#satisfy_preconditions?' do 53 | specify { expect(passed_student_action).to satisfy_preconditions } 54 | specify { expect(failed_student_action).not_to satisfy_preconditions } 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/lib/granite/action/preconditions/embedded_precondition_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::Preconditions::EmbeddedPrecondition do 2 | include_context 'with student data' 3 | 4 | before do 5 | stub_class(:embedded_action, Granite::Action) do 6 | subject :student 7 | precondition { decline_with('embedded_not_passed') unless student.status == 'passed' } 8 | end 9 | 10 | stub_class(:action, Granite::Action) do 11 | subject :student 12 | precondition embedded: :embedded_action 13 | 14 | def embedded_action 15 | EmbeddedAction.new(student) 16 | end 17 | end 18 | end 19 | 20 | describe '#satisfy_preconditions?' do 21 | specify { expect(Action.new(subject: passed_student)).to satisfy_preconditions } 22 | 23 | specify do 24 | expect(Action.new(subject: failed_student)) 25 | .not_to satisfy_preconditions.with_message('embedded_not_passed') 26 | end 27 | 28 | context 'with :if' do 29 | before do 30 | stub_class(:action, Granite::Action) do 31 | subject :student 32 | precondition embedded: :embedded_action, if: -> { false } 33 | end 34 | end 35 | 36 | specify { expect(Action.new(subject: failed_student)).to satisfy_preconditions } 37 | end 38 | 39 | context 'with :unless' do 40 | before do 41 | stub_class(:action, Granite::Action) do 42 | subject :student 43 | precondition embedded: :embedded_action, unless: -> { true } 44 | end 45 | end 46 | 47 | specify { expect(Action.new(subject: failed_student)).to satisfy_preconditions } 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/lib/granite/action/preconditions/object_precondition_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::Preconditions::ObjectPrecondition do 2 | before do 3 | stub_class(:title_precondition, Granite::Action::Precondition) do 4 | def call(expected_title:, **) 5 | decline_with(:wrong_title) if title != expected_title 6 | end 7 | end 8 | end 9 | 10 | context 'with simple declaration' do 11 | before do 12 | stub_class(:action, Granite::Action) do 13 | attribute :title, String 14 | 15 | precondition TitlePrecondition, expected_title: 'Ruby' 16 | end 17 | end 18 | 19 | describe '#satisfy_preconditions?' do 20 | specify { expect(Action.new(title: 'Delphi')).not_to satisfy_preconditions } 21 | specify { expect(Action.new(title: 'Ruby')).to satisfy_preconditions } 22 | end 23 | end 24 | 25 | context 'with :if option' do 26 | before do 27 | stub_class(:action, Granite::Action) do 28 | attribute :title, String 29 | 30 | precondition TitlePrecondition, expected_title: 'Ruby', if: -> { title.length > 3 } 31 | end 32 | end 33 | 34 | describe '#satisfy_preconditions?' do 35 | specify { expect(Action.new(title: 'Ada')).to satisfy_preconditions } 36 | specify { expect(Action.new(title: 'Delphi')).not_to satisfy_preconditions } 37 | specify { expect(Action.new(title: 'Ruby')).to satisfy_preconditions } 38 | end 39 | end 40 | 41 | context 'with :unless option' do 42 | before do 43 | stub_class(:action, Granite::Action) do 44 | attribute :title, String 45 | 46 | precondition TitlePrecondition, expected_title: 'Ruby', unless: -> { title.length > 4 } 47 | end 48 | end 49 | 50 | describe '#satisfy_preconditions?' do 51 | specify { expect(Action.new(title: 'Ada')).not_to satisfy_preconditions } 52 | specify { expect(Action.new(title: 'Delphi')).to satisfy_preconditions } 53 | specify { expect(Action.new(title: 'Ruby')).to satisfy_preconditions } 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/lib/granite/action/preconditions_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::Preconditions do 2 | context 'with block' do 3 | before do 4 | stub_class(:action, Granite::Action) do 5 | attribute :title, String 6 | attribute :author, String 7 | 8 | precondition do 9 | decline_with(:wrong_title) if title !~ /Ruby/ 10 | decline_with(:wrong_author, author_name: author) if author && author != 'George Orwell' 11 | end 12 | # Just to check that validations are not run if preconditions are not satisfied 13 | validates :title, inclusion: { in: ['Ruby'] } 14 | end 15 | end 16 | 17 | describe '#satisfy_preconditions?' do 18 | specify { expect(Action.new(title: 'Delphi')).not_to satisfy_preconditions } 19 | specify { expect(Action.new(title: 'Ruby')).to satisfy_preconditions } 20 | end 21 | 22 | describe '#failed_preconditions' do 23 | subject(:failed_preconditions) { action.failed_preconditions } 24 | 25 | let(:action) { Action.new(title: 'Delphi') } 26 | 27 | it { is_expected.to eq [] } 28 | 29 | specify do 30 | action.satisfy_preconditions? 31 | expect(failed_preconditions).to eq [:wrong_title] 32 | end 33 | end 34 | 35 | describe '#valid?' do 36 | specify { expect(Action.new(title: 'Delphi')).not_to be_valid } 37 | specify { expect(Action.new(title: 'Ruby')).to be_valid } 38 | end 39 | 40 | describe '#errors' do 41 | context 'with Delphi' do 42 | let(:action) { Action.new(title: 'Delphi') } 43 | 44 | specify do 45 | expect { action.valid? }.to change { action.errors.messages } 46 | .to(base: ['Wrong title']) 47 | end 48 | end 49 | 50 | context 'with Rubyist' do 51 | let(:action) { Action.new(title: 'Rubyist') } 52 | 53 | specify do 54 | expect { action.valid? }.to change { action.errors.messages } 55 | .to(title: ['is not included in the list']) 56 | end 57 | end 58 | 59 | context 'with Ruby' do 60 | let(:action) { Action.new(title: 'Ruby') } 61 | 62 | specify do 63 | expect { action.valid? }.not_to(change { action.errors.messages }) 64 | end 65 | end 66 | 67 | context 'with wrong Author' do 68 | let(:action) { Action.new(title: 'Ruby', author: 'Vladimir Sorokin') } 69 | 70 | specify do 71 | expect { action.valid? }.to change { action.errors.messages } 72 | .to(base: ['George Orwell is the only acceptable author, Vladimir Sorokin is not']) 73 | end 74 | end 75 | end 76 | end 77 | 78 | context 'with options' do 79 | before do 80 | stub_class(:action_title, Granite::Action) do 81 | attribute :title, String 82 | 83 | precondition if: -> { title.length > 3 } do 84 | decline_with(:wrong_title) if title !~ /Ruby/ 85 | end 86 | end 87 | 88 | stub_class(:action, Granite::Action) do 89 | attribute :title, String 90 | 91 | precondition embedded: :action_title 92 | 93 | def action_title 94 | ActionTitle.new(title: title) if title 95 | end 96 | end 97 | end 98 | 99 | describe '#satisfy_preconditions?' do 100 | specify { expect(Action.new(title: nil)).to satisfy_preconditions } 101 | specify { expect(Action.new(title: 'Ada')).to satisfy_preconditions } 102 | specify { expect(Action.new(title: 'Delphi')).not_to satisfy_preconditions } 103 | specify { expect(Action.new(title: 'Ruby')).to satisfy_preconditions } 104 | end 105 | end 106 | 107 | context 'with args defaulting to empty list' do 108 | before do 109 | stub_class(:action, Granite::Action) do 110 | attribute :title, String 111 | 112 | precondition :embedded 113 | end 114 | end 115 | 116 | describe '#satisfy_preconditions?' do 117 | specify { expect(Action.new(title: nil)).to satisfy_preconditions } 118 | end 119 | end 120 | 121 | context 'with object' do 122 | before do 123 | stub_class(:test_precondition, Granite::Action::Precondition) do 124 | def call(*) 125 | decline_with(:wrong_title) if title != 'Ruby' 126 | end 127 | end 128 | 129 | stub_class(:action, Granite::Action) do 130 | attribute :title, String 131 | 132 | precondition TestPrecondition 133 | end 134 | end 135 | 136 | describe '#satisfy_preconditions?' do 137 | specify { expect(Action.new(title: 'Delphi')).not_to satisfy_preconditions } 138 | specify { expect(Action.new(title: 'Ruby')).to satisfy_preconditions } 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /spec/lib/granite/action/projectors_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::Projectors do 2 | before do 3 | stub_class(:first_projector, Granite::Projector) 4 | stub_class(:second_projector, Granite::Projector) 5 | stub_class(:third_projector, Granite::Projector) 6 | 7 | stub_class(:first_action, Granite::Action) do 8 | const_set('ZeroProjector', Class.new(Granite::Projector) do 9 | def value 10 | # :nocov: - as the class is overwritten with `projector :zero` 11 | 13 12 | # :nocov: 13 | end 14 | end) 15 | projector :zero do 16 | def value 17 | 31 18 | end 19 | end 20 | projector :first do 21 | def value 22 | 42 23 | end 24 | end 25 | projector :other, class_name: 'SecondProjector' 26 | projector :overwritten_other, class_name: 'SecondProjector' 27 | end 28 | 29 | stub_class(:second_action, FirstAction) do 30 | projector :first do 31 | def value 32 | 43 33 | end 34 | 35 | def will_be_overwritten 36 | # :nocov: - as the method is overwritten in second definition of `projector :first` 37 | 13 38 | # :nocov: 39 | end 40 | end 41 | projector :third 42 | projector :first do 43 | def another_value 44 | 666 45 | end 46 | 47 | def will_be_overwritten 48 | 31 49 | end 50 | end 51 | projector :overwritten_other, class_name: 'ThirdProjector' 52 | end 53 | end 54 | 55 | describe '.projector_names' do 56 | specify { expect(FirstAction.projector_names).to match_array(%i[zero first other overwritten_other]) } 57 | specify { expect(SecondAction.projector_names).to match_array(%i[first third zero other overwritten_other]) } 58 | end 59 | 60 | describe '#projector_name' do 61 | specify { expect(FirstAction.zero).to equal(FirstAction.zero) } 62 | specify { expect(FirstAction.new.zero.value).to eq(31) } 63 | 64 | specify { expect(FirstAction.zero).to be < Granite::Projector } 65 | specify { expect(FirstAction.zero).to eq(FirstAction::ZeroProjector) } 66 | 67 | specify { expect(FirstAction.first).to be < FirstProjector } 68 | specify { expect(FirstAction.first).to eq(FirstAction::FirstProjector) } 69 | specify { expect(FirstAction.new.first.value).to eq(42) } 70 | 71 | specify { expect(FirstAction.other).to be < SecondProjector } 72 | specify { expect(FirstAction.other).to eq(FirstAction::OtherProjector) } 73 | 74 | specify { expect(FirstAction.overwritten_other).to be < SecondProjector } 75 | specify { expect(FirstAction.overwritten_other).to eq(FirstAction::OverwrittenOtherProjector) } 76 | 77 | specify { expect(SecondAction.first).to be < FirstAction::FirstProjector } 78 | specify { expect(SecondAction.first).to eq(SecondAction::FirstProjector) } 79 | specify { expect(SecondAction.new.first.value).to eq(43) } 80 | specify { expect(SecondAction.new.first.another_value).to eq(666) } 81 | specify { expect(SecondAction.new.first.will_be_overwritten).to eq(31) } 82 | 83 | specify { expect(SecondAction.other).to be < FirstAction::OtherProjector } 84 | specify { expect(SecondAction.other).to eq(SecondAction::OtherProjector) } 85 | 86 | specify { expect(SecondAction.overwritten_other).to be < ThirdProjector } 87 | specify { expect(SecondAction.overwritten_other).to eq(SecondAction::OverwrittenOtherProjector) } 88 | 89 | specify { expect(SecondAction.third).to be < ThirdProjector } 90 | specify { expect(SecondAction.third).to eq(SecondAction::ThirdProjector) } 91 | 92 | specify { expect(FirstAction.first.action_class).to eq(FirstAction) } 93 | specify { expect(FirstAction.other.action_class).to eq(FirstAction) } 94 | specify { expect(SecondAction.first.action_class).to eq(SecondAction) } 95 | specify { expect(SecondAction.other.action_class).to eq(SecondAction) } 96 | specify { expect(SecondAction.third.action_class).to eq(SecondAction) } 97 | end 98 | 99 | describe '##projector_name' do 100 | specify { expect(FirstAction.new.first).to be_a FirstAction::FirstProjector } 101 | specify { expect(FirstAction.new.other).to be_a FirstAction::OtherProjector } 102 | specify { expect(FirstAction.new.overwritten_other).to be_a FirstAction::OverwrittenOtherProjector } 103 | 104 | specify { expect(SecondAction.new.first).to be_a SecondAction::FirstProjector } 105 | specify { expect(SecondAction.new.other).to be_a SecondAction::OtherProjector } 106 | specify { expect(SecondAction.new.third).to be_a SecondAction::ThirdProjector } 107 | specify { expect(SecondAction.new.overwritten_other).to be_a SecondAction::OverwrittenOtherProjector } 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /spec/lib/granite/action/subject_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::Subject do 2 | let(:student) { Student.create! } 3 | let(:teacher) { Teacher.create! } 4 | 5 | describe '#initialize' do 6 | before do 7 | stub_class(:action, Granite::Action) do 8 | subject :student 9 | attribute :comment, String 10 | end 11 | end 12 | 13 | specify { expect { Action.new(comment: 'Comment') }.to raise_error Granite::Action::SubjectNotFoundError } 14 | specify { expect(Action.new(comment: 'Comment', subject: student).student).to eq(student) } 15 | 16 | specify { expect { Action.new(nil, comment: 'Comment') }.to raise_error Granite::Action::SubjectNotFoundError } 17 | 18 | specify do 19 | expect do 20 | Action.new(nil, comment: 'Comment', subject: student) 21 | end.to raise_error Granite::Action::SubjectNotFoundError 22 | end 23 | 24 | specify { expect(Action.new(comment: 'Comment', id: student.id).student).to eq(student) } 25 | 26 | specify do 27 | expect do 28 | Action.new(comment: 'Comment', id: student.id.next) 29 | end.to raise_error Granite::Action::SubjectNotFoundError 30 | end 31 | 32 | specify { expect(Action.new(student, comment: 'Comment').student).to eq(student) } 33 | specify { expect(Action.new(student, comment: 'Comment', subject: nil).student).to eq(student) } 34 | 35 | specify do 36 | expect do 37 | Action.new(student.id, comment: 'Comment') 38 | end.to raise_error Granite::Action::SubjectTypeMismatchError 39 | end 40 | 41 | specify do 42 | expect do 43 | Action.new(student.id, comment: 'Comment', subject: nil) 44 | end.to raise_error Granite::Action::SubjectTypeMismatchError 45 | end 46 | 47 | specify do 48 | expect do 49 | Action.new(teacher, comment: 'Comment') 50 | end.to raise_error Granite::Action::SubjectTypeMismatchError 51 | end 52 | 53 | specify do 54 | expect do 55 | Action.new(teacher.id, comment: 'Comment') 56 | end.to raise_error Granite::Action::SubjectTypeMismatchError 57 | end 58 | 59 | specify do 60 | expect do 61 | Action.new(subject: teacher, comment: 'Comment') 62 | end.to raise_error Granite::Action::SubjectTypeMismatchError 63 | end 64 | 65 | specify { expect(Action.new(comment: 'Comment', subject: student).comment).to eq('Comment') } 66 | specify { expect(Action.new(student, comment: 'Comment').comment).to eq('Comment') } 67 | end 68 | 69 | describe '.subject?' do 70 | before { stub_class(:action, Granite::Action) } 71 | 72 | specify { expect(Action).not_to be_subject } 73 | 74 | context 'when action defines subject' do 75 | before { Action.subject(:student) } 76 | 77 | specify { expect(Action).to be_subject } 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/lib/granite/action/transaction_manager/transactions_stack_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::TransactionManager::TransactionsStack do 2 | subject(:transactions_stack) { described_class.new } 3 | 4 | let(:callback1) { double } 5 | 6 | it { expect(transactions_stack.depth).to eq(0) } 7 | 8 | it 'fails to add callback' do 9 | expect do 10 | transactions_stack.add_callback(callback1) 11 | end.to raise_error(RuntimeError, 'Start a transaction before you add callbacks on it') 12 | end 13 | 14 | context 'when in transaction' do 15 | let(:run_transaction) do 16 | transactions_stack.transaction do 17 | transactions_stack.add_callback(callback1) 18 | block1 19 | end 20 | end 21 | 22 | let(:block1) {} # rubocop:disable Lint/EmptyBlock 23 | 24 | it 'adds callbacks' do 25 | run_transaction 26 | expect(transactions_stack.callbacks).to eq([callback1]) 27 | end 28 | 29 | context 'when failed with error' do 30 | let(:block1) { raise 'I failed' } 31 | 32 | it 're-raise error and doesnt store callbacks' do 33 | expect { run_transaction }.to raise_error(RuntimeError, 'I failed').and(not_change do 34 | transactions_stack.callbacks 35 | end) 36 | end 37 | end 38 | 39 | context 'with a nested transaction inside' do 40 | let(:run_transaction) do 41 | transactions_stack.transaction do 42 | transactions_stack.add_callback(callback1) 43 | transactions_stack.transaction do 44 | transactions_stack.add_callback(callback2) 45 | block2 46 | end 47 | end 48 | end 49 | 50 | let(:block2) {} # rubocop:disable Lint/EmptyBlock 51 | let(:callback2) { double } 52 | 53 | it 'adds callbacks' do 54 | run_transaction 55 | expect(transactions_stack.callbacks).to eq([callback1, callback2]) 56 | end 57 | 58 | context 'when failed' do 59 | let(:block2) { raise 'I failed' } 60 | 61 | it 're-raise error and doesnt store callbacks' do 62 | expect { run_transaction }.to raise_error(RuntimeError, 'I failed').and(not_change do 63 | transactions_stack.callbacks 64 | end) 65 | end 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/lib/granite/action/transaction_manager_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::TransactionManager do 2 | describe '.transaction' do 3 | subject(:transaction) do 4 | described_class.transaction do 5 | add_callbacks 6 | block 7 | end 8 | end 9 | 10 | shared_examples 'handles transaction' do 11 | let(:block) { 123 } 12 | 13 | let(:add_callbacks) do 14 | described_class.after_commit { block_listener.do_stuff } 15 | described_class.after_commit(object_listener) 16 | end 17 | 18 | let(:block_listener) { double(do_stuff: true) } # rubocop:disable RSpec/VerifiedDoubles 19 | let(:object_listener) { double(_run_commit_callbacks: true) } # rubocop:disable RSpec/VerifiedDoubles 20 | 21 | it 'returns result of a block and triggers registered callbacks' do 22 | expect(object_listener).to receive(:_run_commit_callbacks).ordered 23 | expect(block_listener).to receive(:do_stuff).ordered 24 | expect(subject).to eq 123 25 | end 26 | 27 | context 'with failed transaction' do 28 | let(:block) { raise 'I failed' } 29 | 30 | it 're-raise and doesnt run callbacks' do 31 | expect(object_listener).not_to receive(:_run_commit_callbacks) 32 | expect(block_listener).not_to receive(:do_stuff) 33 | expect { subject }.to raise_error(RuntimeError, 'I failed') 34 | end 35 | end 36 | 37 | context 'with nested transaction which fails' do # rubocop:disable RSpec/MultipleMemoizedHelpers 38 | let(:block) do 39 | described_class.after_commit(object_listener_one) 40 | described_class.transaction do 41 | described_class.after_commit(object_listener_two) 42 | raise 'I failed' 43 | end 44 | end 45 | 46 | let(:object_listener_one) { double(_run_commit_callbacks: true) } # rubocop:disable RSpec/VerifiedDoubles 47 | let(:object_listener_two) { double(_run_commit_callbacks: true) } # rubocop:disable RSpec/VerifiedDoubles 48 | 49 | specify 'both transactions reverted and error bubbled' do 50 | expect(object_listener).not_to receive(:_run_commit_callbacks) 51 | expect(block_listener).not_to receive(:do_stuff) 52 | expect(object_listener_one).not_to receive(:_run_commit_callbacks) 53 | expect(object_listener_two).not_to receive(:_run_commit_callbacks) 54 | expect { subject }.to raise_error(RuntimeError, 'I failed') 55 | end 56 | end 57 | 58 | context 'with failed after_commit callback' do 59 | let(:add_callbacks) do 60 | super() 61 | described_class.after_commit { raise 'callback failed' } 62 | end 63 | 64 | it 'calls for every callback and fails with first callback error' do 65 | expect(object_listener).to receive(:_run_commit_callbacks).ordered 66 | expect(block_listener).to receive(:do_stuff).ordered 67 | expect { subject }.to raise_error 'callback failed' 68 | end 69 | end 70 | 71 | context 'with multiple after_commit callbacks failures' do 72 | let(:add_callbacks) do 73 | super() 74 | described_class.after_commit { raise 'callback failed second' } 75 | described_class.after_commit { raise 'callback failed first' } 76 | end 77 | 78 | it 'calls for every callback, fails with first callback error and logs others' do 79 | expect(object_listener).to receive(:_run_commit_callbacks).ordered 80 | expect(block_listener).to receive(:do_stuff).ordered 81 | expect(Granite::Form.config.logger) 82 | .to receive(:error).with(/Unhandled.*RuntimeError.*callback failed second.*\n.*transaction_manager_spec.*/) 83 | expect { subject }.to raise_error 'callback failed first' 84 | end 85 | end 86 | end 87 | 88 | context 'with ActiveRecord' do 89 | include_examples 'handles transaction' 90 | 91 | context 'when transacton fails with Granite::Action::Rollback' do 92 | let(:block) { raise Granite::Action::Rollback } 93 | 94 | it 'returns false and does not trigger callbacks' do 95 | expect(object_listener).not_to receive(:_run_commit_callbacks) 96 | expect(block_listener).not_to receive(:do_stuff) 97 | expect(transaction).to be(false) 98 | end 99 | end 100 | 101 | context 'with failing nested transaction' do 102 | let(:block) do 103 | described_class.after_commit(object_listener_one) 104 | User.new.save! 105 | described_class.transaction do 106 | described_class.after_commit(object_listener_two) 107 | Role.new.save! 108 | raise error 109 | end 110 | 456 111 | end 112 | 113 | let(:object_listener_one) { double(_run_commit_callbacks: true) } # rubocop:disable RSpec/VerifiedDoubles 114 | let(:object_listener_two) { double(_run_commit_callbacks: true) } # rubocop:disable RSpec/VerifiedDoubles 115 | 116 | context 'with Granite::Action::Rollback' do 117 | let(:error) { Granite::Action::Rollback } 118 | 119 | specify 'only first transaction commited' do 120 | expect(object_listener).to receive(:_run_commit_callbacks) 121 | expect(block_listener).to receive(:do_stuff) 122 | expect(object_listener_one).to receive(:_run_commit_callbacks) 123 | expect(object_listener_two).not_to receive(:_run_commit_callbacks) 124 | expect { expect(transaction).to eq 456 }.to change(User, :count).by(1).and(not_change { Role.count }) 125 | end 126 | end 127 | 128 | context 'with any other error' do 129 | let(:error) { 'I failed' } 130 | 131 | specify 'no records created and error bubbled' do 132 | expect { transaction }.to raise_error(RuntimeError, 'I failed') 133 | .and not_change { User.count } 134 | .and(not_change { Role.count }) 135 | end 136 | end 137 | end 138 | end 139 | 140 | context 'without ActiveRecord' do 141 | before do 142 | hide_const('ActiveRecord::Base') 143 | hide_const('ActiveRecord') 144 | end 145 | 146 | include_examples 'handles transaction' 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /spec/lib/granite/action/transaction_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::Transaction do 2 | describe 'after_commit' do 3 | subject(:perform) { action.perform! } 4 | 5 | let(:action) { Action.new } 6 | 7 | before do 8 | stub_class(:action, Granite::Action) do 9 | allow_if { true } 10 | collection :callbacks, String 11 | 12 | after_commit do 13 | callbacks << 'after_commit' 14 | end 15 | 16 | def execute_perform!(*) 17 | # Simulates the after_commit_everywhere in_transaction helper (https://github.com/Envek/after_commit_everywhere/pull/23) 18 | # to avoid name collisions 19 | in_transaction { 'test' } 20 | end 21 | 22 | def in_transaction 23 | yield 24 | end 25 | end 26 | end 27 | 28 | it { expect { perform }.to change(action, :callbacks).to(%w[after_commit]) } 29 | 30 | context 'when raises error' do 31 | before do 32 | stub_class(:dummy_error, StandardError) 33 | 34 | stub_class(:action, Granite::Action) do 35 | allow_if { true } 36 | 37 | after_commit do 38 | raise DummyError, 'Dummy exception' 39 | end 40 | end 41 | end 42 | 43 | context 'with unhandled error' do 44 | it 'fails' do 45 | expect(action).to receive(:execute_perform!) 46 | expect { perform }.to raise_error(DummyError, 'Dummy exception') 47 | end 48 | end 49 | 50 | context 'with an error which is handled by `handle_exception`' do 51 | before do 52 | Action.class_eval do 53 | handle_exception(DummyError) do |e| 54 | handle_dummy_error(e) 55 | end 56 | end 57 | end 58 | 59 | it 'does not fail' do 60 | expect(action).to receive(:execute_perform!) 61 | expect(action).to receive(:handle_dummy_error).with(instance_of(DummyError)) 62 | perform 63 | end 64 | end 65 | end 66 | 67 | context 'when preconditions fail' do 68 | before do 69 | Action.precondition { decline_with(:invalid) } 70 | end 71 | 72 | it { expect { perform }.to not_change { action.callbacks }.and raise_error(Granite::Action::ValidationError) } 73 | 74 | context 'when using perform' do 75 | subject(:perform) { action.perform } 76 | 77 | it { expect(perform).to be(false) } 78 | it { expect { perform }.to(not_change { action.callbacks }) } 79 | end 80 | end 81 | 82 | context 'when actions chained with after_commit' do 83 | let(:sub_action) { SubAction.new } 84 | 85 | before do 86 | stub_class(:dummy_error, StandardError) 87 | 88 | stub_class(:action, Granite::Action) do 89 | allow_if { true } 90 | 91 | after_commit do 92 | sub_action.perform! 93 | end 94 | end 95 | 96 | stub_class(:sub_action, Granite::Action) do 97 | allow_if { true } 98 | 99 | after_commit :after_commit_handler 100 | end 101 | 102 | allow(action).to receive(:sub_action).and_return(sub_action) 103 | end 104 | 105 | it do 106 | expect(action).to receive(:execute_perform!).ordered 107 | expect(sub_action).to receive(:execute_perform!).ordered 108 | expect(sub_action).to receive(:after_commit_handler).ordered 109 | perform 110 | end 111 | end 112 | end 113 | 114 | describe 'transaction' do 115 | subject(:perform) { action.perform! } 116 | 117 | let(:action) { Action.new } 118 | 119 | before do 120 | stub_class(:action, Granite::Action) do 121 | allow_if { true } 122 | 123 | def execute_perform!(*) 124 | true 125 | end 126 | end 127 | end 128 | 129 | it 'opens a transaction and registers self as a callback' do 130 | expect(Granite::Action::TransactionManager).to receive(:transaction).ordered.and_call_original 131 | expect(Granite::Action::TransactionManager).to receive(:after_commit).ordered.with(action) 132 | perform 133 | end 134 | 135 | context 'with twice nested actions when the third action fails and the second silenced the failure' do 136 | before do 137 | stub_class(:action1, Granite::Action) do 138 | allow_if { true } 139 | 140 | def execute_perform!(*) 141 | User.new.save! 142 | Action2.new.perform 143 | User.new.save! 144 | end 145 | end 146 | 147 | stub_class(:action2, Granite::Action) do 148 | allow_if { true } 149 | 150 | def execute_perform!(*) 151 | Role.new.save! 152 | Action3.new.perform! 153 | Role.new.save! 154 | end 155 | end 156 | 157 | stub_class(:action3, Granite::Action) do 158 | allow_if { true } 159 | 160 | attribute :name, type: String 161 | validates :name, presence: true 162 | end 163 | end 164 | 165 | it 'commits changes of the first action only' do 166 | expect do 167 | expect(Action1.new.perform!).to be(true) 168 | end.to change(User, :count).by(2).and(not_change { Role.count }) 169 | end 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /spec/lib/granite/action/translations_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Action::Translations do 2 | before do 3 | stub_class(:dummy_action, Granite::Action) do 4 | attribute :id, Integer 5 | end 6 | end 7 | 8 | describe '.i18n_scope' do 9 | subject { DummyAction.i18n_scope } 10 | 11 | it { is_expected.to eq(:granite_action) } 12 | end 13 | 14 | describe '.i18n_scopes' do 15 | subject { DummyAction.i18n_scopes } 16 | 17 | it { is_expected.to eq([:'granite_action.dummy_action', :'granite_action.granite/action', nil]) } 18 | end 19 | 20 | describe '.translate' do 21 | subject { DummyAction.new.t('.key') } 22 | 23 | it { is_expected.to eq('dummy action key') } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/lib/granite/assign_data_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::AssignData do 2 | subject(:action) { DummyAction.new(user) } 3 | 4 | let!(:user) { User.create! } 5 | 6 | before do 7 | stub_class(:dummy_action, Granite::Action) do 8 | subject :user 9 | end 10 | end 11 | 12 | context 'when using block with assign data' do 13 | before do 14 | DummyAction.assign_data do 15 | user.full_name = 'New Name' 16 | end 17 | end 18 | 19 | it { expect { action.validate }.to change(user, :full_name).to('New Name') } 20 | end 21 | 22 | context 'when using method name with assign data' do 23 | before do 24 | DummyAction.class_eval do 25 | assign_data :set_name 26 | 27 | def set_name 28 | user.full_name = 'New Name' 29 | end 30 | end 31 | end 32 | 33 | it { expect { action.validate }.to change(user, :full_name).to('New Name') } 34 | end 35 | 36 | context 'when using method name & options' do 37 | before do 38 | DummyAction.class_eval do 39 | assign_data :set_name, if: -> { user.full_name.blank? } 40 | 41 | def set_name 42 | user.full_name = 'New Name' 43 | end 44 | end 45 | end 46 | 47 | it { expect { action.validate }.to change(user, :full_name).to('New Name') } 48 | 49 | context 'when conditions are not satisfied' do 50 | let!(:user) { User.create! full_name: 'Existing name' } 51 | 52 | it { expect { action.validate }.not_to(change(user, :full_name)) } 53 | end 54 | end 55 | 56 | context 'with represented attribute' do 57 | before do 58 | DummyAction.represents :full_name, of: :subject 59 | action.full_name = 'New Name' 60 | end 61 | 62 | it { expect { action.validate }.to change(user, :full_name).to('New Name') } 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/lib/granite/config_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Config do 2 | let(:config) { described_class.__send__(:new) } 3 | 4 | describe '#base_controller_class' do 5 | subject(:base_controller_class) { config.base_controller_class } 6 | 7 | it { is_expected.to eq ActionController::Base } 8 | 9 | context 'when base_controller is set' do 10 | let!(:controller_class) { stub_class('GraniteConfigTestController', ActionController::Base) } 11 | 12 | before { config.base_controller = 'GraniteConfigTestController' } 13 | 14 | it { is_expected.to eq controller_class } 15 | 16 | context 'with invalid value' do 17 | before { config.base_controller = 'NonExistingController' } 18 | 19 | specify do 20 | expect do 21 | base_controller_class 22 | end.to raise_error NameError, /uninitialized constant NonExistingController/ 23 | end 24 | end 25 | 26 | context 'with live reload' do 27 | let(:controller_class_reloaded) { stub_class('GraniteConfigTestController', ActionController::Base) } 28 | 29 | specify do 30 | expect(config.base_controller_class).to eq controller_class 31 | controller_class_reloaded 32 | expect(config.base_controller_class).to eq controller_class_reloaded 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/lib/granite/context_proxy/proxy_spec.rb: -------------------------------------------------------------------------------- 1 | # spec: unit 2 | 3 | require 'granite/context_proxy/proxy' 4 | 5 | RSpec.describe Granite::ContextProxy::Proxy do 6 | subject(:proxy) { described_class.new(klass, context) } 7 | 8 | let(:klass) { stub_class('DummyClass') } 9 | let(:context) { { performer: '#Performer' } } 10 | 11 | its(:inspect) { is_expected.to eq('"#Performer"}>') } 12 | 13 | describe '#method_missing' do 14 | specify 'when klass does not respond to a method' do 15 | expect { proxy.func }.to raise_error NoMethodError 16 | end 17 | 18 | context 'when klass responds to a method' do 19 | before do 20 | klass.define_singleton_method(:func) { |_| 'value' } 21 | end 22 | 23 | specify do 24 | allow(klass).to receive(:with_context).with(context).and_yield 25 | proxy.func('value') 26 | expect(klass).to have_received(:with_context).with(context) 27 | end 28 | end 29 | end 30 | 31 | describe '#respond_to_missing?' do 32 | specify 'when class does not respond to a method' do 33 | expect(proxy.__send__(:respond_to_missing?, :func)).to be(false) 34 | end 35 | 36 | context 'when klass responds to a method' do 37 | before do 38 | klass.define_singleton_method(:func) { 'value' } 39 | end 40 | 41 | specify do 42 | expect(proxy.__send__(:respond_to_missing?, :func)).to be(true) 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/lib/granite/context_proxy_spec.rb: -------------------------------------------------------------------------------- 1 | require 'granite/context_proxy' 2 | 3 | RSpec.describe Granite::ContextProxy do 4 | subject { klass.new } 5 | 6 | let(:klass) do 7 | Class.new do 8 | include Granite::ContextProxy 9 | end 10 | end 11 | let(:performer) { performers.first } 12 | let(:performers) { 10.times.map { |i| instance_double(User, "Performer #{i}") } } 13 | let(:context) { { performer: performer } } 14 | let(:proxy) { instance_double(Granite::ContextProxy::Proxy) } 15 | 16 | describe '.with' do 17 | before do 18 | allow(Granite::ContextProxy::Proxy).to receive(:new).with(klass, have_attributes(**context)) { proxy } 19 | end 20 | 21 | specify do 22 | expect(klass.with(context)).to eq proxy 23 | end 24 | end 25 | 26 | describe '.as' do 27 | before do 28 | allow(Granite::ContextProxy::Proxy).to receive(:new).with(klass, have_attributes(**context)) { proxy } 29 | end 30 | 31 | specify do 32 | expect(klass.as(performer)).to eq proxy 33 | end 34 | end 35 | 36 | describe '.with_context' do 37 | specify do 38 | expect { |b| klass.with_context(context, &b) }.to yield_with_no_args 39 | end 40 | 41 | it 'sets proxy_content inside block' do 42 | klass.with_context(context) do 43 | expect(klass.proxy_context).to eq context 44 | end 45 | expect(klass.proxy_context).to be_nil 46 | end 47 | 48 | it 'correctly works with nested contexts' do # rubocop:disable RSpec/ExampleLength 49 | klass.with_context(context) do 50 | expect(klass.proxy_context).to eq context 51 | klass.with_context(performer: performers.second) do 52 | expect(klass.proxy_context).to eq(performer: performers.second) 53 | end 54 | expect(klass.proxy_context).to eq context 55 | end 56 | end 57 | end 58 | 59 | describe '.proxy_context' do 60 | before do 61 | Thread.current[:granite_proxy_context] = context 62 | end 63 | 64 | after do 65 | Thread.current[:granite_proxy_context] = nil 66 | end 67 | 68 | specify do 69 | expect(klass.proxy_context).to eq context 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/lib/granite/context_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Context do 2 | subject(:config) { described_class.__send__(:new) } 3 | 4 | describe '#with_view_context' do 5 | let(:view_context) { Object.new } 6 | 7 | specify { expect(config.with_view_context(view_context) { 'result' }).to eq('result') } 8 | 9 | specify do # rubocop:disable RSpec/ExampleLength 10 | expect(config.view_context).to be_nil 11 | config.with_view_context(view_context) do 12 | expect(config.view_context).to eq(view_context) 13 | 14 | config.with_view_context(nil) do 15 | expect(config.view_context).to be_nil 16 | end 17 | 18 | expect(config.view_context).to eq(view_context) 19 | end 20 | expect(config.view_context).to be_nil 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/lib/granite/dispatcher_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Dispatcher do 2 | subject(:dispatcher) { described_class.new } 3 | 4 | before do 5 | stub_class(:projector, Granite::Projector) do 6 | get :confirm do # rubocop:disable Lint/EmptyBlock 7 | end 8 | 9 | post :perform, as: '' do # rubocop:disable Lint/EmptyBlock 10 | end 11 | end 12 | 13 | stub_class(:action, Granite::Action) do 14 | projector :dummy, class_name: 'Projector' 15 | end 16 | end 17 | 18 | let(:params) { { granite_action: 'action', granite_projector: 'dummy' } } 19 | 20 | describe '#call' do 21 | specify { expect { dispatcher.call({}) }.to raise_error 'Dispatcher can\'t be used as a Rack app.' } 22 | end 23 | 24 | describe '#constraints' do 25 | subject { dispatcher.constraints.all? { |c| c.call(req) } } 26 | 27 | let(:req) do 28 | instance_double(ActionDispatch::Request, env: env, params: params, request_method_symbol: request_method) 29 | end 30 | let(:env) { {} } 31 | let(:params) { super().merge(projector_action: 'confirm') } 32 | let(:request_method) { :get } 33 | 34 | context 'when BA projector has appropriate action defined' do 35 | it { is_expected.to be(true) } 36 | end 37 | 38 | context 'when request has different request method' do 39 | let(:request_method) { :post } 40 | 41 | it { is_expected.to be(false) } 42 | end 43 | 44 | context 'when request has different action' do 45 | let(:params) { super().merge(projector_action: 'undefined') } 46 | 47 | it { is_expected.to be(false) } 48 | end 49 | 50 | context 'when request has invalid granite params' do 51 | before do 52 | stub_class(:action_without_projectors, Granite::Action) 53 | end 54 | 55 | let(:params) do 56 | { granite_action: 'action_without_projectors', granite_projector: 'dummy', projector_action: 'confirm' } 57 | end 58 | 59 | it { is_expected.to be(false) } 60 | end 61 | end 62 | 63 | describe '#serve' do # rubocop:disable RSpec/MultipleMemoizedHelpers 64 | let(:controller_class) { Action.dummy.controller_class } 65 | let(:controller_action) { object_spy(->(_env) {}) } 66 | let(:env) { {} } 67 | let(:params) { super().merge(projector_action: 'confirm') } 68 | let(:request_method) { :get } 69 | let(:req) do 70 | instance_double(ActionDispatch::Request, env: env, params: params, request_method_symbol: request_method) 71 | end 72 | 73 | before do 74 | allow(controller_class).to receive(:action) { controller_action } 75 | end 76 | 77 | it 'finds the controller action by name in the specified projector' do 78 | dispatcher.serve(req) 79 | 80 | expect(controller_class).to have_received(:action).with(:confirm) 81 | end 82 | 83 | it 'calls the controller action by name in the specified business action' do 84 | dispatcher.serve(req) 85 | 86 | expect(controller_action).to have_received(:call).with(env) 87 | end 88 | 89 | context 'when projector action is nil' do # rubocop:disable RSpec/MultipleMemoizedHelpers 90 | let(:params) { super().except(:projector_action) } 91 | let(:request_method) { :post } 92 | 93 | it 'finds the controller action by name in the specified projector' do 94 | dispatcher.serve(req) 95 | 96 | expect(controller_class).to have_received(:action).with(:perform) 97 | end 98 | end 99 | end 100 | 101 | describe '#controller' do 102 | subject { dispatcher.controller(params) } 103 | 104 | context 'when projector exists' do 105 | it { is_expected.to eq Action.dummy.controller_class } 106 | end 107 | 108 | context 'when projector does not exist' do 109 | let(:params) { super().merge(granite_projector: 'invalid') } 110 | 111 | it { is_expected.to be_nil } 112 | end 113 | end 114 | 115 | describe '#prepare_params!' do 116 | subject { dispatcher.prepare_params!(params) } 117 | 118 | let(:params) { double } 119 | 120 | it { is_expected.to eq params } 121 | end 122 | 123 | describe '#reset!' do 124 | it 'unmemoize all cached methods' do 125 | expect(dispatcher).to receive(:unmemoize_all) # rubocop:disable RSpec/SubjectStub 126 | dispatcher.reset! 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/lib/granite/projector/controller_actions_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Projector::ControllerActions, type: :granite_projector do 2 | prepend_before do 3 | stub_class(:projector, Granite::Projector) do 4 | get :confirm do 5 | render json: { success: true, action: 'confirm' } 6 | end 7 | 8 | post :perform do 9 | render json: { success: true, action: 'perform' } 10 | end 11 | 12 | get :custom, as: '' do 13 | render json: { success: true, action: 'custom' } 14 | end 15 | 16 | post :custom_post, as: '' do 17 | render json: { success: true, action: 'custom_post' } 18 | end 19 | end 20 | stub_class(:descendant, Projector) 21 | stub_class(:dummy_action, Granite::Action) do 22 | projector :dummy, class_name: 'Projector' 23 | allow_if { true } 24 | attribute :name, String 25 | end 26 | end 27 | 28 | describe '.action' do 29 | before do 30 | Projector.action(:first, method: 'post') {} # rubocop:disable Lint/EmptyBlock 31 | Descendant.action(:second, method: 'put') {} # rubocop:disable Lint/EmptyBlock 32 | end 33 | 34 | specify { expect(Projector.action(:first)).to eq(method: 'post') } 35 | specify { expect(Descendant.action('first')).to eq(method: 'post') } 36 | 37 | specify { expect(Projector.action(:second)).to be_nil } 38 | specify { expect(Descendant.action('second')).to eq(method: 'put') } 39 | 40 | specify { expect(Projector.controller_class).to be_method_defined(:first) } 41 | specify { expect(Descendant.controller_class).to be_method_defined(:first) } 42 | 43 | specify { expect(Projector.controller_class).not_to be_method_defined(:second) } 44 | specify { expect(Descendant.controller_class).to be_method_defined(:second) } 45 | end 46 | 47 | ActionDispatch::Routing::HTTP_METHODS.each do |method| 48 | describe ".#{method}" do 49 | before { Projector.public_send(method, :first) {} } # rubocop:disable Lint/EmptyBlock 50 | 51 | specify { expect(Projector.action(:first)).to eq(method: method) } 52 | specify { expect(Descendant.action('first')).to eq(method: method) } 53 | end 54 | end 55 | 56 | describe '.action_for' do 57 | specify { expect(Projector.action_for(:get, 'confirm')).to eq :confirm } 58 | specify { expect(Projector.action_for(:get, 'perform')).to be_nil } 59 | specify { expect(Projector.action_for(:get, '')).to eq :custom } 60 | specify { expect(Projector.action_for(:post, '')).to eq :custom_post } 61 | 62 | context 'with custom name' do 63 | before do 64 | stub_class(:projector, Granite::Projector) do 65 | get(:confirm, as: 'test') {} # rubocop:disable Lint/EmptyBlock 66 | end 67 | end 68 | 69 | specify { expect(Projector.action_for(:get, 'confirm')).to be_nil } 70 | specify { expect(Projector.action_for(:get, 'test')).to eq :confirm } 71 | end 72 | end 73 | 74 | describe 'routes, with no subject' do 75 | projector { DummyAction.dummy } 76 | 77 | draw_routes do 78 | resources :students do 79 | granite 'dummy_action#dummy', on: :collection 80 | end 81 | end 82 | 83 | describe '##action_url' do 84 | specify { expect(projector.confirm_url(foo: 'string')).to eq('http://test.host/students/dummy_action/confirm?foo=string') } 85 | specify { expect(projector.perform_url(anchor: 'ok')).to eq('http://test.host/students/dummy_action/perform#ok') } 86 | specify { expect(projector.perform_url('anchor' => 'ok')).to eq('http://test.host/students/dummy_action/perform#ok') } 87 | end 88 | 89 | describe '##action_path' do 90 | specify { expect(projector.confirm_path).to eq('/students/dummy_action/confirm') } 91 | specify { expect(projector.perform_path(bar: 'string')).to eq('/students/dummy_action/perform?bar=string') } 92 | 93 | specify do 94 | expect(projector.perform_path('bar' => 'string')).to eq('/students/dummy_action/perform?bar=string') 95 | end 96 | end 97 | end 98 | 99 | describe 'routes, with subject' do 100 | projector { DummyAction.dummy } 101 | 102 | draw_routes do 103 | resources :students do 104 | granite 'dummy_action#dummy', on: :member 105 | end 106 | end 107 | 108 | before do 109 | DummyAction.subject :role 110 | controller.params[:role] = Role.new(id: 42) 111 | end 112 | 113 | describe '##action_url' do 114 | specify { expect(projector.confirm_url(foo: 'string')).to eq('http://test.host/students/42/dummy_action/confirm?foo=string') } 115 | specify { expect(projector.perform_url(anchor: 'ok')).to eq('http://test.host/students/42/dummy_action/perform#ok') } 116 | specify { expect(projector.perform_url('anchor' => 'ok')).to eq('http://test.host/students/42/dummy_action/perform#ok') } 117 | end 118 | 119 | describe '##action_path' do 120 | specify { expect(projector.confirm_path).to eq('/students/42/dummy_action/confirm') } 121 | specify { expect(projector.perform_path(bar: 'string')).to eq('/students/42/dummy_action/perform?bar=string') } 122 | 123 | specify do 124 | expect(projector.perform_path('bar' => 'string')).to eq('/students/42/dummy_action/perform?bar=string') 125 | end 126 | end 127 | end 128 | 129 | describe 'controller testing' do 130 | projector { DummyAction.dummy } 131 | 132 | draw_routes do 133 | resources :students do 134 | granite 'dummy_action#dummy', on: :collection 135 | end 136 | end 137 | 138 | let(:response_json) { JSON.parse(response.body, symbolize_names: true) } 139 | 140 | [%i[get confirm], %i[post perform], %i[get custom], %i[post custom_post]].each do |method, action| 141 | it "successfully performs #{method} to #{action}" do 142 | public_send(method, action) 143 | expect(response).to be_successful 144 | expect(response_json).to eq(success: true, action: action.to_s) 145 | end 146 | end 147 | 148 | it 'passes extra params to the action' do 149 | get :confirm, params: { name: 'Test Name' } 150 | expect(projector.action).to have_attributes(name: 'Test Name') 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /spec/lib/granite/projector/helpers_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Projector::Helpers, type: :granite_projector do 2 | prepend_before do 3 | stub_class(:dummy_user) 4 | stub_class(:projector, Granite::Projector) do 5 | get :confirm, as: '' do # rubocop:disable Lint/EmptyBlock 6 | end 7 | 8 | post :perform, as: '' do # rubocop:disable Lint/EmptyBlock 9 | end 10 | 11 | get :result do # rubocop:disable Lint/EmptyBlock 12 | end 13 | end 14 | stub_class(:dummy_action, Granite::Action) do 15 | projector :dummy, class_name: 'Projector' 16 | end 17 | end 18 | 19 | let(:action) { DummyAction.new } 20 | let(:projector) { DummyAction.dummy.new(action) } 21 | 22 | describe '#view_context' do 23 | let(:view_context) { Object.new } 24 | 25 | specify { expect(projector.view_context).to be_nil } 26 | specify { expect(Granite.with_view_context(view_context) { projector.view_context }).to eq(view_context) } 27 | end 28 | 29 | context 'without route' do 30 | projector { DummyAction.dummy } 31 | 32 | describe '#action_url' do 33 | specify do 34 | expect { projector.action_url('confirm', foo: 'string') } 35 | .to raise_error( 36 | Granite::Projector::ActionNotMountedError, 37 | 'Seems like DummyAction::DummyProjector was not mounted. Do you have dummy_action#dummy declared in routes?' 38 | ) 39 | end 40 | end 41 | 42 | describe '#action_path' do 43 | specify do 44 | expect { projector.action_path('confirm') } 45 | .to raise_error( 46 | Granite::Projector::ActionNotMountedError, 47 | 'Seems like DummyAction::DummyProjector was not mounted. Do you have dummy_action#dummy declared in routes?' 48 | ) 49 | end 50 | end 51 | end 52 | 53 | context 'without subject' do 54 | projector { DummyAction.dummy } 55 | 56 | draw_routes do 57 | resources :students, only: [] do 58 | granite 'dummy_action#dummy', on: :collection 59 | end 60 | end 61 | 62 | describe '#action_url' do 63 | specify { expect(projector.action_url('confirm', foo: 'string')).to eq('http://test.host/students/dummy_action?foo=string') } 64 | specify { expect(projector.action_url(:perform, anchor: 'ok')).to eq('http://test.host/students/dummy_action#ok') } 65 | specify { expect(projector.action_url(:result)).to eq('http://test.host/students/dummy_action/result') } 66 | end 67 | 68 | describe '#action_path' do 69 | specify { expect(projector.action_path('confirm')).to eq('/students/dummy_action') } 70 | 71 | specify do 72 | expect(projector.action_path(:perform, bar: 'string', 73 | only_path: false)).to eq('/students/dummy_action?bar=string') 74 | end 75 | 76 | specify { expect(projector.action_path(:result)).to eq('/students/dummy_action/result') } 77 | end 78 | end 79 | 80 | context 'with subject' do 81 | projector { DummyAction.dummy } 82 | 83 | draw_routes do 84 | resources :students, only: [] do 85 | granite 'dummy_action#dummy', on: :member 86 | end 87 | end 88 | 89 | before do 90 | DummyAction.subject :role 91 | controller.params[:role] = Role.new(id: 42) 92 | end 93 | 94 | describe '#action_url' do 95 | specify { expect(projector.action_url('confirm', foo: 'string')).to eq('http://test.host/students/42/dummy_action?foo=string') } 96 | specify { expect(projector.action_url(:perform, anchor: 'ok')).to eq('http://test.host/students/42/dummy_action#ok') } 97 | specify { expect(projector.action_url(:result)).to eq('http://test.host/students/42/dummy_action/result') } 98 | end 99 | 100 | describe '#action_path' do 101 | specify { expect(projector.action_path('confirm')).to eq('/students/42/dummy_action') } 102 | 103 | specify do 104 | expect(projector.action_path(:perform, bar: 'string', 105 | only_path: false)).to eq('/students/42/dummy_action?bar=string') 106 | end 107 | 108 | specify { expect(projector.action_path(:result)).to eq('/students/42/dummy_action/result') } 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /spec/lib/granite/projector/translations_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Projector::Translations, type: :granite_projector do 2 | prepend_before do 3 | stub_class(:dummy_projector, Granite::Projector) 4 | stub_class(:dummy_action, Granite::Action) do 5 | projector :dummy 6 | end 7 | end 8 | 9 | projector { DummyAction.dummy } 10 | 11 | describe '#i18n_scopes' do 12 | it do 13 | expect(projector.i18n_scopes).to eq %w[granite_action.dummy_action.dummy granite_action.granite/action.dummy 14 | dummy] 15 | end 16 | end 17 | 18 | describe '#translate' do 19 | it do 20 | expect(projector.translate('.key')).to eq 'dummy action dummy projector key' 21 | expect(projector.translate('.other_key')).to eq 'dummy projector other key' 22 | expect(projector.translate(:no_such_key)) 23 | .to eq 'No Such Key' 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/lib/granite/projector_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Projector do 2 | before do 3 | stub_class(:action, Granite::Action) 4 | stub_class(:projector, described_class) do 5 | self.action_class = Action 6 | end 7 | stub_class(:descendant, Projector) 8 | end 9 | 10 | describe '.with' do 11 | let(:context) { { performer: instance_double(Student) } } 12 | 13 | specify { expect(Projector.with(context).new.action.ctx).to have_attributes(**context) } 14 | end 15 | 16 | describe '.controller_class' do 17 | specify { expect(described_class.controller_class).to eq(Granite::Controller) } 18 | specify { expect(Projector.controller_class).to be < Granite::Controller } 19 | specify { expect(Descendant.controller_class).to be < Projector.controller_class } 20 | specify { expect(Descendant.controller_class).not_to eq(Projector.controller_class) } 21 | 22 | specify { expect(described_class.controller_class.projector_class).to be_nil } 23 | specify { expect(Projector.controller_class.projector_class).to eq(Projector) } 24 | specify { expect(Descendant.controller_class.projector_class).to eq(Descendant) } 25 | end 26 | 27 | describe '.projector_path' do 28 | specify { expect(stub_class(:some_projector, described_class).projector_path).to eq('some') } 29 | specify { expect(stub_class('directory/some_projector', described_class).projector_path).to eq('directory/some') } 30 | end 31 | 32 | describe '.projector_name' do 33 | specify { expect(stub_class(:some_projector, described_class).projector_name).to eq('some') } 34 | specify { expect(stub_class('directory/some_projector', described_class).projector_name).to eq('some') } 35 | end 36 | 37 | describe '#initialize' do 38 | describe 'old approach' do 39 | specify { expect(Projector.new(Action.new).action).to be_an Action } 40 | end 41 | 42 | describe 'new approach' do 43 | let(:params) { [double, double] } 44 | let(:action) { double } 45 | 46 | it 'builds an action from params' do 47 | allow(Action).to receive(:new).with(*params).and_return(action) 48 | expect(Projector.new(*params).action).to eq action 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/lib/granite/railtie_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Railtie do 2 | subject { described_class.new } 3 | 4 | describe '#initializer' do 5 | let(:app) { double.as_null_object } 6 | 7 | specify do 8 | allow(app.config.paths).to receive(:add) 9 | described_class.initializers.each { |initializer| initializer.run(app) } 10 | expect(app.config.paths) 11 | .to have_received(:add) 12 | .with('apq', eager_load: true, glob: '{actions,projectors}{,/concerns}') 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/lib/granite/routing/cache_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Routing::Cache do 2 | subject(:cache) { described_class.new(routes) } 3 | 4 | let(:action_route) do 5 | instance_double(ActionDispatch::Journey::Route, 6 | required_defaults: { granite_action: 'test', granite_projector: 'simple' }) 7 | end 8 | let(:another_action_route) do 9 | instance_double(ActionDispatch::Journey::Route, 10 | required_defaults: { granite_action: 'test2', granite_projector: 'simple' }) 11 | end 12 | let(:regular_route) do 13 | instance_double(ActionDispatch::Journey::Route, required_defaults: {}) 14 | end 15 | let(:routes) do 16 | [action_route, another_action_route, regular_route] 17 | end 18 | 19 | describe '#[]' do 20 | it 'returns route with matched action & projector' do 21 | expect(cache[:test, :simple]).to eq action_route 22 | end 23 | 24 | it 'returns nil if no route found' do 25 | expect(cache[:foo, :bar]).to be_nil 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/lib/granite/routing/caching_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Routing::Caching do 2 | subject(:caching) { dummy_class.new } 3 | 4 | super_module = Module.new do 5 | def clear_cache!; end 6 | end 7 | 8 | let(:dummy_class) do 9 | Class.new do 10 | include super_module 11 | include Granite::Routing::Caching 12 | 13 | def initialize 14 | @granite_cache = 'some_value' 15 | end 16 | 17 | def cache 18 | instance_variable_get(:@granite_cache) 19 | end 20 | end 21 | end 22 | 23 | describe '#granite_cache' do 24 | context 'when granite_cache is present' do 25 | it 'returns current value' do 26 | expect(caching.granite_cache).to eq 'some_value' 27 | end 28 | end 29 | 30 | context 'when granite_cache is not present' do 31 | before { caching.clear_cache! } 32 | 33 | it 'returns instance of Cache' do 34 | expect(caching.granite_cache).to be_a Granite::Routing::Cache 35 | end 36 | end 37 | end 38 | 39 | describe '#clear_cache!' do 40 | it 'calls super' do 41 | expect_any_instance_of(super_module).to receive(:clear_cache!) # rubocop:disable RSpec/AnyInstance 42 | caching.clear_cache! 43 | end 44 | 45 | it 'sets granite_cache to nil' do 46 | expect { caching.clear_cache! }.to change(caching, :cache).to(nil) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/lib/granite/routing/declarer_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Routing::Declarer do 2 | subject(:declarer) { described_class } 3 | 4 | describe '.declare' do 5 | let(:routing) { ActionDispatch::Routing::Mapper.new(Rails.application.routes) } 6 | let(:route) { Granite::Routing::Route.new('ba/student/pause#modal') } 7 | 8 | it 'declares route according to route object' do # rubocop:disable RSpec/ExampleLength 9 | declarer.declare(routing, route) 10 | 11 | matched_route = Rails.application.routes.named_routes[route.as] 12 | 13 | expect(matched_route).to be_present 14 | expect(matched_route.required_defaults[:granite_action]).to eq 'ba/student/pause' 15 | expect(matched_route.required_defaults[:granite_projector]).to eq 'modal' 16 | expect(matched_route.verb).to eq('') 17 | expect(matched_route.app.app).to be_a Granite::Dispatcher 18 | expect(matched_route.path).to match '/pause/my_action' 19 | end 20 | 21 | context 'with explicit on: parameter' do 22 | it 'declares a route with appropriate path' do 23 | routing.resources :another_model do 24 | declarer.declare(routing, route, on: :member) 25 | end 26 | 27 | matched_route = Rails.application.routes.named_routes['pause_another_model'] 28 | 29 | expect(matched_route.path).to match '/another_model/:id/pause/my_action' 30 | end 31 | end 32 | 33 | context 'with explicit http verb via: :post' do 34 | it 'declares a route with appropriate verb' do 35 | routing.resources :another do 36 | declarer.declare(routing, route, via: :post) 37 | end 38 | 39 | matched_route = Rails.application.routes.named_routes['another_pause'] 40 | 41 | expect(matched_route.verb).to eq('POST') 42 | end 43 | end 44 | end 45 | 46 | describe '.reset_dispatcher' do 47 | it 'resets an instance of the Dispatcher' do 48 | allow(declarer.dispatcher).to receive(:reset!) 49 | declarer.reset_dispatcher 50 | expect(declarer.dispatcher).to have_received(:reset!) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/lib/granite/routing/mapper_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Routing::Mapper do 2 | subject(:mapper) { Object.new.extend described_class } 3 | 4 | describe '#granite' do 5 | let(:path) { 'ba/student/pause#modal' } 6 | let(:route) { double } 7 | let(:options) { { path: 1, as: 2, projector_prefix: 3, foo: 4 } } 8 | 9 | before do 10 | stub_const 'Granite::Routing::Declarer', spy 11 | stub_const 'Granite::Routing::Route', double 12 | allow(Granite::Routing::Route).to receive(:new).with(path, **options.except(:foo)).and_return(route) 13 | end 14 | 15 | specify do 16 | mapper.granite(path, **options) 17 | expect(Granite::Routing::Declarer).to have_received(:declare) 18 | .with(mapper, route, **options.except(:path, :as, :projector_prefix)) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/lib/granite/routing/route_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Routing::Route do 2 | subject { described_class.new('ba/sample#modal') } 3 | 4 | its(:action_path) { is_expected.to eq 'ba/sample' } 5 | its(:projector_name) { is_expected.to eq 'modal' } 6 | 7 | describe '#path' do 8 | context 'with explicit path passed' do 9 | subject { described_class.new('ba/sample#modal', path: 'my_path') } 10 | 11 | its(:path) { is_expected.to eq 'my_path(/:projector_action)' } 12 | end 13 | 14 | context 'without explicit path passed' do 15 | its(:path) { is_expected.to eq 'sample(/:projector_action)' } 16 | end 17 | 18 | context 'with projector_prefix: true' do 19 | subject { described_class.new('ba/sample#modal', projector_prefix: true) } 20 | 21 | its(:path) { is_expected.to eq 'modal_sample(/:projector_action)' } 22 | end 23 | end 24 | 25 | describe '#as' do 26 | context 'with explicit as passed' do 27 | subject { described_class.new('ba/sample#modal', as: 'me') } 28 | 29 | its(:as) { is_expected.to eq 'me' } 30 | end 31 | 32 | context 'without explicit as passed' do 33 | its(:as) { is_expected.to eq 'sample' } 34 | end 35 | 36 | context 'with projector_prefix: true' do 37 | subject { described_class.new('ba/sample#modal', projector_prefix: true) } 38 | 39 | its(:as) { is_expected.to eq 'modal_sample' } 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/lib/granite/rspec/have_projector_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'have_projector', aggregate_failures: false do # rubocop:disable RSpec/DescribeClass 2 | before do 3 | stub_class(:action, Granite::Action) do 4 | projector :simple 5 | projector :modal 6 | end 7 | end 8 | 9 | specify { expect(Action.new).to have_projector(:simple) } 10 | 11 | specify { expect(Action.new).to have_projector(:modal) } 12 | 13 | specify do 14 | expect do 15 | expect(Action.new).not_to have_projector(:modal) 16 | end.to fail_with 'expected Action not to have a projector named modal' 17 | end 18 | 19 | specify { expect(Action.new).not_to have_projector(:submit_form) } 20 | 21 | specify do 22 | expect do 23 | expect(Action.new).to have_projector(:submit_form) 24 | end.to fail_with 'expected Action to have a projector named submit_form' 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/lib/granite/rspec/perform_action_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'perform_action' do # rubocop:disable RSpec/DescribeClass 2 | let(:action) { DummyAction.new(user) } 3 | let(:other_action) { OtherAction.new } 4 | let(:user) { User.create! } 5 | let(:other_user) { User.create! } 6 | 7 | before do 8 | stub_class(:DummyAction, Granite::Action) do 9 | allow_if { true } 10 | subject :user 11 | 12 | def execute_perform!(*); end 13 | end 14 | 15 | stub_class(:OtherAction, Granite::Action) do 16 | allow_if { true } 17 | 18 | def execute_perform!(*); end 19 | end 20 | end 21 | 22 | it { expect { action.perform! }.to perform_action(DummyAction) } 23 | it { expect { other_action.perform! }.not_to perform_action(DummyAction) } 24 | 25 | describe 'failures' do 26 | it do 27 | expect do 28 | expect { action.perform! }.not_to perform_action(DummyAction) 29 | end.to fail_with('expected not to call DummyAction#perform!') 30 | end 31 | 32 | it do 33 | expect do 34 | expect { other_action.perform! }.to perform_action(DummyAction) 35 | end.to fail_with('expected to call DummyAction#perform!') 36 | end 37 | end 38 | 39 | describe '#using' do 40 | it { expect { action.try_perform! }.to perform_action(DummyAction).using(:try_perform!) } 41 | it { expect { action.perform }.to perform_action(DummyAction).using(:perform) } 42 | 43 | it do 44 | expect do 45 | expect { action.perform! }.to perform_action(DummyAction).using(:perform) 46 | end.to fail_with('expected to call DummyAction#perform') 47 | end 48 | end 49 | 50 | describe '#as' do 51 | it { expect { action.perform! }.to perform_action(DummyAction).as(nil) } 52 | 53 | it do # rubocop:disable RSpec/ExampleLength 54 | expect do 55 | expect { action.perform! }.to perform_action(DummyAction).as(user) 56 | end.to fail_with(<<~MESSAGE.strip) 57 | expected to call DummyAction#perform! 58 | AS #{user.inspect} 59 | received calls to DummyAction#perform!: 60 | AS nil 61 | MESSAGE 62 | end 63 | end 64 | 65 | describe '#with' do 66 | it { expect { action.perform! }.to perform_action(DummyAction).with(subject: user) } 67 | 68 | it { expect { action.perform! }.to perform_action(DummyAction).with(subject: kind_of(User)) } 69 | 70 | it do # rubocop:disable RSpec/ExampleLength 71 | expect do 72 | expect { action.perform! }.to perform_action(DummyAction).with(subject: other_user) 73 | end.to fail_with(<<~MESSAGE.strip) 74 | expected to call DummyAction#perform! 75 | WITH #{{ subject: other_user }.inspect} 76 | received calls to DummyAction#perform!: 77 | WITH #{{ subject: user }.inspect} 78 | MESSAGE 79 | end 80 | 81 | it do # rubocop:disable RSpec/ExampleLength 82 | kind_of_matcher = kind_of(String) 83 | expect do 84 | expect { action.perform! }.to perform_action(DummyAction).with(subject: kind_of_matcher) 85 | end.to fail_with(<<~MESSAGE.strip) 86 | expected to call DummyAction#perform! 87 | WITH #{{ subject: kind_of_matcher }.inspect} 88 | received calls to DummyAction#perform!: 89 | WITH #{{ subject: user }.inspect} 90 | MESSAGE 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/lib/granite/rspec/raise_validation_error_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'raise_validation_error', aggregate_failures: false do # rubocop:disable RSpec/DescribeClass 2 | before do 3 | stub_class(:action, Granite::Action) do 4 | allow_if { true } 5 | 6 | attribute :raise_error, Boolean 7 | 8 | handle_exception StandardError do |_error| 9 | decline_with(:some_error) 10 | end 11 | 12 | private 13 | 14 | def execute_perform!(*) 15 | raise StandardError if raise_error 16 | end 17 | end 18 | end 19 | 20 | context 'when action does not raise error' do 21 | let(:action) { Action.new } 22 | 23 | specify do 24 | expect { action.perform! }.not_to raise_validation_error 25 | end 26 | end 27 | 28 | context 'when action raises error' do 29 | let(:action) { Action.new(raise_error: true) } 30 | 31 | specify do 32 | expect do 33 | expect { action.perform! }.not_to raise_validation_error.of_type(:some_error) 34 | end.to fail_with('expected not to raise validation error on attribute :base of type :some_error') 35 | end 36 | 37 | specify do 38 | expect do 39 | expect { action.perform! }.to raise_validation_error.of_type(:some_error2) 40 | end.to fail_with('expected to raise validation error on attribute :base of type :some_error2, but raised {:base=>[{:error=>:some_error}]}') # rubocop:disable Layout/LineLength 41 | end 42 | 43 | specify do 44 | expect do 45 | expect { action.perform! }.to raise_validation_error.on_attribute(:raise_error) 46 | end.to fail_with(include 'expected to raise validation error on attribute :raise_error, but raised {:base=>[{:error=>:some_error}]') # rubocop:disable Layout/LineLength 47 | end 48 | 49 | specify do 50 | expect do 51 | expect { action.perform! }.to raise_validation_error.on_attribute(:raise_error).of_type(:some_error) 52 | end.to fail_with(include 'expected to raise validation error on attribute :raise_error of type :some_error, but raised {:base=>[{:error=>:some_error}]') # rubocop:disable Layout/LineLength 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/lib/granite/rspec/satisfy_preconditions_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'satisfy_preconditions', aggregate_failures: false do # rubocop:disable RSpec/DescribeClass 2 | before do 3 | stub_class(:action, Granite::Action) do 4 | attribute :fail_precondition, Boolean 5 | attribute :second_fail_precondition, Boolean 6 | 7 | precondition do 8 | decline_with 'Precondition failed' if fail_precondition 9 | decline_with 'Multiple failures' if second_fail_precondition 10 | end 11 | end 12 | end 13 | 14 | context 'with no preconditions' do 15 | let(:action) { Action.new } 16 | 17 | specify { expect(action).to satisfy_preconditions } 18 | 19 | specify do 20 | expect do 21 | expect(action).not_to satisfy_preconditions 22 | end.to fail_with(%(expected #{action} not to satisfy preconditions but preconditions were satisfied)) 23 | end 24 | end 25 | 26 | context 'with failing preconditions' do 27 | let(:action) { Action.new(fail_precondition: true) } 28 | 29 | specify { expect(action).not_to satisfy_preconditions.with_message('Precondition failed') } 30 | 31 | specify { expect(action).not_to satisfy_preconditions.with_message(/failed$/) } 32 | 33 | specify { expect(action).not_to satisfy_preconditions.with_message(a_string_starting_with('P')) } 34 | 35 | specify do 36 | expect(action).not_to satisfy_preconditions.with_messages('Precondition failed', /failed$/, /^Precondition/) 37 | end 38 | 39 | specify do 40 | expect do 41 | expect(action).to satisfy_preconditions 42 | end.to fail_with(%(expected #{action} to satisfy preconditions but got following errors:\n ["Precondition failed"])) # rubocop:disable Layout/LineLength 43 | end 44 | 45 | specify do 46 | expect do 47 | expect(action).not_to satisfy_preconditions.with_message('failed') 48 | end.to fail_with(%(expected #{action} not to satisfy preconditions with error messages ["failed"] but got following error messages:\n ["Precondition failed"])) # rubocop:disable Layout/LineLength 49 | end 50 | 51 | specify do 52 | expect do 53 | expect(action).not_to satisfy_preconditions.with_messages(['WRONG TEXT']) 54 | end.to fail_with(%(expected #{action} not to satisfy preconditions with error messages ["WRONG TEXT"] but got following error messages:\n ["Precondition failed"])) # rubocop:disable Layout/LineLength 55 | end 56 | 57 | specify do 58 | expect do 59 | expect(action).not_to satisfy_preconditions.with_messages(['WRONG TEXT']).exactly 60 | end.to fail_with(%(expected #{action} not to satisfy preconditions exactly with error messages ["WRONG TEXT"] but got following error messages:\n ["Precondition failed"])) # rubocop:disable Layout/LineLength 61 | end 62 | 63 | specify do 64 | expect do 65 | expect(action).not_to satisfy_preconditions.with_messages_of_kinds(:custom_message, :custom_message2).exactly 66 | end.to fail_with(%(expected #{action} not to satisfy preconditions with error messages of kind [:custom_message, :custom_message2] but got following kind of error messages:\n [:error])) # rubocop:disable Layout/LineLength 67 | end 68 | 69 | specify do 70 | expect do 71 | expect(action).not_to satisfy_preconditions.with_message_of_kind(:custom_message).exactly 72 | end.to fail_with(%(expected #{action} not to satisfy preconditions with error messages of kind [:custom_message] but got following kind of error messages:\n [:error])) # rubocop:disable Layout/LineLength 73 | end 74 | end 75 | 76 | context 'with multiple failing preconditions' do 77 | let(:action) { Action.new(fail_precondition: true, second_fail_precondition: true) } 78 | 79 | specify { expect(action).not_to satisfy_preconditions.with_message('Precondition failed') } 80 | 81 | specify { expect(action).not_to satisfy_preconditions.with_message('Multiple failures') } 82 | 83 | context 'with `exactly`' do 84 | specify do 85 | expect do 86 | expect(action).not_to satisfy_preconditions.with_message('Precondition failed').exactly 87 | end.to fail_with(%(expected #{action} not to satisfy preconditions exactly with error messages ["Precondition failed"] but got following error messages:\n ["Precondition failed", "Multiple failures"])) # rubocop:disable Layout/LineLength 88 | end 89 | 90 | specify { expect(action).not_to satisfy_preconditions.with_message(/failed|failures/).exactly } 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/lib/granite/translations_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite::Translations do 2 | before do 3 | stub_class(:test_action, Granite::Action) do 4 | attribute :id, Integer 5 | end 6 | end 7 | 8 | describe '.combine_paths' do 9 | def combine(*args) 10 | described_class.combine_paths(*args) 11 | end 12 | 13 | it do 14 | expect(combine(%w[long short], %w[name word])).to eq(%w[long.name long.word short.name short.word]) 15 | expect(combine(['long', nil], %w[name])).to eq(%w[long.name name]) 16 | expect(combine(%w[long], ['name', nil])).to eq(%w[long.name long]) 17 | end 18 | end 19 | 20 | describe '.scope_translation_args' do 21 | def scope(*args, **options) 22 | described_class.scope_translation_args(TestAction.i18n_scopes, *args, **options) 23 | end 24 | 25 | it 'prepends translation key with action ancestor lookup scopes' do # rubocop:disable RSpec/ExampleLength 26 | expect(scope('key')).to eq(['key', { default: [] }]) 27 | expect(scope(['key'])).to eq([['key'], { default: [] }]) 28 | expect(scope('key', default: ['Default'])).to eq(['key', { default: ['Default'] }]) 29 | expect(scope('key', default: 'Default')).to eq(['key', { default: ['Default'] }]) 30 | 31 | expect(scope('.key')) 32 | .to eq([:'granite_action.test_action.key', { default: %i[granite_action.granite/action.key key] }]) 33 | expect(scope('.key', default: ['Default'])) 34 | .to eq([:'granite_action.test_action.key', 35 | { default: [:'granite_action.granite/action.key', :key, 'Default'] }]) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/lib/granite_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Granite, type: :request do 2 | extend Granite::ProjectorHelpers::ClassMethods 3 | 4 | before do 5 | stub_class(:projector, Granite::Projector) do 6 | get :confirm do 7 | render plain: 'OK' 8 | end 9 | 10 | post :perform 11 | end 12 | 13 | stub_class(:action, Granite::Action) do 14 | allow_if do 15 | raise 'No Performer' unless performer 16 | 17 | performer.id == 'User' 18 | end 19 | 20 | projector :dummy, class_name: 'Projector' 21 | end 22 | end 23 | 24 | draw_routes do 25 | resources :students do 26 | granite 'action#dummy', on: :collection 27 | end 28 | end 29 | 30 | describe '#authorize_action!' do 31 | before do 32 | allow(Granite::Form.config.logger).to receive(:info) 33 | end 34 | 35 | context 'without performer' do 36 | it 'is not allowed' do 37 | get '/students/action/confirm' 38 | 39 | expect(request.env['action_dispatch.exception'].to_s).to eq('No Performer') 40 | expect(response).to have_http_status :internal_server_error 41 | end 42 | end 43 | 44 | context 'with user as project_performer' do 45 | let(:performer) { instance_double(User, id: 'User') } 46 | 47 | it 'is allowed' do 48 | allow_any_instance_of(ApplicationController).to receive(:projector_context).and_return(performer: performer) # rubocop:disable RSpec/AnyInstance 49 | 50 | get '/students/action/confirm' 51 | 52 | expect(request.env['action_dispatch.exception'].to_s).to eq('') 53 | expect(response).to be_successful 54 | expect(response.body).to eq 'OK' 55 | end 56 | end 57 | 58 | context 'with guest as project_performer' do 59 | let(:performer) { instance_double(User, id: 'Guest') } 60 | 61 | it 'is not allowed' do 62 | allow_any_instance_of(ApplicationController).to receive(:projector_context).and_return(performer: performer) # rubocop:disable RSpec/AnyInstance 63 | 64 | get '/students/action/confirm' 65 | 66 | expect(request.env['action_dispatch.exception'].to_s).to eq('') 67 | expect(response).to have_http_status :forbidden 68 | expect(response.body).to match(/Action action is not allowed for (.*)Guest/) 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | 3 | # Add project root to load paths with or without rails 4 | # PROJECT_ROOT = File.expand_path('..', __dir__) 5 | # warn PROJECT_ROOT: PROJECT_ROOT 6 | # $LOAD_PATH << PROJECT_ROOT 7 | 8 | require_relative 'support/rails' 9 | 10 | require 'rspec' 11 | require 'rspec/its' 12 | require 'rspec/rails' 13 | require 'rspec/matchers/fail_matchers' 14 | require 'simplecov' 15 | SimpleCov.start do 16 | minimum_coverage 99.63 17 | end 18 | 19 | require 'granite' 20 | Granite.tap do |config| 21 | config.base_controller = 'ApplicationController' 22 | end 23 | 24 | require 'granite/rspec' 25 | 26 | RSpec.configure do |config| 27 | config.fail_if_no_examples = true 28 | 29 | config.order = :random 30 | 31 | config.disable_monkey_patching! 32 | 33 | # Use the documentation formatter for detailed output 34 | config.default_formatter = config.files_to_run.one? ? 'doc' : 'Fuubar' 35 | 36 | if ENV.key?('CI_NODE_INDEX') 37 | config.before(:example, :focus) { raise 'Should not commit focused specs' } 38 | else 39 | config.filter_run focus: true 40 | config.run_all_when_everything_filtered = true 41 | end 42 | 43 | config.around(:each, time_zone: ->(value) { value.present? }) do |example| 44 | Time.use_zone(example.metadata[:time_zone]) { example.run } 45 | end 46 | 47 | config.include RSpec::Matchers::FailMatchers, file_path: %r{spec/lib/granite/rspec/} 48 | 49 | config.expect_with :rspec do |c| 50 | c.max_formatted_output_length = nil 51 | end 52 | end 53 | 54 | Dir['./spec/support/**/*.rb'].sort.each { |f| require f } 55 | -------------------------------------------------------------------------------- /spec/support/class_helpers.rb: -------------------------------------------------------------------------------- 1 | module ClassHelpers 2 | def stub_model_class(name, superclass = nil, &block) 3 | stub_class(name, superclass || ApplicationRecord, &block) 4 | end 5 | 6 | def stub_class(name, superclass = nil, &block) 7 | context = self 8 | stub_const(name.to_s.camelize, Class.new(superclass || Object)).tap do |klass| 9 | # The name of a class is set by the interpreter when the class object is 10 | # first assigned to a constant. For example, in 11 | # 12 | # c = Class.new 13 | # C = c 14 | # 15 | # the class in the c variable is anonymous at first, and it is after the 16 | # second line that the interpreter sets the name to "C" as a side-effect 17 | # of the constant assignment. 18 | # 19 | # If we passed the block to stub_const, the class would be still anonymous 20 | # while the block is executed. This is not convenient for the typical use 21 | # cases of this method, because if an anonymous class is fine you do not 22 | # need to stub a constant, Class.new would be enough. 23 | # 24 | # So, we get the constant assignment first with stub_const, and then the 25 | # block is eval'ed with the class name already in place. 26 | # 27 | # Also, you can pass self as a block argument. Example: 28 | # 29 | # let(:a) { 1 } 30 | # ... 31 | # stub_class(:my_super_class) do |context| 32 | # define_method(:a) 33 | # context.a 34 | # end 35 | # end 36 | # 37 | # MySuperClass.new.a => 1 38 | # 39 | # Please be sure that you aren't using keywords like `def` or `class` 40 | # because they will change the scope and you won't be able to get 41 | # an access to variables created by `let`. 42 | # Use `define_method` or `Class.new` instead 43 | klass.class_exec(context, &block) if block_given? 44 | end 45 | end 46 | end 47 | 48 | RSpec.configuration.include ClassHelpers 49 | -------------------------------------------------------------------------------- /spec/support/data.rb: -------------------------------------------------------------------------------- 1 | RSpec.shared_context 'with student data' do 2 | let(:passed_student) { Student.new(status: 'passed') } 3 | let(:failed_student) { Student.new(status: 'failed') } 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/database.yml.example: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: postgresql 3 | database: granite 4 | username: granite 5 | password: granite 6 | host: localhost 7 | -------------------------------------------------------------------------------- /spec/support/matchers/negated.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define_negated_matcher :not_change, :change 2 | -------------------------------------------------------------------------------- /spec/support/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /spec/support/models/role.rb: -------------------------------------------------------------------------------- 1 | require_relative 'application_record' 2 | 3 | class Role < ApplicationRecord 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/models/student.rb: -------------------------------------------------------------------------------- 1 | require_relative 'role' 2 | 3 | class Student < Role 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/models/teacher.rb: -------------------------------------------------------------------------------- 1 | require_relative 'role' 2 | 3 | class Teacher < Role 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/models/user.rb: -------------------------------------------------------------------------------- 1 | require_relative 'application_record' 2 | 3 | class User < ApplicationRecord 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/rails.rb: -------------------------------------------------------------------------------- 1 | require 'rails/all' 2 | 3 | class ApplicationController < ActionController::Base 4 | rescue_from 'Granite::Action::NotAllowedError' do |exception| 5 | render plain: exception.to_s, status: :forbidden 6 | end 7 | end 8 | 9 | class GraniteApplication < Rails::Application 10 | end 11 | 12 | Rails.application = GraniteApplication.new 13 | Rails.application.paths['config/database'] << File.expand_path('database.yml', __dir__) 14 | if Rails.version > '7.1' 15 | Rails.application.config.secret_key_base = '1234567890' 16 | else 17 | Rails.application.secrets.secret_key_base = '1234567890' 18 | end 19 | Rails.application.config.hosts = 'www.example.com' 20 | Rails.application.routes_reloader.route_sets << Rails.application.routes 21 | Rails.configuration.eager_load = false 22 | Rails.application.initialize! 23 | -------------------------------------------------------------------------------- /spec/support/schema.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | 3 | ActiveRecord::Schema.define(version: 2017_11_16_12_20_01) do # rubocop:disable Style/NumericLiterals 4 | create_table :roles, force: :cascade do |t| 5 | t.string :status 6 | t.timestamps 7 | end 8 | 9 | create_table :users, force: :cascade do |t| 10 | t.string :email 11 | t.string :full_name 12 | t.integer :sign_in_count 13 | t.integer :related_ids, array: true, default: [] 14 | t.timestamps 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/translations.rb: -------------------------------------------------------------------------------- 1 | I18n.backend.store_translations(:en, YAML.safe_load(<<-YAML)) 2 | dummy: 3 | other_key: 'dummy projector other key' 4 | granite_action: 5 | errors: 6 | messages: 7 | action_invalid: "Name can't be blank" 8 | models: 9 | action: 10 | attributes: 11 | base: 12 | message: 'Base error message' 13 | wrong_title: 'Wrong title' 14 | wrong_author: 'George Orwell is the only acceptable author, %s is not' 15 | dummy_action: 16 | key: 'dummy action key' 17 | dummy: 18 | key: 'dummy action dummy projector key' 19 | result: 20 | key: 'dummy action dummy projector result key' 21 | YAML 22 | --------------------------------------------------------------------------------