diff --git a/Gemfile b/Gemfile index a6860ed..fe3388f 100644 --- a/Gemfile +++ b/Gemfile @@ -7,7 +7,7 @@ gem "multi_json" gem "minitest-line" -gem "trailblazer", path: "../trailblazer" +gem "trailblazer" # gem "trailblazer-operation", path: "../operation" -gem "dry-validation" +gem "dry-validation", "~> 0.11" diff --git a/Gemfile.lock b/Gemfile.lock index 874053f..6cf8b47 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,3 @@ -PATH - remote: ../trailblazer - specs: - trailblazer (2.1.0.rc1) - trailblazer-macro (>= 2.1.0.rc1, < 2.2.0) - trailblazer-macro-contract (= 2.1.0.rc1) - trailblazer-operation - PATH remote: . specs: @@ -15,53 +7,52 @@ PATH GEM remote: https://rubygems.org/ specs: - concurrent-ruby (1.0.2) + concurrent-ruby (1.1.5) declarative (0.0.10) declarative-builder (0.1.0) declarative-option (< 0.2.0) declarative-option (0.1.0) - disposable (0.4.4) + disposable (0.4.7) declarative (>= 0.0.9, < 1.0.0) declarative-builder (< 0.2.0) declarative-option (< 0.2.0) representable (>= 2.4.0, <= 3.1.0) uber (< 0.2.0) - dry-configurable (0.3.0) + dry-configurable (0.9.0) concurrent-ruby (~> 1.0) - dry-container (0.5.0) + dry-core (~> 0.4, >= 0.4.7) + dry-container (0.7.2) concurrent-ruby (~> 1.0) dry-configurable (~> 0.1, >= 0.1.3) - dry-core (0.2.0) + dry-core (0.4.9) + concurrent-ruby (~> 1.0) + dry-equalizer (0.3.0) + dry-inflector (0.2.0) + dry-logic (0.6.1) concurrent-ruby (~> 1.0) - dry-equalizer (0.2.0) - dry-logic (0.4.0) - dry-container (~> 0.2, >= 0.2.6) - dry-core (~> 0.1) + dry-core (~> 0.2) dry-equalizer (~> 0.2) - dry-matcher (0.5.0) - dry-types (0.9.0) + dry-matcher (0.8.3) + dry-core (>= 0.4.8) + dry-types (0.14.1) concurrent-ruby (~> 1.0) - dry-configurable (~> 0.1) dry-container (~> 0.3) - dry-core (~> 0.1) + dry-core (~> 0.4, >= 0.4.4) dry-equalizer (~> 0.2) - dry-logic (~> 0.4, >= 0.4.0) - inflecto (~> 0.0.0, >= 0.0.2) - dry-validation (0.10.3) + dry-inflector (~> 0.1, >= 0.1.2) + dry-logic (~> 0.5, >= 0.5) + dry-validation (0.13.3) concurrent-ruby (~> 1.0) dry-configurable (~> 0.1, >= 0.1.3) - dry-container (~> 0.2, >= 0.2.8) - dry-core (~> 0.1) + dry-core (~> 0.2, >= 0.2.1) dry-equalizer (~> 0.2) - dry-logic (~> 0.4, >= 0.4.0) - dry-types (~> 0.9, >= 0.9.0) - hirb (0.7.3) - inflecto (0.0.2) - minitest (5.9.1) - minitest-line (0.6.3) + dry-logic (~> 0.5, >= 0.5.0) + dry-types (~> 0.14.0) + minitest (5.14.0) + minitest-line (0.6.5) minitest (~> 5.0) - multi_json (1.12.1) - rake (11.3.0) + multi_json (1.14.1) + rake (13.0.1) reform (2.2.4) disposable (>= 0.4.1) representable (>= 2.4.0, < 3.1.0) @@ -69,16 +60,27 @@ GEM declarative (< 0.1.0) declarative-option (< 0.2.0) uber (< 0.2.0) - trailblazer-activity (0.7.1) - hirb - trailblazer-context - trailblazer-context (0.1.2) - trailblazer-macro (2.1.0.rc1) - trailblazer-macro-contract (2.1.0.rc1) + trailblazer (2.1.0) + trailblazer-macro (>= 2.1.0, < 2.2.0) + trailblazer-macro-contract (>= 2.1.0, < 2.2.0) + trailblazer-operation + trailblazer-activity (0.10.0) + trailblazer-context (>= 0.2.0, < 0.3.0) + trailblazer-activity-dsl-linear (0.2.6) + trailblazer-activity (>= 0.9.1, < 1.0.0) + trailblazer-context (0.2.0) + trailblazer-developer (0.0.10) + representable + trailblazer-activity (>= 0.10.0, < 1.0.0) + trailblazer-activity-dsl-linear + trailblazer-macro (2.1.1) + trailblazer-operation (>= 0.6.0) + trailblazer-macro-contract (2.1.0) reform (>= 2.2.0, < 3.0.0) - trailblazer-operation (0.4.1) - trailblazer-activity (>= 0.7.1, < 0.8.0) - trailblazer-context (>= 0.1.1, < 0.3.0) + trailblazer-operation (0.6.0) + trailblazer-activity (>= 0.10.0, < 1.0.0) + trailblazer-activity-dsl-linear (>= 0.2.1, < 1.0.0) + trailblazer-developer (>= 0.0.8) uber (0.1.0) PLATFORMS @@ -86,13 +88,13 @@ PLATFORMS DEPENDENCIES bundler - dry-validation + dry-validation (~> 0.11) minitest minitest-line multi_json rake - trailblazer! + trailblazer trailblazer-endpoint! BUNDLED WITH - 1.16.2 + 1.17.2 diff --git a/lib/trailblazer/endpoint.rb b/lib/trailblazer/endpoint.rb index 8879e38..4489e2b 100644 --- a/lib/trailblazer/endpoint.rb +++ b/lib/trailblazer/endpoint.rb @@ -1,35 +1,28 @@ require "dry/matcher" +require "trailblazer/endpoint/rails" module Trailblazer class Endpoint - # this is totally WIP as we need to find best practices. - # also, i want this to be easily extendable. Matcher = Dry::Matcher.new( - present: Dry::Matcher::Case.new( # DISCUSS: the "present" flag needs some discussion. - match: ->(result) { result.success? && result["present"] }, - resolve: ->(result) { result }), - success: Dry::Matcher::Case.new( - match: ->(result) { result.success? }, - resolve: ->(result) { result }), - created: Dry::Matcher::Case.new( - match: ->(result) { result.success? && result["model.action"] == :new }, # the "model.action" doesn't mean you need Model. - resolve: ->(result) { result }), - not_found: Dry::Matcher::Case.new( - match: ->(result) { result.failure? && result["result.model"] && result["result.model"].failure? }, - resolve: ->(result) { result }), - # TODO: we could add unauthorized here. - unauthenticated: Dry::Matcher::Case.new( - match: ->(result) { result.failure? && result["result.policy.default"].failure? }, # FIXME: we might need a &. here ;) - resolve: ->(result) { result }), - invalid: Dry::Matcher::Case.new( - match: ->(result) { result.failure? && result["result.contract.default"] && result["result.contract.default"].failure? }, - resolve: ->(result) { result }) + not_found: Dry::Matcher::Case.new { |result| result_error_matching?(result, :not_found) ? result : Dry::Matcher::Undefined }, + unauthenticated: Dry::Matcher::Case.new { |result| result_error_matching?(result, :unauthenticated) ? result : Dry::Matcher::Undefined }, + unauthorized: Dry::Matcher::Case.new { |result| result_error_matching?(result, :unauthorized) ? result : Dry::Matcher::Undefined }, + invalid_params: Dry::Matcher::Case.new { |result| result_error_matching?(result, :invalid_params) ? result : Dry::Matcher::Undefined }, ) - # `call`s the operation. - def self.call(operation_class, handlers, *args, &block) - result = operation_class.(*args) - new.(result, handlers, &block) + class << self + # `call`s the operation. + def call(operation_class, handlers, *args, &block) + result = operation_class.(*args) + new.(result, handlers, &block) + end + + private + + def result_error_matching?(result, state) + end_state = result.event.to_h[:semantic] + result.failure? && end_state == state + end end def call(result, handlers=nil, &block) diff --git a/lib/trailblazer/endpoint/rails.rb b/lib/trailblazer/endpoint/rails.rb index dac01ab..8d7e102 100644 --- a/lib/trailblazer/endpoint/rails.rb +++ b/lib/trailblazer/endpoint/rails.rb @@ -1,5 +1,3 @@ -require "trailblazer/endpoint" - module Trailblazer::Endpoint::Handlers # Generic matcher handlers for a Rails API backend. # @@ -15,13 +13,34 @@ def initialize(controller, options) def call ->(m) do - m.not_found { |result| controller.head 404 } - m.unauthenticated { |result| controller.head 401 } - m.present { |result| controller.render json: result["representer.serializer.class"].new(result['model']), status: 200 } - m.created { |result| controller.head 201, location: "#{@path}/#{result["model"].id}" }#, result["representer.serializer.class"].new(result["model"]).to_json - m.success { |result| controller.head 200, location: "#{@path}/#{result["model"].id}" } - m.invalid { |result| controller.render json: result["representer.errors.class"].new(result['result.contract.default'].errors).to_json, status: 422 } + m.not_found { |res| render_json_error(res, 404) } + m.unauthenticated { |res| render_json_error(res, 401) } + m.unauthorized { |res| render_json_error(res, 403) } + m.invalid_params { |res| render_validation_errors(res, 422) } end end + + private + + def render_json_error(ctx, status) + err_msg = ctx['trailblazer-endpoint.error'] || default_for(status) + controller.render(json: err_msg, status: status) + end + + def render_validation_errors(ctx, status) + err_msg = ctx['result.contract.default']&.errors&.messages || default_for(status) + controller.render(json: err_msg, status: status) + end + + def default_for(status_code) + default_msg = { + 401 => 'Unauthorized.', + 403 => 'Forbidden.', + 404 => 'Resource not found.', + 422 => 'Unprocessable entity.' + } + + default_msg[status_code] + end end end diff --git a/test/endpoint_matcher_test.rb b/test/endpoint_matcher_test.rb new file mode 100644 index 0000000..177b8ea --- /dev/null +++ b/test/endpoint_matcher_test.rb @@ -0,0 +1,65 @@ +require 'test_helper' + +class EndpointMatcherTest < Minitest::Spec + class TestOperation < Trailblazer::Operation + NotFound = Class.new(Trailblazer::Activity::Signal) + Unauthenticated = Class.new(Trailblazer::Activity::Signal) + Unauthorized = Class.new(Trailblazer::Activity::Signal) + InvalidParams = Class.new(Trailblazer::Activity::Signal) + + SignalMatchers = { + not_found: NotFound, + unauthenticated: Unauthenticated, + unauthorized: Unauthorized, + invalid_params: InvalidParams, + } + + step ->(_, tested_state:, **) { SignalMatchers[tested_state] || true }, + Output(NotFound, :not_found) => End(:not_found), + Output(Unauthenticated, :unauthenticated) => End(:unauthenticated), + Output(Unauthorized, :unauthorized) => End(:unauthorized), + Output(InvalidParams, :invalid_params) => End(:invalid_params) + end + + let(:my_handlers) do + ->(m) do + m.not_found { |_| @its_a_match = :not_found } + m.unauthenticated { |_| @its_a_match = :unauthenticated } + m.unauthorized { |_| @its_a_match = :unauthorized } + m.invalid_params { |_| @its_a_match = :invalid_params } + end + end + + before do + @its_a_match = :no_match + end + + # 404 :not_found + it 'matches the :not_found state' do + result = TestOperation.call(tested_state: :not_found) + Trailblazer::Endpoint.new.call(result, my_handlers) + + _(@its_a_match).must_equal(:not_found) + end + + it 'matches the :unauthenticated state' do + result = TestOperation.call(tested_state: :unauthenticated) + Trailblazer::Endpoint.new.call(result, my_handlers) + + _(@its_a_match).must_equal(:unauthenticated) + end + + it 'matches the :unauthorized state' do + result = TestOperation.call(tested_state: :unauthorized) + Trailblazer::Endpoint.new.call(result, my_handlers) + + _(@its_a_match).must_equal(:unauthorized) + end + + it 'matches the :unauthorized state' do + result = TestOperation.call(tested_state: :invalid_params) + Trailblazer::Endpoint.new.call(result, my_handlers) + + _(@its_a_match).must_equal(:invalid_params) + end +end diff --git a/test/endpoint_rails_test.rb b/test/endpoint_rails_test.rb new file mode 100644 index 0000000..64edf3a --- /dev/null +++ b/test/endpoint_rails_test.rb @@ -0,0 +1,139 @@ +require 'test_helper' + +class EndpointHandlerTest < Minitest::Spec + class TestOperation < Trailblazer::Operation + NotFound = Class.new(Trailblazer::Activity::Signal) + Unauthenticated = Class.new(Trailblazer::Activity::Signal) + Unauthorized = Class.new(Trailblazer::Activity::Signal) + InvalidParams = Class.new(Trailblazer::Activity::Signal) + + SignalMatchers = { + not_found: NotFound, + unauthenticated: Unauthenticated, + unauthorized: Unauthorized, + invalid_params: InvalidParams, + } + + step ->(_, tested_state:, **) { SignalMatchers[tested_state] || true }, + Output(NotFound, :not_found) => End(:not_found), + Output(Unauthenticated, :unauthenticated) => End(:unauthenticated), + Output(Unauthorized, :unauthorized) => End(:unauthorized), + Output(InvalidParams, :invalid_params) => End(:invalid_params) + end + + def build_controller(test_op) + c = Class.new do + include Trailblazer::Endpoint::Controller + + attr_accessor :http_response + + def initialize(op) + @test_op = op + @http_response = {} + end + + def render(options) + @http_response = options + end + + def action_ending_in(state, **p) + endpoint(@test_op, args: [tested_state: state, params: p]) + end + end + c.new(test_op) + end + + let(:controller) { build_controller(TestOperation) } + + describe ':not_found' do + it 'returns a 404 HTTP code' do + controller.action_ending_in(:not_found) + + _(controller.http_response[:status]).must_equal(404) + end + + it 'returns an error message' do + controller.action_ending_in(:not_found) + + _(controller.http_response[:json]).must_equal('Resource not found.') + end + end + + describe ':unauthenticated' do + it 'returns a 401 HTTP code' do + controller.action_ending_in(:unauthenticated) + + _(controller.http_response[:status]).must_equal(401) + end + + it 'returns an error message' do + controller.action_ending_in(:unauthenticated) + + _(controller.http_response[:json]).must_equal('Unauthorized.') + end + end + + describe ':unauthorized' do + it 'returns a 403 HTTP code' do + controller.action_ending_in(:unauthorized) + + _(controller.http_response[:status]).must_equal(403) + end + + it 'returns an error message' do + controller.action_ending_in(:unauthorized) + + _(controller.http_response[:json]).must_equal('Forbidden.') + end + end + + describe ':invalid_params' do + describe 'when used with Reform' do + TestModel = Struct.new(:id, :name) + + class TestContract < Reform::Form + feature Reform::Form::Dry + property :name + + validation do + required(:name).filled + end + end + + class TestContractOp < Trailblazer::Operation + step Model(TestModel, :new) + step Contract::Build(constant: TestContract) + step Contract::Validate(), Output(:failure) => End(:invalid_params) + end + end + + describe 'when used in standalone' do + it 'returns a 422 HTTP code' do + controller.action_ending_in(:invalid_params, {}) + + _(controller.http_response[:status]).must_equal(422) + end + + it 'returns the contract errors messages' do + controller.action_ending_in(:invalid_params) + + _(controller.http_response[:json]).must_equal('Unprocessable entity.') + end + end + end + + describe 'error message overloading' do + class TestErrorOperation < Trailblazer::Operation + step ->(ctx, **) { ctx['trailblazer-endpoint.error'] = 'Very error'; false }, + Output(:failure) => End(:not_found) + end + + let(:controller) { build_controller(TestErrorOperation) } + + it 'returns the value set in `ctx[\'trailblazer-endpoint.error\']`' do + controller.action_ending_in(:not_found) + + _(controller.http_response[:json]).must_equal('Very error') + end + end +end diff --git a/test/endpoint_test.rb b/test/endpoint_test.old.rb similarity index 100% rename from test/endpoint_test.rb rename to test/endpoint_test.old.rb diff --git a/test/test_helper.rb b/test/test_helper.rb index ada5c51..7e6c3d1 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,3 +1,6 @@ require "minitest/autorun" require "trailblazer" require "trailblazer/endpoint" +require "dry-validation" +require "reform/form/dry" +require "dry/validation/compat/form"