diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a33009f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + name: Lint + steps: + - uses: actions/checkout@v5 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4 + bundler-cache: true + - name: Run Standard RB + run: bundle exec standardrb + + build: + runs-on: ubuntu-latest + name: Ruby ${{ matrix.ruby }} - Rails ${{ matrix.rails }} + strategy: + matrix: + ruby: [3.2, 3.3, 3.4] + rails: [rails_7.0, rails_7.1, rails_8.0] + env: + RUBY_VERSION: ${{ matrix.ruby }} + RAILS_VERSION: ${{ matrix.rails }} + steps: + - uses: actions/checkout@v5 + - name: Set up Ruby ${{ matrix.ruby }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Install dependencies + run: bundle exec appraisal install + - name: Run specs + run: bundle exec appraisal ${{ matrix.rails }} rspec diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..3a2e34d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,36 @@ +name: Publish Gem + +on: + release: + types: [published] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + - name: Release Gem + if: contains(github.ref, 'refs/tags/v') + env: + RUBYGEMS_API_KEY: ${{secrets.RUBYGEMS_API_KEY}} + TAG: ${{ github.event.release.tag_name }} + run: | + echo "Setting up gem credentials..." + mkdir -p ~/.gem + + cat << EOF > ~/.gem/credentials + --- + :rubygems_api_key: ${RUBYGEMS_API_KEY} + EOF + + chmod 0600 ~/.gem/credentials + + bundle exec rake build + + echo "Running gem release task..." + gem push pkg/typical_situation-${TAG#v}.gem diff --git a/.gitignore b/.gitignore index 3b88694..96542b7 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ pickle-email-*.html .byebug_history *.sqlite3 *.sqlite -*.log \ No newline at end of file +*.log + +pkg/**/* diff --git a/.standard.yml b/.standard.yml new file mode 100644 index 0000000..eeb43ab --- /dev/null +++ b/.standard.yml @@ -0,0 +1,12 @@ +# Standard Ruby configuration +# https://github.com/testdouble/standard + +ruby_version: 3.4 + +ignore: + - 'spec/internal/**/*' + - 'pkg/**/*' + - 'vendor/**/*' + - 'tmp/**/*' + +fix: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 1b45396..0000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -os: linux -language: ruby -cache: bundler -before_install: - - gem uninstall -v '>= 2' -i $(rvm gemdir)@global -ax bundler || true - - gem install bundler -v '< 2' - - gem cleanup bundler -script: "bundle exec rspec" -rvm: - - 2.6 - - 2.5 - - 2.4 - - 2.3 -gemfile: - - gemfiles/rails_4.2.gemfile - - gemfiles/rails_5.2.gemfile - - gemfiles/rails_6.0.gemfile -jobs: - exclude: - - rvm: 2.3 - gemfile: gemfiles/rails_6.0.gemfile - - rvm: 2.4 - gemfile: gemfiles/rails_6.0.gemfile - - rvm: 2.6 - gemfile: gemfiles/rails_4.2.gemfile diff --git a/Appraisals b/Appraisals index 43335c8..c2be193 100644 --- a/Appraisals +++ b/Appraisals @@ -1,18 +1,25 @@ # frozen_string_literal: true -appraise 'rails-4.2' do - gem 'rails', '~> 4.2' - gem 'rails-forward_compatible_controller_tests', require: false +appraise "rails_7.0" do + gem "rails", "~> 7.0" + gem "rspec", "~> 3.12" + gem "rspec-rails", "~> 6.0" + gem "rails-controller-testing" + gem "sqlite3", "~> 1.4" end -appraise 'rails-5.2' do - gem 'rails', '~> 5.2' - gem 'rails-controller-testing' +appraise "rails_7.1" do + gem "rails", "~> 7.1" + gem "rspec", "~> 3.12" + gem "rspec-rails", "~> 6.1" + gem "rails-controller-testing" + gem "sqlite3", "~> 1.4" end -appraise 'rails-6.0' do - gem 'rails', '~> 6.0' - gem 'sqlite3', '~> 1.4' - gem 'rails-controller-testing' - gem 'rspec-rails', '4.0.0.beta3' +appraise "rails_8.0" do + gem "rails", "~> 8.0" + gem "rspec", "~> 3.13" + gem "rspec-rails", "~> 8.0" + gem "rails-controller-testing" + gem "sqlite3", "~> 2.1" end diff --git a/Gemfile b/Gemfile index 94b8df6..3d3754b 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ # frozen_string_literal: true -source 'http://rubygems.org' +source "http://rubygems.org" # Declare your gem's dependencies in typical_situation.gemspec. # Bundler will treat runtime dependencies like base dependencies, and diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index b16a591..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,199 +0,0 @@ -PATH - remote: . - specs: - typical_situation (0.11.1) - rails (>= 4.0.0) - -GEM - remote: http://rubygems.org/ - specs: - actioncable (6.0.1) - actionpack (= 6.0.1) - nio4r (~> 2.0) - websocket-driver (>= 0.6.1) - actionmailbox (6.0.1) - actionpack (= 6.0.1) - activejob (= 6.0.1) - activerecord (= 6.0.1) - activestorage (= 6.0.1) - activesupport (= 6.0.1) - mail (>= 2.7.1) - actionmailer (6.0.1) - actionpack (= 6.0.1) - actionview (= 6.0.1) - activejob (= 6.0.1) - mail (~> 2.5, >= 2.5.4) - rails-dom-testing (~> 2.0) - actionpack (6.0.1) - actionview (= 6.0.1) - activesupport (= 6.0.1) - rack (~> 2.0) - rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.0.1) - actionpack (= 6.0.1) - activerecord (= 6.0.1) - activestorage (= 6.0.1) - activesupport (= 6.0.1) - nokogiri (>= 1.8.5) - actionview (6.0.1) - activesupport (= 6.0.1) - builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.0.1) - activesupport (= 6.0.1) - globalid (>= 0.3.6) - activemodel (6.0.1) - activesupport (= 6.0.1) - activerecord (6.0.1) - activemodel (= 6.0.1) - activesupport (= 6.0.1) - activestorage (6.0.1) - actionpack (= 6.0.1) - activejob (= 6.0.1) - activerecord (= 6.0.1) - marcel (~> 0.3.1) - activesupport (6.0.1) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 0.7, < 2) - minitest (~> 5.1) - tzinfo (~> 1.1) - zeitwerk (~> 2.2) - appraisal (2.2.0) - bundler - rake - thor (>= 0.14.0) - builder (3.2.3) - byebug (11.0.1) - combustion (1.1.2) - activesupport (>= 3.0.0) - railties (>= 3.0.0) - thor (>= 0.14.6) - concurrent-ruby (1.1.5) - coveralls (0.8.23) - json (>= 1.8, < 3) - simplecov (~> 0.16.1) - term-ansicolor (~> 1.3) - thor (>= 0.19.4, < 2.0) - tins (~> 1.6) - crass (1.0.5) - diff-lcs (1.3) - docile (1.3.2) - erubi (1.9.0) - factory_bot (4.8.2) - activesupport (>= 3.0.0) - factory_bot_rails (4.8.2) - factory_bot (~> 4.8.2) - railties (>= 3.0.0) - globalid (0.4.2) - activesupport (>= 4.2.0) - i18n (1.7.0) - concurrent-ruby (~> 1.0) - json (2.2.0) - loofah (2.3.1) - crass (~> 1.0.2) - nokogiri (>= 1.5.9) - mail (2.7.1) - mini_mime (>= 0.1.1) - marcel (0.3.3) - mimemagic (~> 0.3.2) - method_source (0.9.2) - mimemagic (0.3.3) - mini_mime (1.0.2) - mini_portile2 (2.4.0) - minitest (5.13.0) - nio4r (2.5.2) - nokogiri (1.10.5) - mini_portile2 (~> 2.4.0) - rack (2.0.7) - rack-test (1.1.0) - rack (>= 1.0, < 3) - rails (6.0.1) - actioncable (= 6.0.1) - actionmailbox (= 6.0.1) - actionmailer (= 6.0.1) - actionpack (= 6.0.1) - actiontext (= 6.0.1) - actionview (= 6.0.1) - activejob (= 6.0.1) - activemodel (= 6.0.1) - activerecord (= 6.0.1) - activestorage (= 6.0.1) - activesupport (= 6.0.1) - bundler (>= 1.3.0) - railties (= 6.0.1) - sprockets-rails (>= 2.0.0) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) - nokogiri (>= 1.6) - rails-html-sanitizer (1.3.0) - loofah (~> 2.3) - railties (6.0.1) - actionpack (= 6.0.1) - activesupport (= 6.0.1) - method_source - rake (>= 0.8.7) - thor (>= 0.20.3, < 2.0) - rake (13.0.1) - rspec-core (3.9.0) - rspec-support (~> 3.9.0) - rspec-expectations (3.9.0) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.9.0) - rspec-mocks (3.9.0) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.9.0) - rspec-rails (3.9.0) - actionpack (>= 3.0) - activesupport (>= 3.0) - railties (>= 3.0) - rspec-core (~> 3.9.0) - rspec-expectations (~> 3.9.0) - rspec-mocks (~> 3.9.0) - rspec-support (~> 3.9.0) - rspec-support (3.9.0) - simplecov (0.16.1) - docile (~> 1.1) - json (>= 1.8, < 3) - simplecov-html (~> 0.10.0) - simplecov-html (0.10.2) - sprockets (4.0.0) - concurrent-ruby (~> 1.0) - rack (> 1, < 3) - sprockets-rails (3.2.1) - actionpack (>= 4.0) - activesupport (>= 4.0) - sprockets (>= 3.0.0) - sqlite3 (1.3.13) - term-ansicolor (1.7.1) - tins (~> 1.0) - thor (0.20.3) - thread_safe (0.3.6) - tins (1.22.2) - tzinfo (1.2.5) - thread_safe (~> 0.1) - websocket-driver (0.7.1) - websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.4) - zeitwerk (2.2.1) - -PLATFORMS - ruby - -DEPENDENCIES - appraisal - bundler (~> 1.0) - byebug - combustion - coveralls - factory_bot_rails - rake - rspec-rails - sqlite3 (~> 1.3.6) - typical_situation! - -BUNDLED WITH - 1.17.1 diff --git a/README.md b/README.md index c9b1bc2..6c13d36 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Typical Situation [![Build Status](https://travis-ci.org/apsislabs/typical_situation.svg?branch=master)](https://travis-ci.org/apsislabs/typical_situation) +# Typical Situation [![Spec CI](https://github.com/apsislabs/typical_situation/workflows/Spec%20CI/badge.svg)](https://github.com/apsislabs/typical_situation/actions) The missing Ruby on Rails ActionController REST API mixin. @@ -8,71 +8,92 @@ A Ruby mixin (module) providing the seven standard resource actions & responses Tested in: -- Rails 4.2 -- Rails 5.2 -- Rails 6.0 +- Rails 7.0 +- Rails 7.1 +- Rails 8.0 Against Ruby versions: -- 2.2 -- 2.3 -- 2.4 -- 2.5 +- 3.2 +- 3.3 +- 3.4 Add to your **Gemfile**: gem 'typical_situation' -**Rails 3.2**: For Rails 3.2 support, see https://github.com/mars/typical_situation +**Legacy Versions**: For Rails 4.x/5.x/6.x support, see older versions of this gem. Ruby 3.0+ is required. ## Usage -### Define three methods +### Define your model and methods - class MockApplePiesController < ApplicationController - include TypicalSituation +Basic usage is to declare the `typical_situation`, and then two required helper methods. Everything else is handled automatically. - # Symbolized, underscored version of the model (class) to use as the resource. - def model_type - :mock_apple_pie - end +```rb +class PostsController < ApplicationController + include TypicalSituation - # The collection of model instances. - def collection - current_user.mock_apple_pies - end + # Symbolized, underscored version of the model to use as the resource. + typical_situation :post # => maps to the Post model - # Find a model instance by ID. - def find_in_collection(id) - collection.find_by_id(id) - end - end + private + + # The collection of model instances. + def collection + current_user.posts + end + + # Find a model instance by ID. + def find_in_collection(id) + collection.find_by_id(id) + end +end +``` + +There are two alternative helper methods: + +#### Typical REST + +The typical REST helper is an alias for `typical_situation`, and defines the 7 standard REST endpoints: `index`, `show`, `new`, `create`, `edit`, `update`, `destroy`. + +```rb +class PostsController < ApplicationController + include TypicalSituation + + typical_rest :post + + ... +end +``` + +#### Typical CRUD + +Sometimes you don't need all seven endpoints, and just need standard CRUD. The typical CRUD helper defines the 4 standard CRUD endpoints: `create`, `show`, `update`, `destroy`. + +```rb +class PostsController < ApplicationController + include TypicalSituation -### Get a fully functional REST API + typical_crud :post -The seven standard resourceful actions: + ... +end +``` -1. **index** -2. **show** -3. **new** -4. **create** -5. **edit** -6. **update** -7. **delete** +#### Customizing defined endpoints -For the content types: +You can also define only the endpoints you want by passing an `only` flag to `typical_situation`: -- **HTML** -- **JSON** +```rb +class PostsController < ApplicationController + include TypicalSituation -With response handling for: + typical_situation :post, only: [:index, :show] -- the collection -- a single instance -- not found -- validation errors (using ActiveModel::Errors format) -- changed -- deleted/gone + ... +end +``` ### Customize by overriding highly composable methods @@ -83,81 +104,302 @@ The library is split into modules: - [identity](https://github.com/mars/typical_situation/blob/master/lib/typical_situation/identity.rb) - **required definitions** of the model & how to find it - [actions](https://github.com/mars/typical_situation/blob/master/lib/typical_situation/actions.rb) - high-level controller actions - [operations](https://github.com/mars/typical_situation/blob/master/lib/typical_situation/operations.rb) - loading, changing, & persisting the model +- [permissions](https://github.com/mars/typical_situation/blob/master/lib/typical_situation/permissions.rb) - handling authorization to records and actions - [responses](https://github.com/mars/typical_situation/blob/master/lib/typical_situation/responses.rb) - HTTP responses & redirects +#### Common Customization Hooks + +**Scoped Collections** - Filter the collection based on user permissions or other criteria: + +```ruby +def scoped_resource + if current_user.admin? + collection + else + collection.where(published: true) + end +end +``` + +**Custom Lookup** - Use different attributes for finding resources: + +```ruby +def find_resource(param) + collection.find_by!(slug: param) +end +``` + +**Custom Redirects** - Control where users go after actions: + +```ruby +def after_resource_created_path(resource) + { action: :index } +end + +def after_resource_updated_path(resource) + edit_resource_path(resource) +end + +def after_resource_destroyed_path(resource) + { action: :index } +end +``` + +**Sorting** - Set default sorting for index pages: + +```ruby +def default_sorting_attribute + :created_at +end + +def default_sorting_direction + :desc +end +``` + +**Pagination** - Bring your own pagination solution: + +```ruby +# Kaminari +def paginate_resources(resources) + resources.page(params[:page]).per(params[:per_page] || 25) +end + +# will_paginate +def paginate_resources(resources) + resources.paginate(page: params[:page], per_page: params[:per_page] || 25) +end + + # Custom pagination + def paginate_resources(resources) + resources.limit(20).offset((params[:page].to_i - 1) * 20) +end +``` + +**Strong Parameters** - Control which parameters are allowed for create and update operations: + +```ruby +class PostsController < ApplicationController + include TypicalSituation + typical_situation :post + + private + + # Only allow title and content for new posts + def permitted_create_params + [:title, :content] + end + + # Allow title, content, and published for updates + def permitted_update_params + [:title, :content, :published] + end +end +``` + +By default, `TypicalSituation` permits all parameters (`permit!`) when these methods return `nil` or an empty array. Override them to restrict parameters for security. + +#### Authorization + +Control access to resources by overriding the `authorized?` method: + +```rb +class PostsController < ApplicationController + include TypicalSituation + typical_situation :post + + private + + def authorized?(action, resource = nil) + case action + when :destroy, :update, :edit + resource&.user == current_user || current_user&.admin? + when :show + resource&.published? || resource&.user == current_user + else + true + end + end +end +``` + +You can also customize the response when authorization is denied: + +```rb +def respond_as_forbidden + redirect_to login_path, alert: "Access denied" +end +``` + +##### CanCanCan + +```rb +def authorized?(action, resource = nil) + can?(action, resource || model_class) +end +``` + +##### Pundit + +```rb +def authorized?(action, resource = nil) + policy(resource || model_class).public_send("#{action}?") +end +``` + #### Serialization Under the hood `TypicalSituation` calls `to_json` on your `ActiveRecord` models. This isn't always the optimal way to serialize resources, though, and so `TypicalSituation` offers a simple means of overriding the base Serialization --- either on an individual controller, or for your entire application. -##### ActiveModelSerializers +##### Alba -To use `ActiveModelSerializers`, add an file an initializer called `typical_situation.rb` and override the `Operations` module: +```rb +class MockApplePieResource + include Alba::Resource - module TypicalSituation - module Operations - def serializable_resource(resource) - ActiveModelSerializers::SerializableResource.new(resource) - end - end - end + attributes :id, :ingredients + + association :grandma, resource: GrandmaResource +end -If you'd like to use different serializers per method, you can check `action_name` to determine your current controller endpoint. +class MockApplePiesController < ApplicationController + include TypicalSituation + typical_situation :mock_apple_pie - class MockApplePieIndexSerializer < ActiveModel::Serializer - attributes :id, :ingredients - end + private - module TypicalSituation - module Operations - def serializable_resource(resource) - if action_name == "index" - ActiveModelSerializers::SerializableResource.new( - resource, - each_serializer: MockApplePieIndexSerializer - ) - else - ActiveModelSerializers::SerializableResource.new(resource) - end - end - end - end - -##### Blueprinter + def serializable_resource(resource) + MockApplePieResource.new(resource).serialize + end -`Blueprinter` relies on calling a specific blueprint, it is better suited to being overriden at the controller level. To do so, in your controller file, simply override the `serializable_resource` method as below: + def collection + current_user.mock_apple_pies + end - class MockApplePieBlueprint < Blueprinter::Base - identifier :id - fields :ingredients - association :grandma, blueprint: GrandmaBlueprint - end + def find_in_collection(id) + collection.find_by_id(id) + end +end +``` - class MockApplePiesController < ApplicationController - include TypicalSituation +##### ActiveModelSerializers - def serializable_resource(resource) - MockApplePieBlueprint.render(resource) +```rb +class MockApplePieIndexSerializer < ActiveModel::Serializer + attributes :id, :ingredients +end + +module TypicalSituation + module Operations + def serializable_resource(resource) + if action_name == "index" + ActiveModelSerializers::SerializableResource.new( + resource, + each_serializer: MockApplePieIndexSerializer + ) + else + ActiveModelSerializers::SerializableResource.new(resource) end end + end +end +``` ###### Fast JSON API -Like `Blueprinter`, +```rb +class MockApplePieSerializer + include FastJsonapi::ObjectSerializer + attributes :ingredients + belongs_to :grandma +end - class MockApplePieSerializer - include FastJsonapi::ObjectSerializer - attributes :ingredients - belongs_to :grandma - end +class MockApplePiesController < ApplicationController + include TypicalSituation - class MockApplePiesController < ApplicationController - include TypicalSituation + def serializable_resource(resource) + MockApplePieSerializer.new(resource).serializable_hash + end +end +``` - def serializable_resource(resource) - MockApplePieSerializer.new(resource).serializable_hash - end - end +## Development + +After checking out the repo, run `bin/setup` to install dependencies. + +### Local Setup + +1. Clone the repository +2. Install dependencies: + ```bash + bundle install + ``` +3. Install appraisal gemfiles for testing across Rails versions: + ```bash + bundle exec appraisal install + ``` + +### Running Tests + +Tests are written using [RSpec](https://rspec.info/) and are setup to use [Appraisal](https://github.com/thoughtbot/appraisal) to run tests over multiple Rails versions. + +Run all tests across all supported Rails versions: +```bash +bundle exec appraisal rspec +``` + +Run tests for a specific Rails version: +```bash +bundle exec appraisal rails_7.0 rspec +bundle exec appraisal rails_7.1 rspec +bundle exec appraisal rails_8.0 rspec +``` + +Run specific test files: +```bash +bundle exec rspec spec/path/to/spec.rb +bundle exec appraisal rails_7.0 rspec spec/path/to/spec.rb +``` + +### Linting and Formatting + +This project uses [Standard Ruby](https://github.com/testdouble/standard) for code formatting and linting. + +Check for style violations: +```bash +bundle exec standardrb +``` + +Automatically fix style violations: +```bash +bundle exec standardrb --fix +``` + +Run both linting and tests (the default rake task): +```bash +bundle exec rake +``` + +### Console + +Start an interactive console to experiment with the gem: +```bash +bundle exec irb -r typical_situation +``` + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/apsislabs/typical_situation. + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). + + +--- + +# Built by Apsis -## Legalese +[![apsis](https://s3-us-west-2.amazonaws.com/apsiscdn/apsis.png)](https://www.apsis.io) -This project uses MIT-LICENSE. +`typical_situation` was built by Apsis Labs. We love sharing what we build! Check out our [other libraries on Github](https://github.com/apsislabs), and if you like our work you can [hire us](https://www.apsis.io) to build your vision. diff --git a/Rakefile b/Rakefile index 82bb534..faea475 100755 --- a/Rakefile +++ b/Rakefile @@ -1,8 +1,14 @@ # frozen_string_literal: true -require 'bundler/gem_tasks' -require 'rspec/core/rake_task' +require "bundler/gem_tasks" +require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) -task default: :spec +begin + require "standard/rake" +rescue LoadError + # Standard not available +end + +task default: [:standard, :spec] diff --git a/config.ru b/config.ru index e3007f5..1265db9 100644 --- a/config.ru +++ b/config.ru @@ -1,7 +1,7 @@ # frozen_string_literal: true -require 'rubygems' -require 'bundler' +require "rubygems" +require "bundler" Bundler.require :default, :development diff --git a/gemfiles/rails_4.2.gemfile b/gemfiles/rails_4.2.gemfile deleted file mode 100644 index 0701be9..0000000 --- a/gemfiles/rails_4.2.gemfile +++ /dev/null @@ -1,8 +0,0 @@ -# This file was generated by Appraisal - -source "http://rubygems.org" - -gem "rails", "~> 4.2" -gem "rails-forward_compatible_controller_tests", require: false - -gemspec path: "../" diff --git a/gemfiles/rails_6.0.gemfile b/gemfiles/rails_7.0.gemfile similarity index 66% rename from gemfiles/rails_6.0.gemfile rename to gemfiles/rails_7.0.gemfile index b49d5b7..8b23c5a 100644 --- a/gemfiles/rails_6.0.gemfile +++ b/gemfiles/rails_7.0.gemfile @@ -2,9 +2,10 @@ source "http://rubygems.org" -gem "rails", "~> 6.0" -gem "sqlite3", "~> 1.4" +gem "rails", "~> 7.0" +gem "rspec", "~> 3.12" +gem "rspec-rails", "~> 6.0" gem "rails-controller-testing" -gem "rspec-rails", "4.0.0.beta3" +gem "sqlite3", "~> 1.4" gemspec path: "../" diff --git a/gemfiles/rails_5.2.gemfile b/gemfiles/rails_7.1.gemfile similarity index 55% rename from gemfiles/rails_5.2.gemfile rename to gemfiles/rails_7.1.gemfile index 6a66f94..331319d 100644 --- a/gemfiles/rails_5.2.gemfile +++ b/gemfiles/rails_7.1.gemfile @@ -2,7 +2,10 @@ source "http://rubygems.org" -gem "rails", "~> 5.2" +gem "rails", "~> 7.1" +gem "rspec", "~> 3.12" +gem "rspec-rails", "~> 6.1" gem "rails-controller-testing" +gem "sqlite3", "~> 1.4" gemspec path: "../" diff --git a/gemfiles/rails_8.0.gemfile b/gemfiles/rails_8.0.gemfile new file mode 100644 index 0000000..786aa5c --- /dev/null +++ b/gemfiles/rails_8.0.gemfile @@ -0,0 +1,11 @@ +# This file was generated by Appraisal + +source "http://rubygems.org" + +gem "rails", "~> 8.0" +gem "rspec", "~> 3.13" +gem "rspec-rails", "~> 8.0" +gem "rails-controller-testing" +gem "sqlite3", "~> 2.1" + +gemspec path: "../" diff --git a/lib/typical_situation.rb b/lib/typical_situation.rb index d45f6ef..4d5a1a6 100644 --- a/lib/typical_situation.rb +++ b/lib/typical_situation.rb @@ -1,23 +1,67 @@ # frozen_string_literal: true -require 'typical_situation/identity' -require 'typical_situation/actions' -require 'typical_situation/operations' -require 'typical_situation/responses' +require "typical_situation/identity" +require "typical_situation/permissions" +require "typical_situation/actions" +require "typical_situation/operations" +require "typical_situation/responses" module TypicalSituation + class Error < StandardError; end + class ActionForbidden < Error; end + include Identity - include Actions + include Permissions include Operations include Responses def self.included(base) add_rescues(base) + base.extend(ClassMethods) + end + + module ClassMethods + # Syntactic sugar for defining model_type + # + # Example: + # class PostsController < ApplicationController + # include TypicalSituation + # typical_situation :post + # end + # + # This is equivalent to: + # def model_type + # :post + # end + def typical_situation(model_type_symbol, only: nil) + define_method :model_type do + model_type_symbol + end + + if only + only.each do |action| + if TypicalSituation::Actions.method_defined?(action) + define_method(action, TypicalSituation::Actions.instance_method(action)) + end + end + else + include TypicalSituation::Actions + end + end + + def typical_rest(model_type_symbol) + typical_situation(model_type_symbol, only: nil) + end + + def typical_crud(model_type_symbol) + typical_situation(model_type_symbol, only: %i[create show update destroy]) + end end def self.add_rescues(action_controller) action_controller.class_eval do rescue_from ActiveRecord::RecordNotFound, with: :respond_as_not_found + rescue_from TypicalSituation::ActionForbidden, with: :respond_as_forbidden end end end diff --git a/lib/typical_situation/actions.rb b/lib/typical_situation/actions.rb index 0108b65..8b641e0 100644 --- a/lib/typical_situation/actions.rb +++ b/lib/typical_situation/actions.rb @@ -4,38 +4,52 @@ module TypicalSituation # Standard REST/CRUD actions. module Actions def index + raise TypicalSituation::ActionForbidden unless authorized?(:index) + get_resources respond_with_resources end def show get_resource + raise TypicalSituation::ActionForbidden unless authorized?(:show, @resource) + respond_with_resource end def edit get_resource + raise TypicalSituation::ActionForbidden unless authorized?(:edit, @resource) + respond_with_resource end def new + raise TypicalSituation::ActionForbidden unless authorized?(:new) + new_resource respond_with_resource end def update get_resource + raise TypicalSituation::ActionForbidden unless authorized?(:update, @resource) + update_resource(@resource, update_params) respond_as_changed end def destroy get_resource + raise TypicalSituation::ActionForbidden unless authorized?(:destroy, @resource) + destroy_resource(@resource) respond_as_gone end def create + raise TypicalSituation::ActionForbidden unless authorized?(:create) + @resource = create_resource(create_params) respond_as_created end diff --git a/lib/typical_situation/identity.rb b/lib/typical_situation/identity.rb index 803aacc..115c867 100644 --- a/lib/typical_situation/identity.rb +++ b/lib/typical_situation/identity.rb @@ -5,7 +5,7 @@ module TypicalSituation module Identity # Symbolized, underscored version of the model (class) to use. def model_type - raise(NotImplementedError, '#model_type must be defined in the TypicalSituation implementation.') + raise(NotImplementedError, "#model_type must be defined in the TypicalSituation implementation.") end def model_params @@ -38,12 +38,12 @@ def permitted_update_params # The collection of model instances. def collection - raise(NotImplementedError, '#collection must be defined in the TypicalSituation implementation.') + raise(NotImplementedError, "#collection must be defined in the TypicalSituation implementation.") end # Find a model instance by ID. def find_in_collection(_id) - raise(NotImplementedError, '#find_in_collection must be defined in the TypicalSituation implementation.') + raise(NotImplementedError, "#find_in_collection must be defined in the TypicalSituation implementation.") end def include_root? diff --git a/lib/typical_situation/operations.rb b/lib/typical_situation/operations.rb index 448bb66..41601af 100644 --- a/lib/typical_situation/operations.rb +++ b/lib/typical_situation/operations.rb @@ -4,8 +4,32 @@ module TypicalSituation # Model operations. # Assume that we're working w/ an ActiveRecord association collection. module Operations + def scoped_resource + collection + end + + def find_resource(param) + find_in_collection(param) + end + + def default_sorting_attribute + nil + end + + def default_sorting_direction + :asc + end + + def paginate_resources(resources) + resources + end + + def pagination_params + params.permit(:page, :per_page) + end + def get_resource - if (@resource = find_in_collection(params[:id])) + if (@resource = find_resource(params[:id])) set_single_instance @resource else @@ -18,7 +42,7 @@ def has_errors? end def get_resources - @resources = collection + @resources = paginate_resources(apply_sorting(scoped_resource)) set_collection_instance @resources end @@ -59,7 +83,7 @@ def serialize_resource(resource, options = {}) def serialize_resources(resources) if include_root? - return { plural_model_type => serializable_resource(resources) } + return {plural_model_type => serializable_resource(resources)} end serializable_resource(resources).to_json(root: false) @@ -72,13 +96,20 @@ def serializable_resource(resource) # Set the singular instance variable named after the model. Modules are delimited with "_". # Example: a MockApplePie resource is set to ivar @mock_apple_pie. def set_single_instance - instance_variable_set(:"@#{model_type.to_s.gsub('/', '__')}", @resource) + instance_variable_set(:"@#{model_type.to_s.gsub("/", "__")}", @resource) end # Set the plural instance variable named after the model. Modules are delimited with "_". # Example: a MockApplePie resource collection is set to ivar @mock_apple_pies. def set_collection_instance - instance_variable_set(:"@#{model_type.to_s.gsub('/', '__').pluralize}", @resources) + instance_variable_set(:"@#{model_type.to_s.gsub("/", "__").pluralize}", @resources) + end + + private + + def apply_sorting(resources) + return resources unless default_sorting_attribute + resources.order(default_sorting_attribute => default_sorting_direction) end end end diff --git a/lib/typical_situation/permissions.rb b/lib/typical_situation/permissions.rb new file mode 100644 index 0000000..367236b --- /dev/null +++ b/lib/typical_situation/permissions.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module TypicalSituation + module Permissions + def authorized?(_action, _resource = nil) + true + end + end +end diff --git a/lib/typical_situation/responses.rb b/lib/typical_situation/responses.rb index e866ccf..4cd9fbe 100644 --- a/lib/typical_situation/responses.rb +++ b/lib/typical_situation/responses.rb @@ -68,8 +68,8 @@ def respond_as_created end format.json do render json: serialize_resource(@resource), - location: location_url, - status: :created + location: location_url, + status: :created end end end @@ -82,11 +82,11 @@ def respond_as_error format.html do set_single_instance render action: (@resource.new_record? ? :new : :edit), - status: :unprocessable_entity + status: :unprocessable_entity end format.json do render json: serialize_resource(@resource, methods: [:errors]), - status: :unprocessable_entity + status: :unprocessable_entity end end end @@ -114,7 +114,7 @@ def respond_as_not_found yield(format) if block_given? format.html do - raise ActionController::RoutingError, 'Not Found' + raise ActionController::RoutingError, "Not Found" end format.json do head :not_found @@ -122,16 +122,35 @@ def respond_as_not_found end end + def respond_as_forbidden + respond_to do |format| + format.html { render plain: "Forbidden", status: :forbidden } + format.json { head :forbidden } + end + end + + def after_resource_created_path(resource) + {action: :show, id: resource.id} + end + + def after_resource_updated_path(resource) + {action: :show, id: resource.id} + end + + def after_resource_destroyed_path(_resource) + {action: :index} + end + # HTML response when @resource saved or updated. def changed_so_redirect - redirect_to action: :show, id: @resource.to_param - true # return true when redirecting + redirect_to after_resource_updated_path(@resource) + true end # HTML response when @resource deleted. def gone_so_redirect - redirect_to action: :index - true # return true when redirecting + redirect_to after_resource_destroyed_path(@resource) + true end end end diff --git a/lib/typical_situation/version.rb b/lib/typical_situation/version.rb index 287744e..c5fb55b 100644 --- a/lib/typical_situation/version.rb +++ b/lib/typical_situation/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module TypicalSituation - VERSION = '0.11.2' + VERSION = "1.0.0" end diff --git a/spec/controllers/mock_apple_pies_controller_spec.rb b/spec/controllers/mock_apple_pies_controller_spec.rb index 532d59e..e9cf0b8 100644 --- a/spec/controllers/mock_apple_pies_controller_spec.rb +++ b/spec/controllers/mock_apple_pies_controller_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'spec_helper' +require "spec_helper" PIES_COUNT = 5 @@ -12,9 +12,9 @@ let(:pie) { @grandma.mock_apple_pies.first } - describe 'GET #index' do - context 'html' do - it 'renders the index template' do + describe "GET #index" do + context "html" do + it "renders the index template" do get :index expect(response).to have_http_status :ok @@ -24,29 +24,29 @@ end end - context 'json' do - it 'renders index JSON' do + context "json" do + it "renders index JSON" do get :index, format: :json response_body = JSON.parse(response.body) expect(response).to have_http_status :ok expect(response_body).to be_a Hash - expect(response_body['mock_apple_pies']).to be_a Array - expect(response_body['mock_apple_pies'].size).to eq PIES_COUNT + expect(response_body["mock_apple_pies"]).to be_a Array + expect(response_body["mock_apple_pies"].size).to eq PIES_COUNT - response_body['mock_apple_pies'].each do |pie| - expect(@grandma.id).to eq pie['grandma_id'] - expect(pie['ingredients']).not_to be nil + response_body["mock_apple_pies"].each do |pie| + expect(@grandma.id).to eq pie["grandma_id"] + expect(pie["ingredients"]).not_to be nil end end end end - describe 'GET #show' do - context 'html' do - it 'renders the show template' do - get :show, params: { id: pie.to_param } + describe "GET #show" do + context "html" do + it "renders the show template" do + get :show, params: {id: pie.to_param} expect(response).to have_http_status :ok expect(response).to render_template(:show) @@ -55,32 +55,32 @@ expect(assigns(:mock_apple_pie)).to be_a MockApplePie end - it 'renders not_found' do - expect { get :show, params: { id: 555 } }.to raise_error(ActionController::RoutingError) + it "renders not_found" do + expect { get :show, params: {id: 555} }.to raise_error(ActionController::RoutingError) end end - context 'json' do - it 'renders show JSON' do - get :show, params: { id: pie.to_param }, format: :json + context "json" do + it "renders show JSON" do + get :show, params: {id: pie.to_param}, format: :json response_body = JSON.parse(response.body) expect(response).to have_http_status :ok expect(response_body).to be_a Hash - expect(response_body['mock_apple_pie']).to be_a Hash - expect(response_body['mock_apple_pie']['grandma_id']).to eq @grandma.id + expect(response_body["mock_apple_pie"]).to be_a Hash + expect(response_body["mock_apple_pie"]["grandma_id"]).to eq @grandma.id end - it 'renders not_found' do - get :show, params: { id: 555 }, format: :json + it "renders not_found" do + get :show, params: {id: 555}, format: :json expect(response).to have_http_status :not_found end end end - describe 'GET #new' do - it 'renders the new template' do + describe "GET #new" do + it "renders the new template" do get :new expect(response).to have_http_status :ok @@ -91,46 +91,46 @@ end end - describe 'POST #create' do - let(:new_attrs) { { mock_apple_pie: { ingredients: 'love', grandma_id: @grandma.id } } } - let(:bad_attrs) { { mock_apple_pie: { ingredients: '', grandma_id: @grandma.id } } } + describe "POST #create" do + let(:new_attrs) { {mock_apple_pie: {ingredients: "love", grandma_id: @grandma.id}} } + let(:bad_attrs) { {mock_apple_pie: {ingredients: "", grandma_id: @grandma.id}} } - context 'html' do - it 'redirects to show' do + context "html" do + it "redirects to show" do post :create, params: new_attrs pie = MockApplePie.all.last expect(response).to have_http_status :redirect expect(response).to redirect_to(action: :show, id: pie.id) end - it 'renders 422 for invalid args' do + it "renders 422 for invalid args" do post :create, params: bad_attrs expect(response).to have_http_status :unprocessable_entity end end - context 'json' do - it 'creates successfully' do + context "json" do + it "creates successfully" do post :create, params: new_attrs.merge(format: :json) response_body = JSON.parse(response.body) expect(response).to have_http_status :created expect(response_body).to be_a Hash - expect(response_body['mock_apple_pie']).to be_a Hash - expect(response_body['mock_apple_pie']['grandma_id']).to eq @grandma.id - expect(response_body['mock_apple_pie']['ingredients']).to eq new_attrs[:mock_apple_pie][:ingredients] + expect(response_body["mock_apple_pie"]).to be_a Hash + expect(response_body["mock_apple_pie"]["grandma_id"]).to eq @grandma.id + expect(response_body["mock_apple_pie"]["ingredients"]).to eq new_attrs[:mock_apple_pie][:ingredients] end - it 'renders 422 for invalid args' do + it "renders 422 for invalid args" do post :create, params: bad_attrs.merge(format: :json) expect(response).to have_http_status :unprocessable_entity end end end - describe 'GET #edit' do - it 'renders the new template' do - get :edit, params: { id: pie.to_param } + describe "GET #edit" do + it "renders the new template" do + get :edit, params: {id: pie.to_param} expect(response).to have_http_status :ok expect(response).to render_template(:edit) @@ -139,93 +139,219 @@ expect(assigns(:mock_apple_pie)).to be_a MockApplePie end - it 'renders not_found' do - expect { get :edit, params: { id: 555 } }.to raise_error(ActionController::RoutingError) + it "renders not_found" do + expect { get :edit, params: {id: 555} }.to raise_error(ActionController::RoutingError) end end - describe 'PUT #update' do - let(:update_attrs) { { mock_apple_pie: { ingredients: 'lots of love' } } } - let(:bad_attrs) { { mock_apple_pie: { ingredients: '' } } } + describe "PUT #update" do + let(:update_attrs) { {mock_apple_pie: {ingredients: "lots of love"}} } + let(:bad_attrs) { {mock_apple_pie: {ingredients: ""}} } - context 'html' do - it 'redirects to show' do + context "html" do + it "redirects to show" do put :update, params: update_attrs.merge(id: pie.to_param) expect(response).to have_http_status :redirect expect(response).to redirect_to(action: :show, id: pie.to_param) end - it 'renders not_found' do + it "renders not_found" do expect { put :update, params: update_attrs.merge(id: 555) }.to raise_error(ActionController::RoutingError) end - it 'renders unprocessable_entity' do + it "renders unprocessable_entity" do put :update, params: bad_attrs.merge(id: pie.to_param) expect(response).to have_http_status :unprocessable_entity end end - context 'json' do - it 'updates successfully' do + context "json" do + it "updates successfully" do put :update, params: update_attrs.merge(id: pie.to_param, format: :json) response_body = JSON.parse(response.body) expect(response).to have_http_status :ok expect(response_body).to be_a Hash - expect(response_body['mock_apple_pie']).to be_a Hash - expect(response_body['mock_apple_pie']['ingredients']).to eq update_attrs[:mock_apple_pie][:ingredients] + expect(response_body["mock_apple_pie"]).to be_a Hash + expect(response_body["mock_apple_pie"]["ingredients"]).to eq update_attrs[:mock_apple_pie][:ingredients] end - it 'renders not_found' do + it "renders not_found" do put :update, params: update_attrs.merge(id: 555, format: :json) expect(response).to have_http_status :not_found end - it 'renders unprocessable_entity' do + it "renders unprocessable_entity" do put :update, params: bad_attrs.merge(id: pie.to_param, format: :json) expect(response).to have_http_status :unprocessable_entity end end end - describe 'DELETE #destroy' do - context 'html' do - it 'redirects to index' do - delete :destroy, params: { id: pie.to_param } + describe "DELETE #destroy" do + context "html" do + it "redirects to index" do + delete :destroy, params: {id: pie.to_param} expect(response).to have_http_status :redirect expect(response).to redirect_to(action: :index) end - it 'renders not_found' do - expect { delete :destroy, params: { id: 555 } }.to raise_error(ActionController::RoutingError) + it "renders not_found" do + expect { delete :destroy, params: {id: 555} }.to raise_error(ActionController::RoutingError) end - it 'renders unprocessable_entity' do - pie.update_attribute(:ingredients, 'real apples') + it "renders unprocessable_entity" do + pie.update_attribute(:ingredients, "real apples") - delete :destroy, params: { id: pie.to_param } + delete :destroy, params: {id: pie.to_param} expect(response).to have_http_status :unprocessable_entity end end - context 'json' do - it 'deletes successfully' do - delete :destroy, params: { id: pie.to_param }, format: :json + context "json" do + it "deletes successfully" do + delete :destroy, params: {id: pie.to_param}, format: :json expect(response).to have_http_status :no_content expect(response.body).to be_empty end - it 'renders not_found' do - delete :destroy, params: { id: 555 }, format: :json + it "renders not_found" do + delete :destroy, params: {id: 555}, format: :json expect(response).to have_http_status :not_found end - it 'renders unprocessable_entity' do - pie.update_attribute(:ingredients, 'real apples') + it "renders unprocessable_entity" do + pie.update_attribute(:ingredients, "real apples") - delete :destroy, params: { id: pie.to_param }, format: :json + delete :destroy, params: {id: pie.to_param}, format: :json expect(response).to have_http_status :unprocessable_entity end end end + + describe "customization hooks" do + describe "default behavior" do + it "scoped_resource returns collection" do + expect(controller.scoped_resource).to eq(@grandma.mock_apple_pies) + end + + it "find_resource calls find_in_collection" do + result = controller.find_resource(pie.id) + expect(result).to eq(pie) + end + + it "default_sorting_attribute returns nil" do + expect(controller.default_sorting_attribute).to be_nil + end + + it "default_sorting_direction returns :asc" do + expect(controller.default_sorting_direction).to eq(:asc) + end + + it "paginate_resources returns unchanged resources" do + resources = @grandma.mock_apple_pies + expect(controller.paginate_resources(resources)).to eq(resources) + end + + it "after_resource_created_path returns show path" do + path = controller.after_resource_created_path(pie) + expect(path).to eq({action: :show, id: pie.id}) + end + + it "after_resource_updated_path returns show path" do + path = controller.after_resource_updated_path(pie) + expect(path).to eq({action: :show, id: pie.id}) + end + + it "after_resource_destroyed_path returns index path" do + path = controller.after_resource_destroyed_path(pie) + expect(path).to eq({action: :index}) + end + end + + describe "pagination_params" do + it "permits page and per_page params" do + allow(controller).to receive(:params).and_return( + ActionController::Parameters.new(page: "2", per_page: "10", other: "ignored") + ) + + permitted = controller.pagination_params + expect(permitted[:page]).to eq("2") + expect(permitted[:per_page]).to eq("10") + expect(permitted[:other]).to be_nil + end + end + + describe "strong params" do + let(:full_params) { ActionController::Parameters.new(mock_apple_pie: {ingredients: "love", grandma_id: 1, secret_field: "hidden"}) } + + before do + allow(controller).to receive(:params).and_return(full_params) + end + + describe "#permitted_create_params" do + it "returns nil by default" do + expect(controller.permitted_create_params).to be_nil + end + end + + describe "#permitted_update_params" do + it "returns nil by default" do + expect(controller.permitted_update_params).to be_nil + end + end + + describe "#create_params" do + it "permits all params when permitted_create_params is nil" do + allow(controller).to receive(:permitted_create_params).and_return(nil) + result = controller.create_params + expect(result[:ingredients]).to eq("love") + expect(result[:grandma_id]).to eq(1) + expect(result[:secret_field]).to eq("hidden") + end + + it "permits all params when permitted_create_params is empty" do + allow(controller).to receive(:permitted_create_params).and_return([]) + result = controller.create_params + expect(result[:ingredients]).to eq("love") + expect(result[:grandma_id]).to eq(1) + expect(result[:secret_field]).to eq("hidden") + end + + it "filters params when permitted_create_params is specified" do + allow(controller).to receive(:permitted_create_params).and_return([:ingredients]) + result = controller.create_params + expect(result[:ingredients]).to eq("love") + expect(result[:grandma_id]).to be_nil + expect(result[:secret_field]).to be_nil + end + end + + describe "#update_params" do + it "permits all params when permitted_update_params is nil" do + allow(controller).to receive(:permitted_update_params).and_return(nil) + result = controller.update_params + expect(result[:ingredients]).to eq("love") + expect(result[:grandma_id]).to eq(1) + expect(result[:secret_field]).to eq("hidden") + end + + it "permits all params when permitted_update_params is empty" do + allow(controller).to receive(:permitted_update_params).and_return([]) + result = controller.update_params + expect(result[:ingredients]).to eq("love") + expect(result[:grandma_id]).to eq(1) + expect(result[:secret_field]).to eq("hidden") + end + + it "filters params when permitted_update_params is specified" do + allow(controller).to receive(:permitted_update_params).and_return([:ingredients]) + result = controller.update_params + expect(result[:ingredients]).to eq("love") + expect(result[:grandma_id]).to be_nil + expect(result[:secret_field]).to be_nil + end + end + end + end end diff --git a/spec/factories/grandma.rb b/spec/factories/grandma.rb index 9a7eb54..a2ee463 100644 --- a/spec/factories/grandma.rb +++ b/spec/factories/grandma.rb @@ -2,7 +2,7 @@ FactoryBot.define do factory :grandma do - name { 'Annie' } + name { "Annie" } transient do pies_count { 5 } diff --git a/spec/factories/mock_apple_pie.rb b/spec/factories/mock_apple_pie.rb index b0d21a4..cc29997 100644 --- a/spec/factories/mock_apple_pie.rb +++ b/spec/factories/mock_apple_pie.rb @@ -2,7 +2,7 @@ FactoryBot.define do factory :mock_apple_pie do - ingredients { 'flour, sugar, water, butter, eggs, milk, Ritz crackers, lemon, vanilla, cinnamon' } + ingredients { "flour, sugar, water, butter, eggs, milk, Ritz crackers, lemon, vanilla, cinnamon" } grandma end end diff --git a/spec/internal/app/controllers/mock_apple_pies_controller.rb b/spec/internal/app/controllers/mock_apple_pies_controller.rb index c31bd72..1a046be 100644 --- a/spec/internal/app/controllers/mock_apple_pies_controller.rb +++ b/spec/internal/app/controllers/mock_apple_pies_controller.rb @@ -3,12 +3,11 @@ class MockApplePiesController < ApplicationController include TypicalSituation + typical_situation :mock_apple_pie + attr_accessor :current_grandma - # Symbolized, underscored version of the model (class) to use. - def model_type - :mock_apple_pie - end + private # The collection of model instances. def collection diff --git a/spec/internal/app/models/test_model.rb b/spec/internal/app/models/test_model.rb new file mode 100644 index 0000000..e2e2da4 --- /dev/null +++ b/spec/internal/app/models/test_model.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class TestModel < ActiveRecord::Base + belongs_to :grandma + + validates :name, presence: true +end \ No newline at end of file diff --git a/spec/internal/db/schema.rb b/spec/internal/db/schema.rb index ba04dd5..3b66326 100644 --- a/spec/internal/db/schema.rb +++ b/spec/internal/db/schema.rb @@ -11,4 +11,9 @@ t.integer 'grandma_id' t.string 'ingredients' end + + create_table 'test_models', force: true do |t| + t.integer 'grandma_id' + t.string 'name' + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f326674..ad9f40a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,28 +1,23 @@ # frozen_string_literal: true -require 'bundler' +require "bundler" Bundler.require :default, :development # If you're using all parts of Rails: Combustion.initialize! :all -require 'rspec/rails' -require 'typical_situation' -require 'factory_bot_rails' +require "rspec/rails" +require "typical_situation" +require "factory_bot_rails" -Dir[File.dirname(__FILE__) + '/factories/*.rb'].each { |f| require f } +Dir[File.dirname(__FILE__) + "/factories/*.rb"].each { |f| require f } RSpec.configure do |config| - if Rails::VERSION::MAJOR < 5 - require 'rails/forward_compatible_controller_tests' - config.include Rails::ForwardCompatibleControllerTests, type: :controller - end - config.include FactoryBot::Syntax::Methods # Enable flags like --only-failures and --next-failure - config.example_status_persistence_file_path = '.rspec_status' + config.example_status_persistence_file_path = ".rspec_status" # Disable RSpec exposing methods globally on `Module` and `main` config.disable_monkey_patching! diff --git a/spec/typical_situation_permissions_spec.rb b/spec/typical_situation_permissions_spec.rb new file mode 100644 index 0000000..b7cf9e3 --- /dev/null +++ b/spec/typical_situation_permissions_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "spec_helper" + +PIES_COUNT = 5 + +RSpec.describe MockApplePiesController, type: :controller do + before(:each) do + @grandma = create(:grandma, pies_count: PIES_COUNT) + controller.current_grandma = @grandma + end + + let(:pie) { @grandma.mock_apple_pies.first } + + describe "authorization" do + describe "default behavior" do + it "authorized? returns true by default" do + expect(controller.authorized?(:show, pie)).to be true + expect(controller.authorized?(:destroy, pie)).to be true + end + end + + describe "custom authorization" do + let(:controller_class) do + Class.new(MockApplePiesController) do + def authorized?(action, resource = nil) + case action + when :destroy + resource&.ingredients != "forbidden_ingredient" + when :show + resource&.ingredients != "secret_ingredient" + else + true + end + end + end + end + + let(:custom_controller) { controller_class.new } + + before do + custom_controller.current_grandma = @grandma + end + + it "allows destroy when authorized" do + pie = create(:mock_apple_pie, grandma: @grandma, ingredients: "allowed") + expect(custom_controller.authorized?(:destroy, pie)).to be true + end + + it "blocks destroy when unauthorized" do + pie = create(:mock_apple_pie, grandma: @grandma, ingredients: "forbidden_ingredient") + expect(custom_controller.authorized?(:destroy, pie)).to be false + end + + it "allows show when authorized" do + pie = create(:mock_apple_pie, grandma: @grandma, ingredients: "allowed") + expect(custom_controller.authorized?(:show, pie)).to be true + end + + it "blocks show when unauthorized" do + pie = create(:mock_apple_pie, grandma: @grandma, ingredients: "secret_ingredient") + expect(custom_controller.authorized?(:show, pie)).to be false + end + end + + describe "custom forbidden responses" do + let(:custom_controller_class) do + Class.new(MockApplePiesController) do + def authorized?(_action, _resource = nil) + false # Always unauthorized + end + + def respond_as_forbidden + redirect_to "/custom_forbidden" + end + end + end + + it "uses custom forbidden response" do + custom_controller = custom_controller_class.new + custom_controller.current_grandma = @grandma + @grandma.mock_apple_pies.first + + # Test that respond_as_forbidden is called (we can't easily test the full response in this setup) + expect(custom_controller).to receive(:redirect_to).with("/custom_forbidden") + custom_controller.respond_as_forbidden + end + end + end +end diff --git a/spec/typical_situation_spec.rb b/spec/typical_situation_spec.rb index 9d0af36..67d766c 100644 --- a/spec/typical_situation_spec.rb +++ b/spec/typical_situation_spec.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -require 'spec_helper' +require "spec_helper" RSpec.describe TypicalSituation do - it 'has a version number' do + it "has a version number" do expect(TypicalSituation::VERSION).not_to be nil end end diff --git a/spec/typical_situation_syntax_spec.rb b/spec/typical_situation_syntax_spec.rb new file mode 100644 index 0000000..a5c249c --- /dev/null +++ b/spec/typical_situation_syntax_spec.rb @@ -0,0 +1,382 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "TypicalSituation syntax sugar" do + describe "typical_situation class method" do + let(:controller_class) do + Class.new(ApplicationController) do + include TypicalSituation + + typical_situation :test_model + + attr_accessor :current_grandma + + def collection + current_grandma.mock_apple_pies + end + + def find_in_collection(id) + collection.find_by_id(id) + end + end + end + + let(:controller) { controller_class.new } + let(:grandma) { create(:grandma) } + + before do + controller.current_grandma = grandma + end + + it "defines model_type method" do + expect(controller.model_type).to eq(:test_model) + end + + it "works with all typical situation functionality" do + expect(controller.model_class).to eq(TestModel) + end + + it "works with plural_model_type" do + expect(controller.plural_model_type).to eq(:test_models) + end + + it "works with model_params" do + # Mock params + allow(controller).to receive(:params) do + ActionController::Parameters.new( + test_model: {name: "Test"} + ) + end + + expect(controller.model_params).to be_a(ActionController::Parameters) + expect(controller.model_params[:name]).to eq("Test") + end + end + + describe "with only parameter" do + let(:limited_controller_class) do + Class.new(ApplicationController) do + include TypicalSituation + + typical_situation :test_model, only: %i[index show] + + attr_accessor :current_grandma + + def collection + current_grandma.mock_apple_pies + end + + def find_in_collection(id) + collection.find_by_id(id) + end + end + end + + let(:limited_controller) { limited_controller_class.new } + let(:grandma) { create(:grandma) } + + before do + limited_controller.current_grandma = grandma + end + + it "only defines specified actions" do + expect(limited_controller).to respond_to(:index) + expect(limited_controller).to respond_to(:show) + expect(limited_controller).not_to respond_to(:create) + expect(limited_controller).not_to respond_to(:update) + expect(limited_controller).not_to respond_to(:destroy) + end + + it "still works with model_type functionality" do + expect(limited_controller.model_type).to eq(:test_model) + end + end + + describe "typical_rest class method" do + let(:rest_controller_class) do + Class.new(ApplicationController) do + include TypicalSituation + + typical_rest :test_model + + attr_accessor :current_grandma + + def collection + current_grandma.mock_apple_pies + end + + def find_in_collection(id) + collection.find_by_id(id) + end + end + end + + let(:rest_controller) { rest_controller_class.new } + let(:grandma) { create(:grandma) } + + before do + rest_controller.current_grandma = grandma + end + + it "defines model_type method" do + expect(rest_controller.model_type).to eq(:test_model) + end + + it "includes all REST actions" do + expect(rest_controller).to respond_to(:index) + expect(rest_controller).to respond_to(:show) + expect(rest_controller).to respond_to(:new) + expect(rest_controller).to respond_to(:create) + expect(rest_controller).to respond_to(:edit) + expect(rest_controller).to respond_to(:update) + expect(rest_controller).to respond_to(:destroy) + end + + it "is equivalent to typical_situation without only parameter" do + equivalent_class = Class.new(ApplicationController) do + include TypicalSituation + + typical_situation :test_model + + attr_accessor :current_grandma + + def collection + current_grandma.mock_apple_pies + end + + def find_in_collection(id) + collection.find_by_id(id) + end + end + + equivalent_controller = equivalent_class.new + equivalent_controller.current_grandma = grandma + + expect(rest_controller.model_type).to eq(equivalent_controller.model_type) + + %i[index show new create edit update destroy].each do |action| + expect(rest_controller.respond_to?(action)).to eq(equivalent_controller.respond_to?(action)) + end + end + end + + describe "typical_crud class method" do + let(:crud_controller_class) do + Class.new(ApplicationController) do + include TypicalSituation + + typical_crud :test_model + + attr_accessor :current_grandma + + def collection + current_grandma.mock_apple_pies + end + + def find_in_collection(id) + collection.find_by_id(id) + end + end + end + + let(:crud_controller) { crud_controller_class.new } + let(:grandma) { create(:grandma) } + + before do + crud_controller.current_grandma = grandma + end + + it "defines model_type method" do + expect(crud_controller.model_type).to eq(:test_model) + end + + it "includes only CRUD actions" do + expect(crud_controller).to respond_to(:create) + expect(crud_controller).to respond_to(:show) + expect(crud_controller).to respond_to(:update) + expect(crud_controller).to respond_to(:destroy) + end + + it "does not include non-CRUD actions" do + expect(crud_controller).not_to respond_to(:index) + expect(crud_controller).not_to respond_to(:new) + expect(crud_controller).not_to respond_to(:edit) + end + + it "is equivalent to typical_situation with CRUD only actions" do + equivalent_class = Class.new(ApplicationController) do + include TypicalSituation + + typical_situation :test_model, only: %i[create show update destroy] + + attr_accessor :current_grandma + + def collection + current_grandma.mock_apple_pies + end + + def find_in_collection(id) + collection.find_by_id(id) + end + end + + equivalent_controller = equivalent_class.new + equivalent_controller.current_grandma = grandma + + expect(crud_controller.model_type).to eq(equivalent_controller.model_type) + + %i[index show new create edit update destroy].each do |action| + expect(crud_controller.respond_to?(action)).to eq(equivalent_controller.respond_to?(action)) + end + end + end + + describe "helper method differences" do + let(:grandma) { create(:grandma) } + + let(:rest_class) do + Class.new(ApplicationController) do + include TypicalSituation + + typical_rest :test_model + attr_accessor :current_grandma + + def collection + current_grandma.mock_apple_pies + end + + def find_in_collection(id) + collection.find_by_id(id) + end + end + end + + let(:crud_class) do + Class.new(ApplicationController) do + include TypicalSituation + + typical_crud :test_model + attr_accessor :current_grandma + + def collection + current_grandma.mock_apple_pies + end + + def find_in_collection(id) + collection.find_by_id(id) + end + end + end + + let(:rest_controller) { rest_class.new.tap { |c| c.current_grandma = grandma } } + let(:crud_controller) { crud_class.new.tap { |c| c.current_grandma = grandma } } + + it "typical_rest includes more actions than typical_crud" do + rest_actions = %i[index show new create edit update destroy].select do |action| + rest_controller.respond_to?(action) + end + + crud_actions = %i[index show new create edit update destroy].select do |action| + crud_controller.respond_to?(action) + end + + expect(rest_actions.count).to be > crud_actions.count + end + + it "typical_crud excludes form-related actions" do + expect(crud_controller).not_to respond_to(:new) + expect(crud_controller).not_to respond_to(:edit) + expect(crud_controller).not_to respond_to(:index) + end + + it "both helpers work with model_type functionality" do + expect(rest_controller.model_type).to eq(:test_model) + expect(crud_controller.model_type).to eq(:test_model) + expect(rest_controller.model_class).to eq(TestModel) + expect(crud_controller.model_class).to eq(TestModel) + end + end + + describe "backward compatibility" do + let(:old_syntax_controller_class) do + Class.new(ApplicationController) do + include TypicalSituation + + def model_type + :legacy_model + end + + attr_accessor :current_grandma + + def collection + current_grandma.mock_apple_pies + end + + def find_in_collection(id) + collection.find_by_id(id) + end + end + end + + let(:new_syntax_controller_class) do + Class.new(ApplicationController) do + include TypicalSituation + + typical_situation :legacy_model + + attr_accessor :current_grandma + + def collection + current_grandma.mock_apple_pies + end + + def find_in_collection(id) + collection.find_by_id(id) + end + end + end + + it "produces identical behavior between old and new syntax" do + old_controller = old_syntax_controller_class.new + new_controller = new_syntax_controller_class.new + + expect(old_controller.model_type).to eq(new_controller.model_type) + expect(old_controller.plural_model_type).to eq(new_controller.plural_model_type) + end + end + + describe "class method availability" do + it "adds typical_situation class method when module is included" do + controller_class = Class.new(ApplicationController) do + include TypicalSituation + end + + expect(controller_class).to respond_to(:typical_situation) + end + + it "adds typical_rest class method when module is included" do + controller_class = Class.new(ApplicationController) do + include TypicalSituation + end + + expect(controller_class).to respond_to(:typical_rest) + end + + it "adds typical_crud class method when module is included" do + controller_class = Class.new(ApplicationController) do + include TypicalSituation + end + + expect(controller_class).to respond_to(:typical_crud) + end + + it "does not add class methods to classes that do not include TypicalSituation" do + controller_class = Class.new(ApplicationController) + + expect(controller_class).not_to respond_to(:typical_situation) + expect(controller_class).not_to respond_to(:typical_rest) + expect(controller_class).not_to respond_to(:typical_crud) + end + end +end diff --git a/typical_situation.gemspec b/typical_situation.gemspec index 06a22a8..5ca6f48 100644 --- a/typical_situation.gemspec +++ b/typical_situation.gemspec @@ -1,32 +1,35 @@ # frozen_string_literal: true -$LOAD_PATH.push File.expand_path('lib', __dir__) +$LOAD_PATH.push File.expand_path("lib", __dir__) # Maintain your gem's version: -require 'typical_situation/version' +require "typical_situation/version" # Describe your gem and declare its dependencies: Gem::Specification.new do |s| - s.name = 'typical_situation' - s.version = TypicalSituation::VERSION - s.authors = ['Mars Hall', 'Wyatt Kirby'] - s.email = ['m@marsorange.com', 'wyatt@apsis.io'] - s.homepage = 'https://github.com/mars/typical_situation' - s.summary = 'The missing Rails ActionController REST API mixin.' - s.description = 'A module providing the seven standard resource actions & responses for an ActiveRecord :model_type & :collection.' + s.name = "typical_situation" + s.version = TypicalSituation::VERSION + s.authors = ["Mars Hall", "Wyatt Kirby"] + s.email = ["m@marsorange.com", "wyatt@apsis.io"] + s.homepage = "https://github.com/apsislabs/typical_situation" + s.summary = "The missing Rails ActionController REST API mixin." + s.description = "A module providing the seven standard resource actions & responses for an ActiveRecord :model_type & :collection." - s.files = Dir['{app,config,db,lib}/**/*'] + ['MIT-LICENSE', 'Rakefile', 'README.md'] - s.test_files = Dir['test/**/*'] + s.files = Dir["{app,config,db,lib}/**/*"] + ["MIT-LICENSE", "Rakefile", "README.md"] - s.add_runtime_dependency 'rails', '>= 4.0.0' + s.required_ruby_version = ">= 3.0.0" - s.add_development_dependency 'appraisal' - s.add_development_dependency 'bundler', '~> 1.0' - s.add_development_dependency 'byebug' - s.add_development_dependency 'combustion' - s.add_development_dependency 'coveralls' - s.add_development_dependency 'factory_bot_rails' - s.add_development_dependency 'rake' - s.add_development_dependency 'rspec-rails' - s.add_development_dependency 'sqlite3', '~> 1.3.6' + s.add_runtime_dependency "rails", ">= 7.0.0" + + s.add_development_dependency "appraisal" + s.add_development_dependency "bundler", ">= 2.2.0" + s.add_development_dependency "byebug" + s.add_development_dependency "combustion" + s.add_development_dependency "coveralls" + s.add_development_dependency "factory_bot_rails" + s.add_development_dependency "rails-controller-testing" + s.add_development_dependency "rake" + s.add_development_dependency "rspec-rails", ">= 6.0" + s.add_development_dependency "sqlite3", ">= 1.4" + s.add_development_dependency "standard" end