diff --git a/Appraisals b/Appraisals index d32d306..fcd4a97 100644 --- a/Appraisals +++ b/Appraisals @@ -1,5 +1,20 @@ appraise 'rails-app' do gem 'rails', '6.0.3.1' gem 'sqlite3', '~> 1.4' - gem "trailblazer-operation" + gem "representable" + gem "trailblazer-operation", '>= 0.6.5' + gem "trailblazer-cells" + gem "cells-rails" + gem "cells-erb" + gem "jwt" +end + +appraise 'grape-app' do + gem 'grape', '~> 1.5' + gem "zeitwerk", "~> 2.4" + gem "representable" + gem "trailblazer-operation", '>= 0.6.5' + + gem "minitest-line", "~> 0.6" + gem "rack-test", "1.1.0" end diff --git a/Rakefile b/Rakefile index e005594..5d64bb6 100644 --- a/Rakefile +++ b/Rakefile @@ -9,8 +9,18 @@ Rake::TestTask.new(:test) do |test| test.verbose = true end +# To run grape app's test, run below command +# $ appraisal rails-app rake test-rails-app Rake::TestTask.new('test-rails-app') do |test| test.libs << 'test' test.test_files = FileList['test/rails-app/test/test_helper.rb', 'test/rails-app/test/**/*.rb'] test.verbose = true end + +# To run grape app's test, run below command +# $ appraisal grape-app rake test-grape-app +Rake::TestTask.new('test-grape-app') do |test| + test.libs << 'test' + test.test_files = FileList['test/grape-app/test/test_helper.rb', 'test/grape-app/test/**/*.rb'] + test.verbose = true +end diff --git a/gemfiles/grape_app.gemfile b/gemfiles/grape_app.gemfile new file mode 100644 index 0000000..5cca27d --- /dev/null +++ b/gemfiles/grape_app.gemfile @@ -0,0 +1,14 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "multi_json" +gem "minitest-line", "~> 0.6" +gem "dry-validation" +gem "grape", "~> 1.5" +gem "zeitwerk", "~> 2.4" +gem "representable" +gem "trailblazer-operation", ">= 0.6.5" +gem "rack-test", "1.1.0" + +gemspec path: "../" diff --git a/gemfiles/rails_app.gemfile b/gemfiles/rails_app.gemfile new file mode 100644 index 0000000..d25507f --- /dev/null +++ b/gemfiles/rails_app.gemfile @@ -0,0 +1,17 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "multi_json" +gem "minitest-line" +gem "dry-validation" +gem "rails", "6.0.3.1" +gem "sqlite3", "~> 1.4" +gem "representable" +gem "trailblazer-operation", ">= 0.6.5" +gem "trailblazer-cells" +gem "cells-rails" +gem "cells-erb" +gem "jwt" + +gemspec path: "../" diff --git a/lib/trailblazer/endpoint/controller.rb b/lib/trailblazer/endpoint/controller.rb index ca0e591..06de51b 100644 --- a/lib/trailblazer/endpoint/controller.rb +++ b/lib/trailblazer/endpoint/controller.rb @@ -124,8 +124,12 @@ def build_endpoint(name, domain_activity: name, **options) end module InstanceMethods + # Returns object link between compile-time and run-time config + def config_source + self.class + end - def endpoint_for(name, config_source: self.class) + def endpoint_for(name) config_source.options_for(:endpoints, {}).fetch(name.to_s) # TODO: test non-existant endpoint end @@ -144,9 +148,8 @@ def invoke_endpoint_with_dsl(options, &block) end module API - def endpoint(name, config_source: self.class, **action_options) - endpoint = endpoint_for(name, config_source: config_source) - + def endpoint(name, **action_options) + endpoint = endpoint_for(name) action_options = {controller: self}.merge(action_options) # FIXME: redundant with {InstanceMethods#endpoint} block_options = config_source.options_for(:options_for_block_options, **action_options) diff --git a/lib/trailblazer/endpoint/grape/controller.rb b/lib/trailblazer/endpoint/grape/controller.rb new file mode 100644 index 0000000..dedd8fa --- /dev/null +++ b/lib/trailblazer/endpoint/grape/controller.rb @@ -0,0 +1,28 @@ +module Trailblazer + class Endpoint + # Grape Integration + # + module Grape + module Controller + # Make endpoint's compile time methods available in `base` and + # instance methods available in it's routes. + def self.included(base) + base.extend(Trailblazer::Endpoint::Controller) + + base.helpers( + Trailblazer::Endpoint::Controller::InstanceMethods, + Trailblazer::Endpoint::Controller::InstanceMethods::API + ) + + base.helpers do + # Override `Controller::InstanceMethods#config_source` to return `base` + # as the link between compile-time and run-time config. + # + # @api public + define_method(:config_source, ->{ base }) + end + end + end + end + end +end diff --git a/test/grape-app/app/api/app.rb b/test/grape-app/app/api/app.rb new file mode 100644 index 0000000..87a26a1 --- /dev/null +++ b/test/grape-app/app/api/app.rb @@ -0,0 +1,5 @@ +module App + class API < Grape::API + mount V1::API => "/v1" + end +end diff --git a/test/grape-app/app/api/v1.rb b/test/grape-app/app/api/v1.rb new file mode 100644 index 0000000..ad2ad9d --- /dev/null +++ b/test/grape-app/app/api/v1.rb @@ -0,0 +1,6 @@ +module V1 + class API < Grape::API + format :json + mount V1::Album => "/albums" + end +end diff --git a/test/grape-app/app/api/v1/album.rb b/test/grape-app/app/api/v1/album.rb new file mode 100644 index 0000000..21619cb --- /dev/null +++ b/test/grape-app/app/api/v1/album.rb @@ -0,0 +1,67 @@ +module V1 + class Album < V1::API + include Trailblazer::Endpoint::Grape::Controller + + def self.options_for_endpoint(ctx, controller:, **) + { + request: controller.request, + errors: Trailblazer::Endpoint::Adapter::API::Errors.new, + } + end + + def self.options_for_domain_ctx(ctx, controller:, **) + { + params: controller.params, + # current_user: current_user, # TODO: Access current_user + } + end + + def self.options_for_block_options(ctx, controller:, **) + response_block = ->(ctx, endpoint_ctx:, **) do + controller.body json: ctx[:model] + controller.status endpoint_ctx[:status] + end + + failure_block = ->(ctx, endpoint_ctx:, **) do + controller.body json: ctx[:errors].message + controller.status endpoint_ctx[:status] + end + + { + success_block: response_block, + failure_block: failure_block, + protocol_failure_block: failure_block + } + end + + directive :options_for_endpoint, method(:options_for_endpoint) + directive :options_for_domain_ctx, method(:options_for_domain_ctx) + directive :options_for_block_options, method(:options_for_block_options) + + endpoint ::Album::Operation::Index, protocol: Application::Endpoint::Protocol, adapter: Application::Endpoint::Adapter + desc "Get list of albums" + get { endpoint ::Album::Operation::Index, representer_class: ::Album::Representer } + + endpoint ::Song::Operation::Index, protocol: Application::Endpoint::Protocol, adapter: Application::Endpoint::Adapter + endpoint ::Song::Operation::Create, protocol: Application::Endpoint::Protocol, adapter: Application::Endpoint::Adapter + + # FIXME: Use inheritance same as Rails's ApplicationController for maintaining global config + # Grape has anonymous class scope within resource block which doesn't copy inheritance settings + # mount ::V1::Song => ':album_id/songs' + + resource ':album_id/songs' do + desc "Get list of songs" + get { endpoint ::Song::Operation::Index, representer_class: ::Song::Representer } + + desc "Create a song" + post do + on_create = ->(ctx, model:, endpoint_ctx:, **) do + status 201 + body json: endpoint_ctx[:representer_class].new(model).to_json + end + + endpoint ::Song::Operation::Create, success_block: on_create, representer_class: ::Song::Representer + end + end + end +end diff --git a/test/grape-app/app/concepts/album/operation/index.rb b/test/grape-app/app/concepts/album/operation/index.rb new file mode 100644 index 0000000..8cd0f51 --- /dev/null +++ b/test/grape-app/app/concepts/album/operation/index.rb @@ -0,0 +1,7 @@ +class Album::Operation::Index < Trailblazer::Operation + step :model + + def model(ctx, **) + ctx[:model] = 3.times.collect{ |i| Album.new(i) } + end +end diff --git a/test/grape-app/app/concepts/album/representer.rb b/test/grape-app/app/concepts/album/representer.rb new file mode 100644 index 0000000..70722d9 --- /dev/null +++ b/test/grape-app/app/concepts/album/representer.rb @@ -0,0 +1,7 @@ +require 'representable' + +class Album::Representer < Representable::Decorator + include Representable::JSON + + property :id +end diff --git a/test/grape-app/app/concepts/application/endpoint/adapter.rb b/test/grape-app/app/concepts/application/endpoint/adapter.rb new file mode 100644 index 0000000..87bf1e1 --- /dev/null +++ b/test/grape-app/app/concepts/application/endpoint/adapter.rb @@ -0,0 +1,6 @@ +module Application::Endpoint + class Adapter < Trailblazer::Endpoint::Adapter::API + include Errors::Handlers + insert_error_handler_steps!(self) + end +end diff --git a/test/grape-app/app/concepts/application/endpoint/protocol.rb b/test/grape-app/app/concepts/application/endpoint/protocol.rb new file mode 100644 index 0000000..2af0330 --- /dev/null +++ b/test/grape-app/app/concepts/application/endpoint/protocol.rb @@ -0,0 +1,14 @@ +module Application::Endpoint + class Protocol < Trailblazer::Endpoint::Protocol + def authenticate(ctx, controller:, **) + username, password = Rack::Auth::Basic::Request.new(controller.env).credentials + return false if username != password + + ctx[:current_user] = User.new(username) + end + + def policy(ctx, current_user:, **) + current_user.username == 'admin' + end + end +end diff --git a/test/grape-app/app/concepts/song/operation/create.rb b/test/grape-app/app/concepts/song/operation/create.rb new file mode 100644 index 0000000..f477119 --- /dev/null +++ b/test/grape-app/app/concepts/song/operation/create.rb @@ -0,0 +1,7 @@ +class Song::Operation::Create < Trailblazer::Operation + step :model + + def model(ctx, params:, **) + ctx[:model] = Song.new(1, params.fetch(:album_id), "current_user.username") # TODO: Access current_user + end +end diff --git a/test/grape-app/app/concepts/song/operation/index.rb b/test/grape-app/app/concepts/song/operation/index.rb new file mode 100644 index 0000000..d70ac1e --- /dev/null +++ b/test/grape-app/app/concepts/song/operation/index.rb @@ -0,0 +1,7 @@ +class Song::Operation::Index < Trailblazer::Operation + step :model + + def model(ctx, **) + ctx[:model] = 3.times.collect{ |i| Song.new(i) } + end +end diff --git a/test/grape-app/app/concepts/song/representer.rb b/test/grape-app/app/concepts/song/representer.rb new file mode 100644 index 0000000..5c2709c --- /dev/null +++ b/test/grape-app/app/concepts/song/representer.rb @@ -0,0 +1,9 @@ +require 'representable' + +class Song::Representer < Representable::Decorator + include Representable::JSON + + property :id + property :album_id + property :created_by +end diff --git a/test/grape-app/app/models/album.rb b/test/grape-app/app/models/album.rb new file mode 100644 index 0000000..d3f9738 --- /dev/null +++ b/test/grape-app/app/models/album.rb @@ -0,0 +1,2 @@ +class Album < Struct.new(:id) +end diff --git a/test/grape-app/app/models/song.rb b/test/grape-app/app/models/song.rb new file mode 100644 index 0000000..9e2c378 --- /dev/null +++ b/test/grape-app/app/models/song.rb @@ -0,0 +1,2 @@ +class Song < Struct.new(:id, :album_id, :created_by) +end diff --git a/test/grape-app/app/models/user.rb b/test/grape-app/app/models/user.rb new file mode 100644 index 0000000..82fb645 --- /dev/null +++ b/test/grape-app/app/models/user.rb @@ -0,0 +1,2 @@ +class User < Struct.new(:username) +end diff --git a/test/grape-app/config.ru b/test/grape-app/config.ru new file mode 100644 index 0000000..71c8c43 --- /dev/null +++ b/test/grape-app/config.ru @@ -0,0 +1,15 @@ +require "grape" +require "zeitwerk" + +require "trailblazer/operation" +require "trailblazer/endpoint" +require "trailblazer/endpoint/grape/controller" + +loader = Zeitwerk::Loader.new +loader.push_dir("#{__dir__}/app/api") +loader.push_dir("#{__dir__}/app/models") +loader.push_dir("#{__dir__}/app/concepts") +loader.setup + +App::API.compile! +run App::API diff --git a/test/grape-app/test/album_api_test.rb b/test/grape-app/test/album_api_test.rb new file mode 100644 index 0000000..9995561 --- /dev/null +++ b/test/grape-app/test/album_api_test.rb @@ -0,0 +1,37 @@ +require "test_helper" + +class AlbumApiTest < Minitest::Spec + include Rack::Test::Methods + + def app + APP_API + end + + it "not_authenticated" do + get "/v1/albums", {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('admin', 'wrong') + + assert_equal last_response.status, 401 + assert_equal last_response.body, "{\"json\":\"Authentication credentials were not provided or are invalid.\"}" + end + + it "not_authorized" do + get "/v1/albums", {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('not_admin', 'not_admin') + + assert_equal last_response.status, 403 + assert_equal last_response.body, "{\"json\":\"Action not allowed due to a policy setting.\"}" + end + + it "success" do + get "/v1/albums", {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('admin', 'admin') + + assert_equal last_response.status, 200 + # assert_equal last_response.body, "" # TODO: Use representer + end + + it "created" do + post "/v1/albums/1/songs", {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('admin', 'admin') + + assert_equal last_response.status, 201 + assert_equal JSON.parse(last_response.body), {"json"=>"{\"id\":1,\"album_id\":\"1\",\"created_by\":\"current_user.username\"}"} + end +end diff --git a/test/grape-app/test/test_helper.rb b/test/grape-app/test/test_helper.rb new file mode 100644 index 0000000..76855e4 --- /dev/null +++ b/test/grape-app/test/test_helper.rb @@ -0,0 +1,11 @@ +require "minitest/autorun" +require "rack/test" + +config_path = File.expand_path(File.join(__FILE__, '../../config.ru')) +APP_API = Rack::Builder.parse_file(config_path).first + +Minitest::Spec.class_eval do + def encode_basic_auth(username, password) + 'Basic ' + Base64.encode64("#{username}:#{password}") + end +end diff --git a/trailblazer-endpoint.gemspec b/trailblazer-endpoint.gemspec index 1efecba..3638a06 100644 --- a/trailblazer-endpoint.gemspec +++ b/trailblazer-endpoint.gemspec @@ -23,4 +23,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency "rake" spec.add_development_dependency "minitest" spec.add_development_dependency "trailblazer-developer" + spec.add_development_dependency "appraisal" end