├── .circleci └── config.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-gemset ├── .ruby-version ├── .simplecov ├── CHANGES.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── simple_endpoint.rb └── simple_endpoint │ ├── controller.rb │ ├── controller │ ├── builder.rb │ └── class_methods.rb │ ├── endpoint.rb │ ├── endpoint │ └── endpoint_options.rb │ ├── errors.rb │ └── version.rb ├── simple_endpoint.gemspec └── spec ├── simple_endpoint ├── controller_spec.rb └── endpoint_spec.rb └── spec_helper.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | executors: 4 | test_executor: 5 | docker: 6 | - image: ${RUBY_VERSION} 7 | auth: 8 | username: $DOCKERHUB_USERNAME 9 | password: $DOCKERHUB_PASSWORD 10 | working_directory: ~/simple_endpoint 11 | 12 | jobs: 13 | build: 14 | environment: 15 | RUBY_VERSION: << parameters.ruby_version >> 16 | BUNDLER_VERSION: 2.1.4 17 | executor: test_executor 18 | parameters: 19 | ruby_version: 20 | type: string 21 | steps: 22 | - checkout 23 | 24 | - run: 25 | name: 'Install bundler' 26 | command: 'gem install bundler' 27 | 28 | - run: 29 | name: Install dependencies 30 | command: bundle check --path=vendor/bundle || bundle install --path=vendor/bundle --jobs 4 --retry 3 31 | 32 | - run: 33 | name: Run Rubocop 34 | command: bundle exec rubocop 35 | 36 | - run: 37 | name: Run Tests 38 | command: bundle exec rspec 39 | 40 | workflows: 41 | build_and_test: 42 | jobs: 43 | - build: 44 | name: 'ruby 2.6.8' 45 | ruby_version: circleci/ruby:2.6.8 46 | - build: 47 | name: 'ruby 2.7.0' 48 | ruby_version: circleci/ruby:2.7.0 49 | - build: 50 | name: 'ruby 3.0.3' 51 | ruby_version: circleci/ruby:3.0.3 52 | - build: 53 | name: 'ruby 3.1.2' 54 | ruby_version: cimg/ruby:3.1.2 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.bundle/ 3 | /.yardoc 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | 11 | # rspec failure tracking 12 | .rspec_status 13 | Gemfile.lock 14 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-performance 3 | - rubocop-rspec 4 | 5 | AllCops: 6 | TargetRubyVersion: 3.1 7 | NewCops: enable 8 | SuggestExtensions: false 9 | Exclude: 10 | - 'simple_endpoint.gemspec' 11 | - 'vendor/**/*' 12 | 13 | Style/Documentation: 14 | Enabled: false 15 | 16 | Layout/LineLength: 17 | Max: 120 18 | 19 | Style/FrozenStringLiteralComment: 20 | Enabled: true 21 | 22 | Metrics/BlockLength: 23 | IgnoredMethods: ['describe','context'] 24 | 25 | RSpec/DescribeClass: 26 | Enabled: false 27 | 28 | RSpec/NestedGroups: 29 | Max: 4 30 | 31 | Style/HashSyntax: 32 | EnforcedShorthandSyntax: never 33 | 34 | Naming/BlockForwarding: 35 | EnforcedStyle: explicit 36 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | simple_endpoint 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.2 2 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | 5 | SimpleCov.start do 6 | enable_coverage :branch 7 | 8 | add_filter 'spec' 9 | end 10 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | simple_endpoint changelog 2 | ===================== 3 | 4 | [2022-02-12] Version 1.0.2 5 | --------------------------- 6 | - Add Ruby 3+ support 7 | 8 | [2022-02-09] Version 1.0.1 9 | --------------------------- 10 | - Add Ruby 2.7+ support 11 | 12 | [2020-01-25] Version 1.0.0 13 | --------------------------- 14 | **Breaking changes** 15 | - [PR #6](https://github.com/differencialx/simple_endpoint/pull/6): Added ability to pass additional options from controller tot handlers, such as options for serialization, to avoid setting them inside trailblazer operation. 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | # Specify your gem's dependencies in simple_endpoint.gemspec 8 | gemspec 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 differencialx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SimpleEndpoint 2 | [![](https://circleci.com/gh/differencialx/simple_endpoint.svg?style=svg)](https://circleci.com/gh/differencialx/simple_endpoint) 3 | [![Gem Version](https://img.shields.io/gem/v/simple_endpoint.svg)](https://rubygems.org/gems/simple_endpoint) 4 | 5 | Dry-matcher free implementation of trailblazer endpoint. 6 | 7 | ## Installation 8 | Add this to your Gemfile: 9 | 10 | ```ruby 11 | gem 'simple_endpoint', '~> 2.0.0' 12 | ``` 13 | 14 | ## Getting Started 15 | 16 | Include simple endpoint to your base controller 17 | 18 | ```ruby 19 | class ApplicationController < ActionController::Base 20 | include SimpleEndpoint::Controller 21 | end 22 | ``` 23 | 24 | Define `cases` to specify trailblazer operation result handling. 25 | 26 | ```ruby 27 | class ApplicationController < ActionController::Base 28 | include SimpleEndpoint::Controller 29 | 30 | cases do 31 | match(:success) { |result| result.success? } 32 | match(:invalid) { |result| result.failure? } 33 | end 34 | end 35 | ``` 36 | or 37 | ```ruby 38 | class ApplicationController < ActionController::Base 39 | include SimpleEndpoint::Controller 40 | 41 | private 42 | 43 | def default_cases 44 | { 45 | success: -> (result) { result.success? }, 46 | invalid: -> (result) { result.failure? } 47 | } 48 | end 49 | end 50 | ``` 51 | 52 | Define `handler` to specify how to handle each case 53 | 54 | ```ruby 55 | class ApplicationController < ActionController::Base 56 | include SimpleEndpoint::Controller 57 | 58 | handler do 59 | on(:success) { |result, **opts| render json: result['model'], **opts, status: 200 } 60 | on(:invalid) { |result, **| render json: result['contract.default'].errors, serializer: ErrorSerializer, status: :unprocessable_entity } 61 | end 62 | end 63 | ``` 64 | or `default_handler` method 65 | ```ruby 66 | class ApplicationController < ActionController::Base 67 | include SimpleEndpoint::Controller 68 | 69 | private 70 | 71 | def default_handler 72 | { 73 | success: -> (result, **opts) { render json: result['model'], **opts, status: 200 }, 74 | invalid: -> (result, **) { render json: result['contract.default'].errors, serializer: ErrorSerializer, status: :unprocessable_entity } 75 | } 76 | end 77 | end 78 | ``` 79 | 80 | `OperationIsNotHandled` error will be raised if `cases`/`#default_cases` doesn't contain case for specific operation result. 81 | 82 | `UnhandledResultError` will be raised if `handler`/`#default_hadnler` doesn't contain for some cases. 83 | 84 | `NotImplementedError` will be raised if `cases`/`#default_cases` or `handler`/`#default_hadnler` aren't defined. 85 | 86 | 87 | ### #endpoint method 88 | 89 | Now you are able to use `endpoint` method at other controllers 90 | 91 | `#endpoint` method has next signature: 92 | 93 | | Key | Required | Default value | Description | 94 | |---|---|---|---| 95 | | `:operation` | yes | - | Traiblazer operation class | 96 | | `:different_cases`| no | {} | Cases that should be redefined for exact `#endpoint` call | 97 | | `:different_handler` | no | {} | Case of handler that should be handled in different way | 98 | | `:options` | no | {} | Additional hash which will be merged to `#ednpoint_options` method result before operation execution | 99 | | `:before_response` | no | {} | Allow to process code before specific case handler | 100 | | `:renderer_options` | no | {} | Allow to pass serializer options from controller and Will available inside handler as second parameter. 101 | 102 | 103 | #### Simple endpoint call 104 | ```ruby 105 | class PostsController < ApplicationController 106 | def create 107 | endpoint operation: Post::Create 108 | end 109 | end 110 | ``` 111 | 112 | #### Redefining cases for specific controller 113 | 114 | If you need to redefine operation result handling for specific controller you can do next 115 | 116 | It will redefine or add only **these** cases 117 | ```ruby 118 | class PostsController < ApplicationController 119 | cases do 120 | match(:success) { |result| result.success? && is_it_raining? } 121 | match(:invalid) { |result| result.failure? && is_vasya_in_the_house? } 122 | end 123 | 124 | def create 125 | endpoint operation: Post::Create 126 | end 127 | 128 | private 129 | 130 | def is_it_raining? 131 | WeatherForecast.for_today.raining? 132 | end 133 | 134 | def is_vasya_in_the_house? 135 | User.find_by(login: 'vasya').signed_in? 136 | end 137 | end 138 | ``` 139 | If you want to remove parent cases use `inherit` option 140 | It will remove cases and add new ones 141 | ```ruby 142 | class PostsController < ApplicationController 143 | cases(inherit: false) do 144 | match(:success) { |result| result.success? && is_it_raining? } 145 | match(:invalid) { |result| result.failure? && is_vasya_in_the_house? } 146 | end 147 | 148 | def create 149 | endpoint operation: Post::Create 150 | end 151 | 152 | private 153 | 154 | def is_it_raining? 155 | WeatherForecast.for_today.raining? 156 | end 157 | 158 | def is_vasya_in_the_house? 159 | User.find_by(login: 'vasya').signed_in? 160 | end 161 | end 162 | ``` 163 | or manually create `default_cases` method. Note that it'll override `ApplicationController#default_cases` 164 | ```ruby 165 | class PostsController < ApplicationController 166 | def create 167 | endpoint operation: Post::Create 168 | end 169 | 170 | private 171 | 172 | def default_cases 173 | { 174 | success: -> (result) { result.success? && is_it_raining? }, 175 | invalid: -> (result) { result.failure? && is_vasya_in_the_house? } 176 | ... # other cases 177 | } 178 | end 179 | 180 | def is_it_raining? 181 | WeatherForecast.for_today.raining? 182 | end 183 | 184 | def is_vasya_in_the_house? 185 | User.find_by(login: 'vasya').signed_in? 186 | end 187 | end 188 | ``` 189 | 190 | #### Redefining cases for specific controller action 191 | 192 | Code below will redefine only `success` operation handling logic of `cases`/`#default_cases`, it doesn't matter where `cases`/`#default_cases` was defined, at `ApplicationController` or `PostsController` 193 | 194 | ```ruby 195 | class PostsController < ApplicationController 196 | def create 197 | cases { on(:success) { |result| result.success? && is_vasya_in_the_house? } } 198 | endpoint operation: Post::Create 199 | end 200 | 201 | private 202 | 203 | def is_vasya_in_the_house? 204 | User.find_by(login: 'vasya').signed_in? 205 | end 206 | end 207 | ``` 208 | or 209 | ```ruby 210 | class PostsController < ApplicationController 211 | def create 212 | endpoint operation: Post::Create, 213 | different_cases: different_cases 214 | end 215 | 216 | private 217 | 218 | def different_cases 219 | { 220 | success: -> (result) { result.success? && is_vasya_in_the_house? } 221 | } 222 | end 223 | 224 | def is_vasya_in_the_house? 225 | User.find_by(login: 'vasya').signed_in? 226 | end 227 | end 228 | ``` 229 | 230 | #### Redefining handler for specific controller 231 | 232 | If you need to redefine handler logic, simply redefine `handler`. It'll redefine only `success` handler 233 | ```ruby 234 | class PostsController < ApplicationController 235 | handler do 236 | on(:success) { |result, **| head :ok } 237 | end 238 | 239 | def create 240 | endpoint operation: Post::Create 241 | end 242 | end 243 | ``` 244 | If you want remove parent handler settings you can use `inherit` option. It'll remove all other settings. 245 | ```ruby 246 | class PostsController < ApplicationController 247 | handler(inherit: false) do 248 | on(:success) { |result, **| head :ok } 249 | end 250 | 251 | def create 252 | endpoint operation: Post::Create 253 | end 254 | end 255 | ``` 256 | or redefine `default_handler` method 257 | ```ruby 258 | class PostsController < ApplicationController 259 | def create 260 | endpoint operation: Post::Create 261 | end 262 | 263 | private 264 | 265 | def default_handler 266 | { 267 | success: -> (result, **) { head :ok } 268 | } 269 | end 270 | end 271 | ``` 272 | 273 | #### Redefining handler for specific controller action 274 | 275 | ```ruby 276 | class PostsController < ApplicationController 277 | def create 278 | handler { on(:success) { |result, **| render json: { message: 'Nice!' }, status: :created } } 279 | endpoint operation: Post::Create, 280 | different_handler: different_handler 281 | end 282 | end 283 | 284 | ``` 285 | or 286 | ```ruby 287 | class PostsController < ApplicationController 288 | def create 289 | endpoint operation: Post::Create, 290 | different_handler: different_handler 291 | end 292 | 293 | private 294 | 295 | def different_handler 296 | { 297 | success: -> (result, **) { render json: { message: 'Nice!' }, status: :created } 298 | } 299 | end 300 | end 301 | ``` 302 | 303 | #### Defining default params for trailblazer operation 304 | 305 | Default `#endpoint_options` method implementation 306 | 307 | ```ruby 308 | def endpoint_options 309 | { params: params } 310 | end 311 | ``` 312 | 313 | Redefining `endpoint_options` 314 | It will extend existing options 315 | ```ruby 316 | class PostsController < ApplicationController 317 | endpoint_options { { params: permitted_params } } 318 | 319 | def permitted_params 320 | params.permit(:some, :attributes) 321 | end 322 | end 323 | ``` 324 | If you want to remove previously defined options you can use `inherit` option 325 | ```ruby 326 | class PostsController < ApplicationController 327 | endpoint_options(inherit: false) { { params: permitted_params } } 328 | 329 | def permitted_params 330 | params.permit(:some, :attributes) 331 | end 332 | end 333 | ``` 334 | Or redefine `endpoint_options` method 335 | ```ruby 336 | class PostsController < ApplicationController 337 | 338 | private 339 | 340 | def endpoint_options 341 | { params: permitted_params } 342 | end 343 | 344 | def permitted_params 345 | params.permit(:some, :attributes) 346 | end 347 | end 348 | ``` 349 | 350 | #### Passing additional params to operation 351 | 352 | `options` will be merged with `#endpoint_options` method result and trailblazer operation will be executed with such params: `Post::Create.(params: params, current_user: current_user)` 353 | 354 | ```ruby 355 | class PostsController < ApplicationController 356 | def create 357 | endpoint operation: Post::Create, 358 | options: { current_user: current_user } 359 | end 360 | end 361 | ``` 362 | 363 | #### Before handler actions 364 | 365 | You can do some actions before `#default_handler` execution 366 | 367 | ```ruby 368 | class PostsController < ApplicationController 369 | def create 370 | before_response do 371 | on(:success) do |result, **| 372 | response.headers['Some-header'] = result[:some_data] 373 | end 374 | end 375 | endpoint operation: Post::Create 376 | end 377 | end 378 | end 379 | ``` 380 | or 381 | ```ruby 382 | class PostsController < ApplicationController 383 | def create 384 | endpoint operation: Post::Create, 385 | before_response: before_render_actions 386 | end 387 | end 388 | 389 | private 390 | 391 | def before_response_actions 392 | { 393 | success: -> (result, **) { response.headers['Some-header'] = result[:some_data] } 394 | } 395 | end 396 | end 397 | ``` 398 | 399 | Code above will put data from operation result into response haeders before render 400 | 401 | 402 | #### Pass additional options from controller 403 | 404 | ```ruby 405 | class PostsController < ApplicationController 406 | def create 407 | endpoint operation: Post::Create, 408 | renderer_options: { serializer: SerializerClass } 409 | end 410 | end 411 | 412 | private 413 | 414 | def default_handler 415 | { 416 | # renderer_options will be available as **opts 417 | success: -> (result, **opts) { render json: result['model'], **opts, status: 200 }, 418 | invalid: -> (result, **) { render json: result['contract.default'].errors, serializer: ErrorSerializer, status: :unprocessable_entity } 419 | } 420 | end 421 | end 422 | ``` 423 | ### Migration from v1 to v2 424 | :warning::warning::warning: 425 | **Be cautious while using v2.x.x. We executing blocks and lambdas explicitly on the instance of the class where `SimpleEndpoint::Controller` module were included. That's related to `handler`/`#default_handler`, `cases`/`#default_cases` and `before_response`. 426 | If you are creating lambdas or blocks for handler and cases in different class with internal methods usage it won't work unlike the v1** 427 | :warning::warning::warning: 428 | 429 | ```ruby 430 | class Handler 431 | def self.call 432 | { 433 | success: ->(result, **) { result.success? && some_method? } 434 | } 435 | end 436 | 437 | private 438 | 439 | def some_method? 440 | true 441 | end 442 | end 443 | 444 | class ApplicationController 445 | def default_handler 446 | # works with v1 but will cause an error in v2 447 | Handler.call 448 | end 449 | end 450 | ``` 451 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'simple_endpoint' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/simple_endpoint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | 5 | require_relative 'simple_endpoint/errors' 6 | require_relative 'simple_endpoint/endpoint/endpoint_options' 7 | require_relative 'simple_endpoint/endpoint' 8 | require_relative 'simple_endpoint/controller/builder' 9 | require_relative 'simple_endpoint/controller/class_methods' 10 | require_relative 'simple_endpoint/controller' 11 | 12 | module SimpleEndpoint 13 | end 14 | -------------------------------------------------------------------------------- /lib/simple_endpoint/controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleEndpoint 4 | module Controller 5 | attr_accessor :__different_cases, :__different_handler, :__before_response 6 | 7 | def self.included(object) 8 | super 9 | object.extend(ClassMethods) 10 | end 11 | 12 | def endpoint(operation:, options: {}, **kwargs) 13 | result = operation.call(**endpoint_options, **options) 14 | options = Endpoint::EndpointOptions.new( 15 | result: result, default_handler: default_handler, default_cases: default_cases, invoker: self, **kwargs 16 | ) 17 | Endpoint.call(options) 18 | end 19 | 20 | private 21 | 22 | def endpoint_options 23 | { params: params } 24 | end 25 | 26 | def default_handler 27 | raise NotImplementedError, HANDLER_ERROR_MESSAGE 28 | end 29 | 30 | def default_cases 31 | raise NotImplementedError, CASES_ERROR_MESSAGE 32 | end 33 | 34 | def cases(&block) 35 | self.__different_cases = Builder.new(&block).to_h 36 | end 37 | 38 | def handler(&block) 39 | self.__different_handler = Builder.new(&block).to_h 40 | end 41 | 42 | def before_response(&block) 43 | self.__before_response = Builder.new(&block).to_h 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/simple_endpoint/controller/builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleEndpoint 4 | module Controller 5 | class Builder 6 | def initialize(&block) 7 | @config = {} 8 | instance_exec(&block) 9 | end 10 | 11 | def to_h 12 | @config 13 | end 14 | 15 | private 16 | 17 | def on(key, &block) 18 | @config[key] = block 19 | end 20 | 21 | alias match on 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/simple_endpoint/controller/class_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleEndpoint 4 | module Controller 5 | module ClassMethods 6 | attr_accessor :default_handler, :default_cases 7 | 8 | def inherited(subclass) 9 | super 10 | subclass.default_handler = default_handler.dup 11 | subclass.default_cases = default_cases.dup 12 | end 13 | 14 | def handler(inherit: true, &block) 15 | builder = Builder.new(&block) 16 | self.default_handler = ((inherit && default_handler) || {}).merge(builder.to_h) 17 | define_method(:default_handler) { self.class.default_handler } 18 | end 19 | 20 | def cases(inherit: true, &block) 21 | builder = Builder.new(&block) 22 | self.default_cases = ((inherit && default_cases) || {}).merge(builder.to_h) 23 | define_method(:default_cases) { self.class.default_cases } 24 | end 25 | 26 | def endpoint_options(inherit: true, &block) 27 | define_method(:endpoint_options) do 28 | (inherit ? super() : {}).merge(instance_exec(&block)) 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/simple_endpoint/endpoint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleEndpoint 4 | class Endpoint 5 | extend Forwardable 6 | 7 | attr_reader :options 8 | 9 | def_delegators :options, :result, :invoker, :renderer_options, :before_response, :handler, :cases 10 | 11 | def self.call(options) 12 | new(options).call 13 | end 14 | 15 | def initialize(options) 16 | @options = options 17 | end 18 | 19 | def call 20 | procees_handler(before_response) 21 | procees_handler(handler, strict: true) 22 | end 23 | 24 | def matched_case 25 | @matched_case ||= cases.detect { |_kase, condition| invoker.instance_exec(result, &condition) }&.first 26 | @matched_case || raise(OperationIsNotHandled) 27 | end 28 | 29 | def procees_handler(handler, strict: false) 30 | return invoker.instance_exec(result, **renderer_options, &handler[matched_case]) if handler.key?(matched_case) 31 | raise UnhandledResultError.new(matched_case, handler) if strict 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/simple_endpoint/endpoint/endpoint_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleEndpoint 4 | class Endpoint 5 | class EndpointOptions 6 | attr_reader :options 7 | 8 | def initialize(**options) 9 | @options = options 10 | end 11 | 12 | def invoker 13 | options[:invoker] 14 | end 15 | 16 | def result 17 | options[:result] 18 | end 19 | 20 | def renderer_options 21 | @renderer_options ||= options[:renderer_options] || {} 22 | end 23 | 24 | def before_response 25 | @before_response ||= (options[:before_response] || invoker.__before_response) || {} 26 | end 27 | 28 | def handler 29 | @handler ||= options[:default_handler].merge(options[:different_handler] || invoker.__different_handler || {}) 30 | end 31 | 32 | def cases 33 | @cases ||= options[:default_cases].merge(options[:different_cases] || invoker.__different_cases || {}) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/simple_endpoint/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleEndpoint 4 | class OperationIsNotHandled < StandardError 5 | OPERATION_IS_NOT_HANDLED_ERROR = 'Current operation result is not handled at specified cases' 6 | 7 | def initialize 8 | super(OPERATION_IS_NOT_HANDLED_ERROR) 9 | end 10 | end 11 | 12 | class UnhandledResultError < StandardError 13 | def initialize(matched_case, handler) 14 | super("Key: #{matched_case} is not present at #{handler}") 15 | end 16 | end 17 | 18 | HANDLER_ERROR_MESSAGE = <<-LARGE_ERROR 19 | Please specify handler 20 | 21 | EXAMPLE: 22 | ############################################### 23 | 24 | # Can be put into ApplicationController and redefined in subclasses 25 | 26 | class Controller 27 | handler 28 | on(:) { |result, **| } 29 | ... 30 | end 31 | end 32 | 33 | ############################################### 34 | LARGE_ERROR 35 | 36 | CASES_ERROR_MESSAGE = <<-LARGE_ERROR 37 | Please define cases 38 | 39 | EXAMPLE: 40 | ############################################### 41 | # default trailblazer-endpoint logic, you can change it 42 | # Can be put into ApplicationController and redefined in subclasses 43 | 44 | class Controller 45 | cases do 46 | match(:) { |result| } 47 | ... 48 | end 49 | end 50 | 51 | ############################################### 52 | LARGE_ERROR 53 | end 54 | -------------------------------------------------------------------------------- /lib/simple_endpoint/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SimpleEndpoint 4 | VERSION = '2.0.0' 5 | end 6 | -------------------------------------------------------------------------------- /simple_endpoint.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'simple_endpoint/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'simple_endpoint' 9 | spec.version = SimpleEndpoint::VERSION 10 | spec.authors = ['Alex Bal'] 11 | spec.email = ['differencialx@gmail.com'] 12 | 13 | spec.summary = 'Simple implementation of Trailblazer endpoint' 14 | spec.description = 'Dry-matcher free implementation of trailblazer endpoint, with ability to redefine matchers and handlers behavior for separate controllers or actions.' 15 | spec.homepage = 'https://github.com/differencialx/simple_endpoint' 16 | 17 | # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' 18 | # to allow pushing to a single host or delete this section to allow pushing to any host. 19 | if spec.respond_to?(:metadata) 20 | spec.metadata['allowed_push_host'] = 'https://rubygems.org' 21 | else 22 | raise 'RubyGems 2.0 or newer is required to protect against ' \ 23 | 'public gem pushes.' 24 | end 25 | 26 | # Specify which files should be added to the gem when it is released. 27 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 28 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 29 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 30 | end 31 | spec.bindir = 'exe' 32 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 33 | spec.require_paths = ['lib'] 34 | 35 | spec.add_development_dependency 'bundler', '>= 2.1.0' 36 | spec.add_development_dependency 'pry-byebug', '~> 3.9.0' 37 | spec.add_development_dependency 'rake', '~> 13.0' 38 | spec.add_development_dependency 'rspec', '~> 3.0' 39 | spec.add_development_dependency 'rubocop', '~> 1.25.1' 40 | spec.add_development_dependency 'rubocop-rspec', '~> 2.10.0' 41 | spec.add_development_dependency 'rubocop-performance', '~> 1.14.0' 42 | spec.add_development_dependency 'simplecov', '~> 0.21.2' 43 | end 44 | -------------------------------------------------------------------------------- /spec/simple_endpoint/controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe SimpleEndpoint::Controller do 4 | describe '#endpoint' do 5 | subject(:result) { klass.new.endpoint(operation: operation, options: options) } 6 | 7 | let(:klass) do 8 | Class.new do 9 | include SimpleEndpoint::Controller 10 | 11 | handler { on(:success) { |result| result[:value] = :success } } 12 | cases { on(:success) { |result| result[:value] = :success } } 13 | 14 | def params 15 | { param: :param_value } 16 | end 17 | end 18 | end 19 | # rubocop:disable RSpec/VerifiedDoubleReference 20 | let(:operation) { instance_double('dummy_operation') } 21 | # rubocop:enable RSpec/VerifiedDoubleReference 22 | let(:options) { { option: :option_value } } 23 | 24 | before { allow(operation).to receive(:call) } 25 | 26 | context 'when everything is implemented' do 27 | before do 28 | allow(SimpleEndpoint::Endpoint).to receive(:call) 29 | result 30 | end 31 | 32 | it 'invokes operation' do 33 | expect(operation).to have_received(:call).with(params: { param: :param_value }, **options) 34 | end 35 | 36 | it 'invokes Endpoint class' do 37 | expect(SimpleEndpoint::Endpoint).to have_received(:call).with( 38 | instance_of(SimpleEndpoint::Endpoint::EndpointOptions) 39 | ) 40 | end 41 | end 42 | 43 | context 'when default_handler is not redefined' do 44 | let(:klass) do 45 | Class.new do 46 | include SimpleEndpoint::Controller 47 | 48 | def params 49 | { param: :param_value } 50 | end 51 | end 52 | end 53 | 54 | it 'raises NotImplementedError with message' do 55 | expect { result }.to raise_error(NotImplementedError, SimpleEndpoint::HANDLER_ERROR_MESSAGE) 56 | end 57 | end 58 | 59 | context 'when default_cases is not redefined' do 60 | let(:klass) do 61 | Class.new do 62 | include SimpleEndpoint::Controller 63 | 64 | handler { on(:handler_status) { :handler } } 65 | 66 | def params 67 | { param: :param_value } 68 | end 69 | end 70 | end 71 | 72 | it 'raises NotImplementedError with message' do 73 | expect { result }.to raise_error(NotImplementedError, SimpleEndpoint::CASES_ERROR_MESSAGE) 74 | end 75 | end 76 | end 77 | 78 | describe '.handler' do 79 | subject(:instance) { klass.new } 80 | 81 | let(:klass) do 82 | Class.new do 83 | include SimpleEndpoint::Controller 84 | 85 | handler { on(:handler_status) { :handler } } 86 | end 87 | end 88 | 89 | it 'creates default_handler method with provided settings' do 90 | expect(instance.default_handler[:handler_status].call).to eq(:handler) 91 | end 92 | 93 | context 'when handler is called in child class' do 94 | subject(:instance) { child_klass.new } 95 | 96 | let(:child_klass) do 97 | Class.new(klass) do 98 | handler { on(:another_handler_status) { :child_handler } } 99 | end 100 | end 101 | 102 | it 'copies parent default_handler' do 103 | expect(instance.default_handler[:handler_status].call).to eq(:handler) 104 | end 105 | 106 | it 'adds new handler settings' do 107 | expect(instance.default_handler[:another_handler_status].call).to eq(:child_handler) 108 | end 109 | end 110 | 111 | context 'when handler is called in child class with inherit: false' do 112 | subject(:instance) { child_klass.new } 113 | 114 | let(:child_klass) do 115 | Class.new(klass) do 116 | handler(inherit: false) { on(:another_handler_status) { :child_handler } } 117 | end 118 | end 119 | 120 | it 'does not copy parent default_handler' do 121 | expect(instance.default_handler).not_to include(:handler_status) 122 | end 123 | 124 | it 'adds new handler settings' do 125 | expect(instance.default_handler[:another_handler_status].call).to eq(:child_handler) 126 | end 127 | end 128 | end 129 | 130 | describe '.cases' do 131 | subject(:instance) { klass.new } 132 | 133 | let(:klass) do 134 | Class.new do 135 | include SimpleEndpoint::Controller 136 | 137 | cases { match(:case_status) { :case_handler } } 138 | end 139 | end 140 | 141 | it 'creates default_cases method with provided settings' do 142 | expect(instance.default_cases[:case_status].call).to eq(:case_handler) 143 | end 144 | 145 | context 'when cases is called in child class' do 146 | subject(:instance) { child_klass.new } 147 | 148 | let(:child_klass) do 149 | Class.new(klass) do 150 | cases { match(:another_case_status) { :child_case_handler } } 151 | end 152 | end 153 | 154 | it 'copies parent default_cases' do 155 | expect(instance.default_cases[:case_status].call).to eq(:case_handler) 156 | end 157 | 158 | it 'adds new cases settings' do 159 | expect(instance.default_cases[:another_case_status].call).to eq(:child_case_handler) 160 | end 161 | end 162 | 163 | context 'when cases is called in child class with inherit: false' do 164 | subject(:instance) { child_klass.new } 165 | 166 | let(:child_klass) do 167 | Class.new(klass) do 168 | cases(inherit: false) { match(:another_case_status) { :child_case_handler } } 169 | end 170 | end 171 | 172 | it 'does not copy parent default_cases' do 173 | expect(instance.default_cases).not_to include(:case_status) 174 | end 175 | 176 | it 'adds new cases settings' do 177 | expect(instance.default_cases[:another_case_status].call).to eq(:child_case_handler) 178 | end 179 | end 180 | end 181 | 182 | describe '.endpoint_options' do 183 | subject(:instance) { klass.new } 184 | 185 | let(:klass) do 186 | Class.new do 187 | include SimpleEndpoint::Controller 188 | 189 | endpoint_options { { option: :option_value } } 190 | 191 | def params 192 | { param: :param_value } 193 | end 194 | end 195 | end 196 | 197 | it 'creates endpoint_options method' do 198 | expect(instance.endpoint_options).to eq({ params: { param: :param_value }, option: :option_value }) 199 | end 200 | 201 | context 'when endpoint_options uses another method' do 202 | let(:klass) do 203 | Class.new do 204 | include SimpleEndpoint::Controller 205 | 206 | endpoint_options { { option: option } } 207 | 208 | def params 209 | { param: :param_value } 210 | end 211 | 212 | def option 213 | :option_value 214 | end 215 | end 216 | end 217 | 218 | it 'creates endpoint_options method' do 219 | expect(instance.endpoint_options).to eq({ params: { param: :param_value }, option: :option_value }) 220 | end 221 | end 222 | 223 | context 'when endpoint_options is called in child class' do 224 | subject(:instance) { child_klass.new } 225 | 226 | let(:child_klass) do 227 | Class.new(klass) do 228 | endpoint_options { { another_option: :another_option_value } } 229 | end 230 | end 231 | 232 | it 'returns parent and child options' do 233 | expect(instance.endpoint_options).to eq({ params: { param: :param_value }, 234 | option: :option_value, 235 | another_option: :another_option_value }) 236 | end 237 | end 238 | 239 | context 'when endpoint_options is called in child class with inherit: false' do 240 | subject(:instance) { child_klass.new } 241 | 242 | let(:child_klass) do 243 | Class.new(klass) do 244 | endpoint_options(inherit: false) { { another_option: :another_option_value } } 245 | end 246 | end 247 | 248 | it 'returns parent and child options' do 249 | expect(instance.endpoint_options).to eq({ another_option: :another_option_value }) 250 | end 251 | end 252 | end 253 | 254 | describe '#handler' do 255 | subject(:instance) do 256 | Class.new do 257 | include SimpleEndpoint::Controller 258 | 259 | def action 260 | handler { on(:success) { |result| result } } 261 | end 262 | end.new 263 | end 264 | 265 | before { instance.action } 266 | 267 | it 'saves different_handler inside __diferrent_handler accessor' do 268 | expect(instance.__different_handler).to include(:success) 269 | end 270 | end 271 | 272 | describe '#cases' do 273 | subject(:instance) do 274 | Class.new do 275 | include SimpleEndpoint::Controller 276 | 277 | def action 278 | cases { match(:success) { |result| result[:success] } } 279 | end 280 | end.new 281 | end 282 | 283 | before { instance.action } 284 | 285 | it 'saves different_cases inside __diferrent_cases accessor' do 286 | expect(instance.__different_cases).to include(:success) 287 | end 288 | end 289 | 290 | describe '#before_response' do 291 | subject(:instance) do 292 | Class.new do 293 | include SimpleEndpoint::Controller 294 | 295 | def action 296 | before_response { on(:success) { |result| result[:value] = :modified_value } } 297 | end 298 | end.new 299 | end 300 | 301 | before { instance.action } 302 | 303 | it 'saves before_response inside __before_response accessor' do 304 | expect(instance.__before_response).to include(:success) 305 | end 306 | end 307 | end 308 | -------------------------------------------------------------------------------- /spec/simple_endpoint/endpoint_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe SimpleEndpoint::Endpoint do 4 | describe '.call' do 5 | subject(:endpoint) { described_class.call(endpoint_options) } 6 | 7 | let(:endpoint_options) do 8 | SimpleEndpoint::Endpoint::EndpointOptions.new( 9 | result: result, invoker: invoker, default_handler: handler, default_cases: cases 10 | ) 11 | end 12 | let(:invoker) do 13 | Class.new do 14 | include SimpleEndpoint::Controller 15 | end.new 16 | end 17 | let(:result) { { success: true, value: :value } } 18 | let(:handler) { { success: ->(result, **) { result[:value] } } } 19 | let(:cases) { { success: ->(result) { result[:success] } } } 20 | 21 | context 'when result is successful' do 22 | it 'returns handler result' do 23 | expect(endpoint).to eq(result[:value]) 24 | end 25 | 26 | context 'when handler contains invoker method' do 27 | let(:endpoint_options) do 28 | SimpleEndpoint::Endpoint::EndpointOptions.new( 29 | result: result, invoker: invoker, default_handler: handler, default_cases: cases 30 | ) 31 | end 32 | let(:handler) { { success: ->(_, **) { handle_status } } } 33 | let(:invoker) do 34 | Class.new do 35 | include SimpleEndpoint::Controller 36 | 37 | def handle_status 38 | :value 39 | end 40 | end.new 41 | end 42 | 43 | it 'returns handler result' do 44 | expect(endpoint).to eq(result[:value]) 45 | end 46 | end 47 | 48 | context 'when cases contains invoker method' do 49 | let(:endpoint_options) do 50 | SimpleEndpoint::Endpoint::EndpointOptions.new( 51 | result: result, invoker: invoker, default_handler: handler, default_cases: cases 52 | ) 53 | end 54 | let(:cases) { { success: ->(_) { success_result? } } } 55 | let(:invoker) do 56 | Class.new do 57 | include SimpleEndpoint::Controller 58 | 59 | def success_result? 60 | true 61 | end 62 | end.new 63 | end 64 | 65 | it 'returns handler result' do 66 | expect(endpoint).to eq(result[:value]) 67 | end 68 | end 69 | end 70 | 71 | context 'with renderer options' do 72 | let(:endpoint_options) do 73 | SimpleEndpoint::Endpoint::EndpointOptions.new( 74 | result: result, invoker: invoker, default_handler: handler, default_cases: cases, 75 | renderer_options: { option: :option_value } 76 | ) 77 | end 78 | let(:handler) { { success: ->(result, **options) { result[:options] = options } } } 79 | 80 | before { endpoint } 81 | 82 | it 'passes renderer options to handler' do 83 | expect(result.dig(:options, :option)).to eq(:option_value) 84 | end 85 | end 86 | 87 | context 'with before response that is not match result status' do 88 | let(:endpoint_options) do 89 | SimpleEndpoint::Endpoint::EndpointOptions.new( 90 | result: result, invoker: invoker, default_handler: handler, default_cases: cases, 91 | before_response: { failure: ->(result, **) { result[:value] = :modified_value } } 92 | ) 93 | end 94 | 95 | it 'does not modify result' do 96 | endpoint 97 | expect(result[:value]).not_to eq(:modified_value) 98 | end 99 | end 100 | 101 | context 'with before response that is stored inside invoker' do 102 | let(:endpoint_options) do 103 | SimpleEndpoint::Endpoint::EndpointOptions.new( 104 | result: result, invoker: invoker, default_handler: handler, default_cases: cases 105 | ) 106 | end 107 | let(:invoker) do 108 | Class.new do 109 | include SimpleEndpoint::Controller 110 | end.new 111 | end 112 | 113 | before do 114 | invoker.__before_response = { success: ->(result, **) { result[:value] = :modified_value } } 115 | endpoint 116 | end 117 | 118 | it 'modifies result' do 119 | expect(result[:value]).to eq(:modified_value) 120 | end 121 | end 122 | 123 | context 'with before response' do 124 | let(:endpoint_options) do 125 | SimpleEndpoint::Endpoint::EndpointOptions.new( 126 | result: result, invoker: invoker, default_handler: handler, default_cases: cases, 127 | before_response: { success: ->(result, **) { result[:value] = :modified_value } } 128 | ) 129 | end 130 | 131 | it 'modifies result' do 132 | endpoint 133 | expect(result[:value]).to eq(:modified_value) 134 | end 135 | end 136 | 137 | context 'with different_handler that is stored inside invoker' do 138 | let(:endpoint_options) do 139 | SimpleEndpoint::Endpoint::EndpointOptions.new( 140 | result: result, invoker: invoker, default_handler: handler, default_cases: cases 141 | ) 142 | end 143 | let(:invoker) do 144 | Class.new do 145 | include SimpleEndpoint::Controller 146 | end.new 147 | end 148 | 149 | before do 150 | invoker.__different_handler = { success: ->(result, **) { result[:value] = :modified_value } } 151 | endpoint 152 | end 153 | 154 | it 'modifies result' do 155 | expect(result[:value]).to eq(:modified_value) 156 | end 157 | end 158 | 159 | context 'with different_handler' do 160 | let(:endpoint_options) do 161 | SimpleEndpoint::Endpoint::EndpointOptions.new( 162 | result: result, invoker: invoker, default_handler: handler, default_cases: cases, 163 | different_handler: { success: ->(result, **) { result[:value] = :modified_value } } 164 | ) 165 | end 166 | let(:invoker) do 167 | Class.new do 168 | include SimpleEndpoint::Controller 169 | end.new 170 | end 171 | 172 | before { endpoint } 173 | 174 | it 'modifies result' do 175 | expect(result[:value]).to eq(:modified_value) 176 | end 177 | end 178 | 179 | context 'with different_cases that are stored inside invoker' do 180 | let(:endpoint_options) do 181 | SimpleEndpoint::Endpoint::EndpointOptions.new( 182 | result: result, invoker: invoker, default_handler: handler, default_cases: cases 183 | ) 184 | end 185 | let(:invoker) do 186 | Class.new do 187 | include SimpleEndpoint::Controller 188 | end.new 189 | end 190 | 191 | before { invoker.__different_cases = { success: ->(result) { result[:value] == :value } } } 192 | 193 | it 'modifies result' do 194 | expect(endpoint).to eq(:value) 195 | end 196 | end 197 | 198 | context 'with different_cases' do 199 | let(:endpoint_options) do 200 | SimpleEndpoint::Endpoint::EndpointOptions.new( 201 | result: result, invoker: invoker, default_handler: handler, default_cases: cases, 202 | different_cases: { success: ->(result) { result[:value] == :value } } 203 | ) 204 | end 205 | let(:invoker) do 206 | Class.new do 207 | include SimpleEndpoint::Controller 208 | end.new 209 | end 210 | 211 | it 'modifies result' do 212 | expect(endpoint).to eq(:value) 213 | end 214 | end 215 | 216 | context 'when there is no matched case' do 217 | let(:cases) { {} } 218 | 219 | it 'raises OperationIsNotHandled error with message' do 220 | expect { endpoint }.to raise_error( 221 | SimpleEndpoint::OperationIsNotHandled, SimpleEndpoint::OperationIsNotHandled::OPERATION_IS_NOT_HANDLED_ERROR 222 | ) 223 | end 224 | end 225 | 226 | context 'when there is no matched case handler' do 227 | let(:handler) { {} } 228 | 229 | it 'raises UnhandledResultError error with message' do 230 | expect { endpoint }.to raise_error(SimpleEndpoint::UnhandledResultError) 231 | end 232 | end 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'simplecov' 5 | require 'simple_endpoint' 6 | 7 | Dir["#{Dir.pwd}/spec/support/**/*.rb"].each { |f| require f } 8 | 9 | RSpec.configure do |config| 10 | # Enable flags like --only-failures and --next-failure 11 | config.example_status_persistence_file_path = '.rspec_status' 12 | 13 | # Disable RSpec exposing methods globally on `Module` and `main` 14 | config.disable_monkey_patching! 15 | 16 | config.expect_with :rspec do |c| 17 | c.syntax = :expect 18 | end 19 | end 20 | --------------------------------------------------------------------------------