├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── Guardfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── gemfiles ├── rails_6.0.gemfile └── rails_6.1.gemfile ├── lib ├── services.rb └── services │ ├── asyncable.rb │ ├── base.rb │ ├── logger │ ├── file.rb │ ├── null.rb │ └── redis.rb │ ├── modules │ ├── call_logger.rb │ ├── exception_wrapper.rb │ ├── object_class.rb │ └── uniqueness_checker.rb │ ├── query.rb │ ├── railtie.rb │ └── version.rb ├── services.gemspec └── spec ├── services ├── asyncable_spec.rb ├── base_spec.rb ├── configuration_spec.rb ├── logger │ └── redis_spec.rb ├── modules │ ├── call_logger_spec.rb │ ├── exception_wrapper_spec.rb │ └── uniqueness_checker_spec.rb └── query_spec.rb ├── spec_helper.rb └── support ├── activerecord_models_and_services.rb ├── call_proxy.rb ├── helpers.rb ├── log └── .gitkeep ├── redis-cli ├── redis-server ├── shared.rb └── test_services.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | 6 | tests: 7 | name: Tests 8 | runs-on: ubuntu-latest 9 | 10 | env: 11 | RAILS_ENV: test 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | ruby_version: 17 | - 3.0 18 | - 2.7 19 | 20 | services: 21 | 22 | redis: 23 | image: redis 24 | options: --entrypoint redis-server 25 | ports: 26 | - 6379:6379 27 | 28 | steps: 29 | 30 | - name: Check out code 31 | uses: actions/checkout@v4 32 | 33 | - name: Set up Ruby 34 | uses: ruby/setup-ruby@v1 35 | with: 36 | ruby-version: ${{ matrix.ruby_version }} 37 | bundler-cache: true 38 | 39 | - name: Install gems 40 | run: bundle exec appraisal install 41 | 42 | - name: Run tests 43 | run: bundle exec appraisal rspec 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | gemfiles/*.gemfile.lock 3 | spec/support/log/**/*.log 4 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise 'rails_6.0' do 2 | gem 'rails', '~> 6.0.0' 3 | end 4 | 5 | appraise 'rails_6.1' do 6 | gem 'rails', '~> 6.1.0' 7 | end 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 9.0.0 2 | 3 | * Drop support for Rails 5.2 and Ruby 2.6 4 | * Exclude test files from release 5 | * Add CI via GitHub Actions 6 | * Make replacing ActiveRecord records in asyncable params more robust 7 | * When calling a service async, make args and kwargs available as ivars 8 | 9 | ## 8.0.1 10 | 11 | * add missing `to_s` 12 | 13 | ## 8.0.0 14 | 15 | * handle kwargs correctly 16 | * test with current rails versions 17 | 18 | ## 7.3.3 19 | 20 | * fix typo 21 | * increase cache time to 30 days 22 | 23 | ## 7.3.1 24 | 25 | * fix loading background processor 26 | 27 | ## 7.3.0 28 | 29 | * allow using sucker_punch instead of sidekiq 30 | * allow setting the method arity when allowing class method to be used in queries, since scopes shadow the arity of their procs 31 | 32 | ## 7.2.0 33 | 34 | * enable using class methods in queries 35 | 36 | ## 7.1.2 37 | 38 | * use select instead of pluck 39 | 40 | ## 7.1.1 41 | 42 | * fix typos 43 | 44 | ## 7.1.0 45 | 46 | * fix joining order classes 47 | * refactor, formatting 48 | 49 | ## 7.0.3 50 | 51 | * fix identifying call method when calling service async 52 | * remove gemnasium badge 53 | * fix "buy me a coffee" link 54 | * add "buy me a coffee" link to readme 55 | 56 | ## 7.0.2 57 | 58 | * symbolize hash keys for arguments to call method when kwargs are used 59 | * dont modify args 60 | * fix spec 61 | * fix specs and readme 62 | 63 | ## 7.0.1 64 | 65 | * add table name to condition 66 | 67 | ## 7.0.0 68 | 69 | * add created_after and created_before as default conditions, allow overriding default conditions in query object 70 | 71 | ## 6.0.5 72 | 73 | * Revert "set default order for query if it was set to nil" 74 | 75 | ## 6.0.4 76 | 77 | * automatically add LEFT OUTER JOIN when trying to sort by a field on an association table 78 | * set default order for query if it was set to nil 79 | 80 | ## 6.0.3 81 | 82 | * allow calling query service without params 83 | 84 | ## 6.0.2 85 | 86 | * add id_not as special query condition 87 | 88 | ## 6.0.1 89 | 90 | * allow calling query object with only arguments 91 | * update sidekiq dev dependency to 5.x 92 | * remove encoding comment 93 | * add missing require 94 | * syntax 95 | 96 | ## 6.0.0 97 | 98 | * dont specify ruby patchlevels in travis config 99 | * use globalid to make args serializable 100 | * remove bulk call async method 101 | * remove async instance methods from services 102 | 103 | ## 5.1.2 104 | 105 | * dup passed in scope in query class before using it 106 | * fix specs for ruby < 2.4 107 | * update rails 5.1 to released version 108 | * update travis config 109 | * stop supporting rails 4.0 and 4.1, add 5.1.rc1 110 | 111 | ## 5.1.1 112 | 113 | * replace Fixnum with Integer to silence Ruby 2.4 warnings 114 | * update ruby versions in travis config 115 | * update travis config 116 | * update ruby versions in travis config 117 | * update travis build matrix, dont test ruby 2.4.0 with rails 4.0 or 4.1, allow ruby 2.4.0 with rails 4.2 to fail for now (until rails 4.2.8 is released with 118 | out json 1.x dependency) 119 | * increase sleep and waiting time in specs 120 | * require active_record explicitly 121 | * fix specs 122 | 123 | ## 5.1.0 124 | 125 | * test with ruby 2.4.0, clarify version requirements for ruby and redis 126 | * update sidekiq dev dependency to ~> 4.0 127 | * allow passing scope to query call 128 | 129 | ## 5.0.0 130 | 131 | * freeze constants 132 | * use #public_send instead of #send where possible 133 | * rename perform_* methods to call_*, closes #6 134 | 135 | ## 4.3.0 136 | 137 | * update travis config 138 | * use pluck only on activerecord relations, from activesupport 5 on all enumerables respond to #pluck, which will call #[] on the contained objects [Manuel M 139 | eurer] 140 | * drop support for rails 3.2, add appraisal for testing 141 | * update readme 142 | * update ruby dependency in readme 143 | * update travis config 144 | 145 | ## 4.1.4 146 | 147 | * update changelog 148 | * fix finding find service class 149 | 150 | ## 4.1.3 151 | 152 | * Fix finding the find service class 153 | 154 | ## 4.1.2 155 | 156 | * Make "Services" namespace optional when determining object class 157 | 158 | ## 4.1.1 159 | 160 | * Try to determine Redis connection from `Redis.current` if not explicitly set in configuration 161 | 162 | ## 4.1.0 163 | 164 | * Add possibility to automatically convert condition objects to IDs in query 165 | 166 | ## 4.0.2 167 | 168 | * Add null logger 169 | 170 | ## 4.0.1 171 | 172 | * Account for that `redis.multi` can return nil 173 | 174 | ## 4.0.0 175 | 176 | * Remove host configuration and controller method 177 | 178 | ## 3.1.1 179 | 180 | * Query does not have its own error, raise ArgumentError instead 181 | * Verify that query ids parameter is not nil 182 | 183 | ## 3.0.1 184 | 185 | * Fix for Ruby 2.0 186 | 187 | ## 3.0.0 188 | 189 | * Rename `BaseFinder` to `Query` 190 | * `Query` doesn't inherit from `Base` anymore 191 | * Only use SQL subquery in `Query` if a JOIN is used 192 | 193 | ## 2.2.4 194 | 195 | * Increase TTL for Redis keys for uniqueness and error count to one day 196 | * Fix ordering in `BaseFinder` 197 | 198 | ## 2.2.3 199 | 200 | * Add `on_error` option `return` to uniqueness checker 201 | 202 | ## 2.1.0 203 | 204 | * Add `find_ids` and `find_id` helpers to base service 205 | 206 | ## 2.0.2 207 | 208 | * Make BaseFinder smarter, don't create SQL subquery if not necessary 209 | 210 | ## 2.0.1 211 | 212 | * Fix disabling call logging 213 | 214 | ## 2.0.0 215 | 216 | * Improve call logging 217 | * Implement `disable_call_logging` and `enable_call_logging` to control call logging for specific services 218 | * Disable call logging for `BaseFinder` by default 219 | * Rename `check_uniqueness!` to `check_uniqueness` 220 | 221 | ## 1.3.0 222 | 223 | * Allow only certain classes in Redis logger meta (NilClass, TrueClass, FalseClass, Symbol, String, Numeric) 224 | 225 | ## 1.2.0 226 | 227 | * Convert log time to time object when fetching logs 228 | 229 | ## 1.1.1 230 | 231 | * When logging to Redis, convert all values to strings first 232 | 233 | ## 1.1.0 234 | 235 | * Change arguments for log call in file and Redis logger, replace tag array with meta hash 236 | * Add methods to query size of logs and fetch logs to Redis logger 237 | 238 | ## 1.0.0 239 | 240 | * First stable version 241 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | require 'guard/rspec' 2 | 3 | module ::Guard 4 | class RSpec < Plugin 5 | # Add `stop` method if not defined 6 | # so that `stop_*` callbacks work. 7 | unless instance_methods.include?(:stop) 8 | def stop; end 9 | end 10 | end 11 | 12 | module ServicesGemHelpers 13 | SPEC_SUPPORT_DIR = Pathname.new(File.expand_path('../spec/support', __FILE__)) 14 | REDIS_BIN = SPEC_SUPPORT_DIR.join('redis-server') 15 | REDIS_CLI = SPEC_SUPPORT_DIR.join('redis-cli') 16 | REDIS_PIDFILE = SPEC_SUPPORT_DIR.join('redis.pid') 17 | REDIS_LOGFILE = SPEC_SUPPORT_DIR.join('log', 'redis.log') 18 | REDIS_PORT = 6379 19 | 20 | def self.options_to_string(options) 21 | options.map { |k, v| "-#{'-' if k.length > 1}#{k} #{v}" }.join(' ') 22 | end 23 | 24 | class OnStart 25 | def call(guard_class, event, *args) 26 | options = { 27 | daemonize: 'yes', 28 | dir: SPEC_SUPPORT_DIR, 29 | dbfilename: 'redis.rdb', 30 | logfile: REDIS_LOGFILE, 31 | pidfile: REDIS_PIDFILE, 32 | port: REDIS_PORT 33 | } 34 | system "#{REDIS_BIN} #{ServicesGemHelpers.options_to_string options}" 35 | 36 | i = 0 37 | while !File.exist?(REDIS_PIDFILE) 38 | puts 'Waiting for Redis to start...' 39 | sleep 1 40 | i += 1 41 | raise "Redis didn't start in #{i} seconds." if i >= 5 42 | end 43 | end 44 | end 45 | 46 | class OnStop 47 | def call(guard_class, event, *args) 48 | options = { 49 | p: REDIS_PORT 50 | } 51 | system "#{REDIS_CLI} #{ServicesGemHelpers.options_to_string options} shutdown" 52 | 53 | i = 0 54 | while File.exist?(REDIS_PIDFILE) 55 | puts 'Waiting for Redis to stop...' 56 | sleep 1 57 | i += 1 58 | raise "Redis didn't stop in #{i} seconds." if i >= 5 59 | end 60 | end 61 | end 62 | end 63 | end 64 | 65 | guard 'rspec', cmd: 'bundle exec appraisal rspec' do 66 | callback ServicesGemHelpers::OnStart.new, :start_begin 67 | callback ServicesGemHelpers::OnStop.new, :stop_begin 68 | 69 | # Specs 70 | watch(%r(^spec/.+_spec\.rb$)) 71 | 72 | # Files 73 | watch(%r(^lib/(.+)\.rb$)) { "spec/#{_1[1]}_spec.rb" } 74 | end 75 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 kraut computing UG (haftungsbeschränkt) 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Services 2 | 3 | [![Gem Version](https://badge.fury.io/rb/services.png)](http://badge.fury.io/rb/services) 4 | [![CI](https://github.com/manuelmeurer/services/actions/workflows/ci.yml/badge.svg)](https://github.com/manuelmeurer/services/actions/workflows/ci.yml) 5 | 6 | Services is a collection of modules and base classes that let you simply add a service layer to your Rails app. 7 | 8 | ## Motivation 9 | 10 | A lot has been written about service layers (service objects, SOA, etc.) for Rails. There are of course advantages and disadvantages, but after using Services since 2013 in several Rails apps, I must say that in my opinion the advantages far outweigh the disadvantages. 11 | 12 | **The biggest benefit you get when using a service layer, in my opinion, is that it gets so much easier to reason about your application, find a bug, or implement new features, when all your business logic is in services, not scattered in models, controllers, helpers etc.** 13 | 14 | ## Usage 15 | 16 | For disambiguation: in this README, when you read "Services" with a uppercase "S", this gem is meant, whereas with "services", well, the plural of service is meant. 17 | 18 | ### Requirements 19 | 20 | #### Ruby >= 2.7 21 | 22 | #### Rails >= 6.0 23 | 24 | #### Redis >= 3.0 25 | 26 | Redis is used at several points, e.g. to store information about the currently running services, so you can enforce uniqueness for specific services, i.e. make sure no more than one instance of such a service is executed simultaneously. 27 | 28 | #### Postgres (optional) 29 | 30 | The SQL that `Services::Query` (discussed further down) generates is optimized for Postgres. It might work with other databases but it's not guaranteed. If you're not using Postgres, you can still use all other parts of Services, just don't use `Services::Query` or, even better, submit a [pull request](https://github.com/manuelmeurer/services/issues) that fixes it to work with your database! 31 | 32 | #### Sidekiq (optional) 33 | 34 | To process services in the background, Services uses [Sidekiq](https://github.com/mperham/sidekiq). If you don't need background processing, you can still use Services without Sidekiq. When you then try to enqueue a service for background processing, an exception will be raised. If you use Sidekiq, make sure to load the Services gem after the Sidekiq gem. 35 | 36 | ### Basic principles 37 | 38 | Services is based on a couple of basic principles around what a service should be and do in your app: 39 | 40 | A service... 41 | 42 | * does only one thing and does it well (Unix philosophy) 43 | * can be run synchronously (i.e. blocking/in the foreground) or asynchronously (i.e. non-blocking/in the background) 44 | * can be configured as "unique", meaning only one instance of it should be run at any time (including or ignoring parameters) 45 | * logs all the things (start time, end time, duration, caller, exceptions etc.) 46 | * has its own exception class(es) which all exceptions that might be raised inherit from 47 | * does not care whether certain parameters are objects or object IDs 48 | 49 | Apart from these basic principles, you are free to implement the actual logic in a service any way you want. 50 | 51 | ### Conventions 52 | 53 | Follow these conventions when using Services in your Rails app, and you'll be fine: 54 | 55 | * Let your services inherit from `Services::Base` 56 | * Let your query objects inherit from `Services::Query` 57 | * Put your services in `app/services/` 58 | * Decide if you want to use a `Services` namespace or not. Namespacing your service allows you to use a name for them that some other class or module in your app has (e.g. you can have a `Services::Maintenance` service, yet also a `Maintenance` module in `lib`). Not using a namespace saves you from writing `Services::` everytime you want to reference a service in your app. Both approaches are fine, pick one and stick to it. 59 | * Give your services "verby" names, e.g. `app/services/users/delete.rb` defines `Users::Delete` (or `Services::Users::Delete`, see above). If a service operates on multiple models or no models at all, don't namespace them (`Services::DoStuff`) or namespace them by logical groups unrelated to models (`Services::Maintenance::CleanOldStuff`, `Services::Maintenance::SendDailySummary`, etc.) 60 | * Some services call other services. Try to not combine multiple calls to other services and business logic in one service. Instead, some services should contain only business logic and other services only a bunch of service calls but no (or little) business logic. This keeps your services nice and modular. 61 | 62 | ### Configuration 63 | 64 | You can/should configure Services in an initializer: 65 | 66 | ```ruby 67 | # config/initializers/services.rb 68 | Services.configure do |config| 69 | config.logger = Services::Logger::Redis.new(Redis.new) # see [Logging](#Logging) 70 | config.redis = Redis.new # optional, if `Redis.current` is defined. Otherwise it is recommended to use 71 | # a [connection pool](https://github.com/mperham/connection_pool) here instead of simply `Redis.new`. 72 | end 73 | ``` 74 | 75 | ### Rails autoload fix for `Services` namespace 76 | 77 | By default, Rails expects `app/services/users/delete.rb` to define `Users::Delete`. If you want to use the `Services` namespace for your services, we want it to expect `Services::Users::Delete`. To make this work, add the `app` folder to the autoload path: 78 | 79 | ```ruby 80 | # config/application.rb 81 | config.autoload_paths += [config.root.join('app')] 82 | ``` 83 | 84 | This looks as if it might break things, but AFAIK it has never cause problems so far. 85 | 86 | ### Services::Base 87 | 88 | `Services::Base` is the base class you should use for all your services. It gives you a couply of helper methods and defines a custom exception class for you. 89 | 90 | Read [the source](lib/services/base.rb) to understand what it does in more detail. 91 | 92 | The following example service takes one or more users or user IDs as an argument and deletes the users: 93 | 94 | ```ruby 95 | module Services 96 | module Users 97 | class Delete < Services::Base 98 | def call(ids_or_objects) 99 | users = find_objects(ids_or_objects) 100 | users.each do |user| 101 | if user.posts.any? 102 | raise Error, "User #{user.id} has one or more posts, refusing to delete." 103 | end 104 | user.destroy 105 | Mailer.user_deleted(user).deliver 106 | end 107 | users 108 | end 109 | end 110 | end 111 | end 112 | ``` 113 | 114 | This service can be called in several ways: 115 | 116 | ```ruby 117 | # Execute synchronously/in the foreground 118 | 119 | Services::Users::Delete.call User.find(1) # with a user object 120 | Services::Users::Delete.call User.where(id: [1, 2, 3]) # with a ActiveRecord::Relation returning user objects 121 | Services::Users::Delete.call [user1, user2, user3] # with an array of user objects 122 | Services::Users::Delete.call 1 # with a user ID 123 | Services::Users::Delete.call [1, 2, 3] # with an array of user IDs 124 | 125 | # Execute asynchronously/in the background 126 | 127 | Services::Users::Delete.call_async 1 # with a user ID 128 | Services::Users::Delete.call_async [1, 2, 3] # with multiple user IDs 129 | ``` 130 | 131 | As you can see, you cannot use objects or a ActiveRecord::Relation as parameters when calling a service asynchronously since the arguments are serialized to Redis. This might change once Services works with [ActiveJob](https://github.com/rails/rails/tree/master/activejob) and [GlobalID](https://github.com/rails/globalid/). 132 | 133 | The helper `find_objects` is used to allow the `ids_or_objects` parameter to be a object, object ID, array or ActiveRecord::Relation, and make sure you we dealing with an array of objects from that point on. 134 | 135 | It's good practice to always return the objects a service has been operating on at the end of the service. 136 | 137 | ### Services::Query 138 | 139 | `Services::Query` on the other hand should be the base class for all query objects. 140 | 141 | Here is an example that is used to find users: 142 | 143 | ```ruby 144 | module Services 145 | module Users 146 | class Find < Services::Query 147 | convert_condition_objects_to_ids :post 148 | 149 | private def process(scope, condition, value) 150 | case condition 151 | when :email, :name 152 | scope.where(condition => value) 153 | when :post_id 154 | scope.joins(:posts).where("#{Post.table_name}.id" => value) 155 | end 156 | end 157 | end 158 | end 159 | end 160 | ``` 161 | 162 | A query object that inherits from `Services::Query` always receives two parameters: an array of IDs and a hash of conditions. It always returns an array, even if none or only one object is found. 163 | 164 | When you write your query objects, the only method you have to write is `process` (preferably make it private). This method does the actual querying for all non-standard parameters (more about standard vs. non-standard parameters below). 165 | 166 | This is how `Services::Users::Find` can be called: 167 | 168 | ```ruby 169 | Services::Users::Find.call [] # find all users, neither filtered by IDs nor by conditions 170 | Services::Users::Find.call [1, 2, 3] # find users with ID 1, 2 or 3 171 | Services::Users::Find.call 1 # find users with ID 1 (careful: returns an array containing this one user, if found, otherwise an empty array) 172 | Services::Users::Find.call [], email: 'foo@bar.com' # find users with this email address 173 | Services::Users::Find.call [1, 2], post: Post.find(1) # find users with ID 1 or 2 and having the post with ID 1 174 | Services::Users::Find.call [1, 2], post: [Post.find(1)] # same as above 175 | Services::Users::Find.call [1, 2], post: 1 # same as above 176 | ``` 177 | 178 | Check out [the source of `Services::Query`](lib/services/query.rb) to understand what it does in more detail. 179 | 180 | #### Standard vs. non-standard parameters 181 | 182 | to be described... 183 | 184 | #### convert_condition_objects_to_ids 185 | 186 | As with service objects, you want to be able to pass objects or IDs as conditions to query objects as well, and be sure that they behave the same way. This is what `convert_condition_objects_to_ids :post` does in the previous example: it tells the service object to convert the `post` condition, if present, to `post_id`. 187 | 188 | For example, at some point in your app you have an array of posts and need to find the users that created these posts. `Services::Users::Find.call([], post: posts)` will find them for you. If you have a post ID on the other hand, simply use `Services::Users::Find.call([], post: post_id)`, or if you have a single post, use `Services::Users::Find.call([], post: post)`. Each of these calls will return an array of users, as you would expect. 189 | 190 | `Services::Query` takes an array of IDs and a hash of conditions as parameters. It then extracts some special conditions (:order, :limit, :page, :per_page) that are handled separately and passes a `ActiveRecord::Relation` and the remaining conditions to the `process` method that the inheriting class must define. This method should handle all the conditions, extend the scope and return it. 191 | 192 | ### Helpers 193 | 194 | Your services inherit from `Services::Base` which makes several helper methods available to them: 195 | 196 | * `Rails.application.routes.url_helpers` is included so you use all Rails URL helpers. 197 | * `find_objects` and `find_object` let you automatically find object or a single object from an array of objects or object IDs, or a single object or object ID. The only difference is that `find_object` returns a single object whereas `find_objects` always returns an array. 198 | * `object_class` tries to figure out the class the service operates on. If you follow the service naming conventions and you have a service `Services::Products::Find`, `object_class` will return `Product`. Don't call it if you have a service like `Services::DoStuff` or it will raise an exception. 199 | 200 | Your services also automatically get a custom `Error` class, so you can `raise Error, 'Uh-oh, something has gone wrong!'` in `Services::MyService` and a `Services::MyService::Error` will be raised. 201 | 202 | ### Logging 203 | 204 | You can choose between logging to Redis or to a file, or turn logging off. By default logging is turned off. 205 | 206 | #### Redis 207 | 208 | to be described... 209 | 210 | #### File 211 | 212 | to be described... 213 | 214 | ### Exception wrapping 215 | 216 | to be described... 217 | 218 | ### Uniqueness checking 219 | 220 | to be described... 221 | 222 | ### Background/asynchronous processing 223 | 224 | Each service can run synchronously (i.e. blocking/in the foreground) or asynchronously (i.e. non-blocking/in the background). If you want to run a service in the background, make sure it takes only arguments that can be serialized without problems (i.e. integers, strings, etc.). The background processing is done by Sidekiq, so you must set up Sidekiq in the Services initializer. 225 | 226 | ## Installation 227 | 228 | Add this line to your application's Gemfile: 229 | 230 | gem 'services' 231 | 232 | And then execute: 233 | 234 | $ bundle 235 | 236 | Or install it yourself as: 237 | 238 | $ gem install services 239 | 240 | ## Contributing 241 | 242 | 1. Fork it 243 | 2. Create your feature branch (`git checkout -b my-new-feature`) 244 | 3. Commit your changes (`git commit -am 'Add some feature'`) 245 | 4. Push to the branch (`git push origin my-new-feature`) 246 | 5. Create new Pull Request 247 | 248 | ## Testing 249 | 250 | You need Redis to run tests, check out the [Guardfile](Guardfile) which loads it automatically when you start Guard! 251 | 252 | ## Support 253 | 254 | If you like this project, consider [buying me a coffee](https://www.buymeacoffee.com/279lcDtbF)! :) 255 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rspec/core/rake_task' 2 | 3 | RSpec::Core::RakeTask.new(:spec) 4 | 5 | task default: :spec 6 | -------------------------------------------------------------------------------- /gemfiles/rails_6.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 6.0.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/rails_6.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 6.1.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /lib/services.rb: -------------------------------------------------------------------------------- 1 | require 'gem_config' 2 | 3 | require_relative 'services/logger/null' 4 | 5 | module Services 6 | include GemConfig::Base 7 | 8 | NoBackgroundProcessorFound = Class.new(StandardError) 9 | RedisNotFound = Class.new(StandardError) 10 | 11 | with_configuration do 12 | has :logger, default: Services::Logger::Null.new 13 | has :redis 14 | has :allowed_class_methods_in_queries, default: {} 15 | end 16 | 17 | class << self 18 | def redis 19 | @redis ||= configuration.redis || (defined?(Redis.current) && Redis.current) or fail RedisNotFound, 'Redis not configured.' 20 | end 21 | 22 | def allow_class_method_in_queries(klass, method, arity = nil) 23 | (configuration.allowed_class_methods_in_queries[klass.to_s] ||= {})[method.to_sym] = arity 24 | end 25 | 26 | def replace_records_with_global_ids(arg) 27 | method = method(__method__) 28 | 29 | case arg 30 | when Array then arg.map(&method) 31 | when Hash then arg.transform_keys(&method) 32 | .transform_values(&method) 33 | else arg.respond_to?(:to_global_id) ? "_#{arg.to_global_id.to_s}" : arg 34 | end 35 | end 36 | 37 | def replace_global_ids_with_records(arg) 38 | method = method(__method__) 39 | 40 | case arg 41 | when Array then arg.map(&method) 42 | when Hash then arg.transform_keys(&method) 43 | .transform_values(&method) 44 | when String then (arg.starts_with?("_") && GlobalID::Locator.locate(arg[1..-1])) || arg 45 | else arg 46 | end 47 | end 48 | end 49 | end 50 | 51 | require_relative 'services/version' 52 | require_relative 'services/logger/file' 53 | require_relative 'services/logger/redis' 54 | require_relative 'services/asyncable' 55 | require_relative 'services/modules/call_logger' 56 | require_relative 'services/modules/exception_wrapper' 57 | require_relative 'services/modules/object_class' 58 | require_relative 'services/modules/uniqueness_checker' 59 | require_relative 'services/base' 60 | require_relative 'services/query' 61 | require_relative 'services/railtie' if defined?(Rails) 62 | -------------------------------------------------------------------------------- /lib/services/asyncable.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/concern' 2 | require 'global_id' 3 | 4 | module Services 5 | module Asyncable 6 | extend ActiveSupport::Concern 7 | 8 | ASYNC_METHOD_SUFFIXES = %i(async in at).freeze 9 | 10 | included do 11 | sidekiq_loaded = false 12 | 13 | begin 14 | require 'sidekiq' 15 | require 'sidekiq/api' 16 | rescue LoadError 17 | else 18 | include Sidekiq::Worker 19 | sidekiq_loaded = true 20 | end 21 | 22 | unless sidekiq_loaded 23 | begin 24 | require 'sucker_punch' 25 | rescue LoadError 26 | raise Services::NoBackgroundProcessorFound 27 | else 28 | include SuckerPunch::Job 29 | end 30 | end 31 | end 32 | 33 | module ClassMethods 34 | ASYNC_METHOD_SUFFIXES.each do |async_method_suffix| 35 | define_method "call_#{async_method_suffix}" do |*args| 36 | args = args.map(&Services.method(:replace_records_with_global_ids)) 37 | self.public_send "perform_#{async_method_suffix}", *args 38 | end 39 | end 40 | end 41 | 42 | def perform(*args) 43 | args = args.map(&Services.method(:replace_global_ids_with_records)) 44 | 45 | call_method = method(:call) 46 | 47 | # Find the first class that inherits from `Services::Base`. 48 | while !(call_method.owner < Services::Base) 49 | call_method = call_method.super_method 50 | end 51 | 52 | # If the `call` method takes any kwargs and the last argument is a hash, pass them to the method as kwargs. 53 | kwargs = if call_method.parameters.map(&:first).grep(/\Akey/).any? && args.last.is_a?(Hash) 54 | args.pop.symbolize_keys 55 | else 56 | {} 57 | end 58 | 59 | # Save args and kwargs in ivars so they can be used 60 | # in the service, i.e. for rescheduling. 61 | @_call_args, @_call_kwargs = args, kwargs 62 | 63 | call *args, **kwargs 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/services/base.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'securerandom' 3 | require 'action_dispatch' 4 | require 'digest' 5 | 6 | module Services 7 | class Base 8 | include ObjectClass 9 | 10 | class << self 11 | def inherited(subclass) 12 | subclass.const_set :Error, Class.new(StandardError) 13 | subclass.public_send :include, Rails.application.routes.url_helpers if defined?(Rails) 14 | begin 15 | subclass.public_send :include, Asyncable 16 | rescue Services::NoBackgroundProcessorFound 17 | end 18 | subclass.public_send :prepend, CallLogger, ExceptionWrapper, UniquenessChecker 19 | end 20 | 21 | delegate :call, to: :new 22 | end 23 | 24 | def initialize 25 | @id = SecureRandom.hex(6) 26 | end 27 | 28 | def call(*args, **kwargs) 29 | raise NotImplementedError 30 | end 31 | 32 | private 33 | 34 | def log(message, meta = {}, severity = 'info') 35 | Services.configuration.logger.log message, meta.merge(service: self.class.to_s, id: @id), severity 36 | end 37 | 38 | def _split_ids_and_objects(ids_or_objects, klass) 39 | ids_or_objects = Array(ids_or_objects) 40 | ids, objects = ids_or_objects.grep(Integer), ids_or_objects.grep(klass) 41 | if ids.size + objects.size < ids_or_objects.size 42 | raise "All params must be either #{klass.to_s.pluralize} or Integers: #{ids_or_objects.map { |id_or_object| [id_or_object.class, id_or_object.inspect].join(' - ')}}" 43 | end 44 | [ids, objects] 45 | end 46 | 47 | def find_ids(ids_or_objects, klass = object_class) 48 | ids, objects = _split_ids_and_objects(ids_or_objects, klass) 49 | ids.concat objects.map(&:id) if objects.any? 50 | ids 51 | end 52 | 53 | def find_service(klass) 54 | find_service_name = "#{klass.to_s.pluralize}::Find" 55 | candidates = ["Services::#{find_service_name}", find_service_name] 56 | # Use a lazy enumerator here because attempting to 57 | # constantize the find service without a namespace 58 | # might raise a circular dependency error if it has 59 | # a namespace 60 | candidates.lazy.map(&:safe_constantize).detect(&:itself) or raise self.class::Error, "Could not find find service (tried: #{candidates.join(', ')})" 61 | end 62 | 63 | def find_objects(ids_or_objects, klass = object_class) 64 | ids, objects = _split_ids_and_objects(ids_or_objects, klass) 65 | if ids.any? 66 | objects_from_ids = find_service(klass).call(ids) 67 | object_ids = if objects_from_ids.is_a?(ActiveRecord::Relation) 68 | objects_from_ids.pluck(:id) 69 | else 70 | objects_from_ids.map(&:id) 71 | end 72 | missing_ids = ids - object_ids 73 | raise self.class::Error, "#{klass.to_s.pluralize(missing_ids)} #{missing_ids.join(', ')} not found." if missing_ids.size > 0 74 | objects.concat objects_from_ids 75 | end 76 | objects 77 | end 78 | 79 | %i(object id).each do |type| 80 | define_method "find_#{type}" do |*args| 81 | send("find_#{type.to_s.pluralize}", *args).tap do |objects_or_ids| 82 | raise "Expected exactly one object or ID but found #{objects_or_ids.size}." unless objects_or_ids.size == 1 83 | end.first 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/services/logger/file.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/tagged_logging' 2 | 3 | module Services 4 | module Logger 5 | class File 6 | def initialize(log_dir) 7 | log_file = ::File.join(log_dir, 'services.log') 8 | @logger = ActiveSupport::TaggedLogging.new(::Logger.new(log_file)) 9 | @logger.clear_tags! 10 | end 11 | 12 | def log(message, meta = {}, severity = 'info') 13 | tags = meta.map do |k, v| 14 | [k, v].join('=') 15 | end 16 | @logger.tagged Time.now, severity.upcase, *tags do 17 | @logger.public_send severity, message 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/services/logger/null.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | module Logger 3 | class Null 4 | def log(*args) 5 | end 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/services/logger/redis.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | module Logger 3 | class Redis 4 | META_CLASSES = [ 5 | NilClass, 6 | TrueClass, 7 | FalseClass, 8 | Symbol, 9 | String, 10 | Numeric 11 | ].freeze 12 | 13 | InvalidMetaError = Class.new(StandardError) 14 | EmptyResponseFromRedisMultiError = Class.new(StandardError) 15 | 16 | def initialize(redis, key = 'logs') 17 | @redis, @key = redis, key 18 | end 19 | 20 | def log(message, meta = {}, severity = 'info') 21 | # Allow only simple data types in meta 22 | raise InvalidMetaError, "Meta keys and values must be of one of the following classes: #{META_CLASSES.join(', ')}" if meta_includes_invalid_values?(meta) 23 | 24 | value = { 25 | time: Time.now.to_i, 26 | message: message.to_s, 27 | severity: severity.to_s, 28 | meta: meta 29 | } 30 | @redis.lpush @key, value.to_json 31 | end 32 | 33 | def size 34 | @redis.llen @key 35 | end 36 | 37 | def fetch 38 | @redis.lrange(@key, 0, -1).map(&method(:log_entry_from_json)) 39 | end 40 | 41 | def clear 42 | response = 3.tries on: EmptyResponseFromRedisMultiError do 43 | @redis.multi do 44 | @redis.lrange @key, 0, -1 45 | @redis.del @key 46 | end or raise EmptyResponseFromRedisMultiError 47 | end 48 | response.first.map(&method(:log_entry_from_json)) 49 | end 50 | 51 | private 52 | 53 | def log_entry_from_json(json) 54 | data = JSON.load(json) 55 | data['time'] = Time.at(data['time']) 56 | data 57 | end 58 | 59 | def meta_includes_invalid_values?(meta) 60 | [meta.values, meta.keys].any? do |elements| 61 | elements.any? do |element| 62 | META_CLASSES.none? do |klass| 63 | element.class <= klass 64 | end 65 | end 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/services/modules/call_logger.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | class Base 3 | module CallLogger 4 | def self.prepended(mod) 5 | mod.extend ClassMethods 6 | mod.instance_eval do 7 | def inherited(subclass) 8 | subclass.extend ClassMethods 9 | subclass.disable_call_logging if self.call_logging_disabled 10 | end 11 | end 12 | end 13 | 14 | module ClassMethods 15 | @_call_logging_disabled = false 16 | 17 | def call_logging_disabled 18 | @_call_logging_disabled 19 | end 20 | 21 | def disable_call_logging 22 | @_call_logging_disabled = true 23 | end 24 | 25 | def enable_call_logging 26 | @_call_logging_disabled = false 27 | end 28 | end 29 | 30 | def call(*args, **kwargs) 31 | unless self.class.call_logging_disabled 32 | log "START with args: #{args}, kwargs: #{kwargs}", caller: caller 33 | start = Time.now 34 | end 35 | begin 36 | result = super 37 | rescue => e 38 | log exception_message(e), {}, 'error' 39 | raise e 40 | ensure 41 | log 'END', duration: (Time.now - start).round(2) unless self.class.call_logging_disabled 42 | result 43 | end 44 | end 45 | 46 | private 47 | 48 | def exception_message(e) 49 | message = "#{e.class}: #{e.message}" 50 | e.backtrace.each do |line| 51 | message << "\n #{line}" 52 | end 53 | message << "\ncaused by: #{exception_message(e.cause)}" if e.respond_to?(:cause) && e.cause 54 | message 55 | end 56 | 57 | def caller 58 | caller_location = caller_locations(1, 10).detect do |location| 59 | location.path !~ /\A#{Regexp.escape File.expand_path('../..', __FILE__)}/ 60 | end 61 | if caller_location.nil? 62 | nil 63 | else 64 | caller_path = caller_location.path 65 | caller_path = caller_path.sub(%r(\A#{Regexp.escape Rails.root.to_s}/), '') if defined?(Rails) 66 | [caller_path, caller_location.lineno].join(':') 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/services/modules/exception_wrapper.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | class Base 3 | module ExceptionWrapper 4 | def call(*args, **kwargs) 5 | super 6 | rescue StandardError => e 7 | if e.class <= self.class::Error 8 | raise e 9 | else 10 | raise self.class::Error, e 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/services/modules/object_class.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | module ObjectClass 3 | private 4 | 5 | def object_class 6 | self.class.to_s[/\A(?:Services::)?([^:]+)/, 1].singularize.constantize 7 | rescue 8 | raise "Could not determine service class from #{self.class}." 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/services/modules/uniqueness_checker.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | class Base 3 | module UniquenessChecker 4 | KEY_PREFIX = %w( 5 | services 6 | uniqueness 7 | ).join(':').freeze 8 | 9 | ON_ERROR = %i( 10 | fail 11 | ignore 12 | reschedule 13 | return 14 | ).freeze 15 | 16 | MAX_RETRIES = 10.freeze 17 | THIRTY_DAYS = (60 * 60 * 24 * 30).freeze 18 | 19 | def self.prepended(mod) 20 | mod.const_set :NotUniqueError, Class.new(mod::Error) 21 | end 22 | 23 | def check_uniqueness(*args, on_error: :fail) 24 | raise "on_error must be one of #{ON_ERROR.join(', ')}, but was #{on_error}" unless ON_ERROR.include?(on_error.to_sym) 25 | @_on_error = on_error 26 | raise 'Service args not found.' if @_service_args.nil? 27 | @_uniqueness_args = args.empty? ? @_service_args : args 28 | new_uniqueness_key = uniqueness_key(@_uniqueness_args) 29 | raise "A uniqueness key with args #{@_uniqueness_args.inspect} already exists." if @_uniqueness_keys && @_uniqueness_keys.include?(new_uniqueness_key) 30 | if @_similar_service_id = Services.redis.get(new_uniqueness_key) 31 | if on_error.to_sym == :ignore 32 | return false 33 | else 34 | @_retries_exhausted = on_error.to_sym == :reschedule && error_count >= MAX_RETRIES 35 | raise_not_unique_error 36 | end 37 | else 38 | @_uniqueness_keys ||= [] 39 | @_uniqueness_keys << new_uniqueness_key 40 | Services.redis.setex new_uniqueness_key, THIRTY_DAYS, @id 41 | true 42 | end 43 | end 44 | 45 | def call(*args, **kwargs) 46 | @_service_args = args 47 | super 48 | rescue self.class::NotUniqueError => e 49 | case @_on_error.to_sym 50 | when :fail 51 | raise e 52 | when :reschedule 53 | if @_retries_exhausted 54 | raise e 55 | else 56 | increase_error_count 57 | reschedule 58 | end 59 | when :return 60 | return e 61 | else 62 | raise "Unexpected on_error: #{@_on_error}" 63 | end 64 | ensure 65 | Services.redis.del @_uniqueness_keys unless Array(@_uniqueness_keys).empty? 66 | Services.redis.del error_count_key 67 | end 68 | 69 | private 70 | 71 | def raise_not_unique_error 72 | message = "Service #{self.class} #{@id} with uniqueness args #{@_uniqueness_args} is not unique, a similar service is already running: #{@_similar_service_id}." 73 | message << " The service has been retried #{MAX_RETRIES} times." if @_retries_exhausted 74 | raise self.class::NotUniqueError.new(message) 75 | end 76 | 77 | def convert_for_rescheduling(arg) 78 | case arg 79 | when Array 80 | arg.map do |array_arg| 81 | convert_for_rescheduling array_arg 82 | end 83 | when Integer, String, TrueClass, FalseClass, NilClass 84 | arg 85 | when object_class 86 | arg.id 87 | else 88 | raise "Don't know how to convert arg #{arg.inspect} for rescheduling." 89 | end 90 | end 91 | 92 | def reschedule 93 | # Convert service args for rescheduling first 94 | reschedule_args = @_service_args.map do |arg| 95 | convert_for_rescheduling arg 96 | end 97 | log "Rescheduling to be executed in #{retry_delay} seconds." if self.respond_to?(:log) 98 | self.class.call_in retry_delay, *reschedule_args 99 | end 100 | 101 | def error_count 102 | (Services.redis.get(error_count_key) || 0).to_i 103 | end 104 | 105 | def increase_error_count 106 | Services.redis.setex error_count_key, retry_delay + THIRTY_DAYS, error_count + 1 107 | end 108 | 109 | def uniqueness_key(args) 110 | [ 111 | KEY_PREFIX, 112 | self.class.to_s.gsub(':', '_') 113 | ].tap do |key| 114 | key << Digest::MD5.hexdigest(args.to_s) unless args.empty? 115 | end.join(':') 116 | end 117 | 118 | def error_count_key 119 | [ 120 | KEY_PREFIX, 121 | 'errors', 122 | self.class.to_s.gsub(':', '_') 123 | ].tap do |key| 124 | key << Digest::MD5.hexdigest(@_service_args.to_s) unless @_service_args.empty? 125 | end.join(':') 126 | end 127 | 128 | def retry_delay 129 | error_count ** 3 + 5 130 | end 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/services/query.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | class Query 3 | include ObjectClass 4 | 5 | COMMA_REGEX = /\s*,\s*/ 6 | TABLE_NAME_REGEX = /\A([A-Za-z0-9_]+)\./ 7 | CREATED_BEFORE_AFTER_REGEX = /\Acreated_(before|after)\z/ 8 | 9 | class << self 10 | delegate :call, to: :new 11 | 12 | def convert_condition_objects_to_ids(*class_names) 13 | @object_to_id_class_names = class_names 14 | end 15 | 16 | def object_to_id_class_names 17 | @object_to_id_class_names || [] 18 | end 19 | end 20 | 21 | def call(ids_or_conditions = {}, _conditions = {}) 22 | ids, conditions = if ids_or_conditions.is_a?(Hash) 23 | if _conditions.any? 24 | fail ArgumentError, 'If conditions are passed as first argument, there must not be a second argument.' 25 | end 26 | [[], ids_or_conditions.symbolize_keys] 27 | else 28 | if ids_or_conditions.nil? 29 | fail ArgumentError, 'IDs must not be nil.' 30 | end 31 | [Array(ids_or_conditions), _conditions.symbolize_keys] 32 | end 33 | 34 | object_table_id = "#{object_class.table_name}.id" 35 | 36 | unless conditions.key?(:order) 37 | conditions[:order] = object_table_id 38 | end 39 | 40 | scope = conditions.delete(:scope).try(:dup) || object_class.all 41 | if ids.any? 42 | scope = scope.where(object_table_id => ids) 43 | end 44 | 45 | if conditions.any? 46 | self.class.object_to_id_class_names.each do |class_name| 47 | if object_or_objects = conditions.delete(class_name) 48 | ids = case object_or_objects 49 | when Array 50 | object_or_objects.map(&:id) 51 | when ActiveRecord::Relation 52 | object_or_objects.select(:id) 53 | else 54 | [object_or_objects.id] 55 | end 56 | conditions[:"#{class_name}_id"] = ids.size == 1 ? ids.first : ids 57 | end 58 | end 59 | 60 | conditions.each do |k, v| 61 | if new_scope = process(scope, k, v) 62 | conditions.delete k 63 | scope = new_scope 64 | end 65 | end 66 | 67 | # If a JOIN is involved, use a subquery to make sure we get DISTINCT records. 68 | if scope.to_sql =~ / join /i 69 | scope = object_class.where(id: scope.select("DISTINCT #{object_table_id}")) 70 | end 71 | end 72 | 73 | conditions.each do |k, v| 74 | allowed_class_methods = Services.configuration.allowed_class_methods_in_queries[object_class.to_s] 75 | if allowed_class_methods&.key?(k) 76 | arity = allowed_class_methods[k] || object_class.method(k).arity 77 | case arity 78 | when 0 79 | unless v == true 80 | raise ArgumentError, "Method #{k} of class #{self} takes no arguments, so `true` must be passed as the value for this param, not #{v} (#{v.class})." 81 | end 82 | scope = scope.public_send(k) 83 | when 1, -1 84 | scope = scope.public_send(k, v) 85 | else 86 | unless v.is_a?(Array) 87 | raise ArgumentError, "Method #{k} of class #{self} takes more than one argument, so an array must be passed as the value for this param, not #{v} (#{v.class})." 88 | end 89 | scope = scope.public_send(k, *v) 90 | end 91 | else 92 | case k 93 | when :id_not 94 | scope = scope.where.not(id: v) 95 | when CREATED_BEFORE_AFTER_REGEX 96 | operator = $1 == 'before' ? '<' : '>' 97 | scope = scope.where("#{object_class.table_name}.created_at #{operator} ?", v) 98 | when :order 99 | next unless v 100 | 101 | order = v.split(COMMA_REGEX).map do |order_part| 102 | table_name = order_part[TABLE_NAME_REGEX, 1] 103 | case 104 | when table_name && table_name != object_class.table_name 105 | unless reflection = object_class.reflections.values.detect { |reflection| reflection.table_name == table_name } 106 | fail "Reflection on class #{object_class} with table name #{table_name} not found." 107 | end 108 | 109 | if ActiveRecord::VERSION::MAJOR >= 5 110 | scope = scope.left_outer_joins(reflection.name) 111 | else 112 | join_conditions = "LEFT OUTER JOIN #{table_name} ON #{table_name}.#{reflection.foreign_key} = #{object_class.table_name}.id" 113 | if reflection.type 114 | join_conditions << " AND #{table_name}.#{reflection.type} = '#{object_class}'" 115 | end 116 | scope = scope.joins(join_conditions) 117 | end 118 | when !table_name 119 | order_part.prepend "#{object_class.table_name}." 120 | end 121 | order_part 122 | end.join(', ') 123 | 124 | scope = scope.order(order) 125 | when :limit 126 | scope = scope.limit(v) 127 | when :page 128 | scope = scope.page(v) 129 | when :per_page 130 | scope = scope.per(v) 131 | else 132 | raise ArgumentError, "Unexpected condition: #{k}" 133 | end 134 | end 135 | end 136 | 137 | scope 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/services/railtie.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | class Railtie < Rails::Railtie 3 | # Require `Services::Query` here since it relies 4 | # on Rails.application to be present. 5 | initializer 'services.load_services_query' do 6 | require 'services/query' 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/services/version.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | VERSION = '9.0.0'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /services.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | 3 | unless $LOAD_PATH.include?(lib) 4 | $LOAD_PATH.unshift(lib) 5 | end 6 | 7 | require 'services/version' 8 | 9 | Gem::Specification.new do |gem| 10 | files = `git ls-files`.split($/) 11 | test_files = files.grep(%r(^spec/)) 12 | 13 | gem.name = 'services' 14 | gem.version = Services::VERSION 15 | gem.platform = Gem::Platform::RUBY 16 | gem.author = 'Manuel Meurer' 17 | gem.email = 'manuel@krautcomputing.com' 18 | gem.summary = 'A nifty service layer for your Rails app' 19 | gem.description = 'A nifty service layer for your Rails app' 20 | gem.homepage = 'https://manuelmeurer.com/services/' 21 | gem.license = 'MIT' 22 | gem.required_ruby_version = '>= 2.7' 23 | gem.files = files - test_files 24 | gem.executables = gem.files.grep(%r(\Abin/)).map(&File.method(:basename)) 25 | gem.test_files = test_files 26 | gem.require_paths = ['lib'] 27 | 28 | gem.add_development_dependency 'rake', '>= 0.9.0' 29 | gem.add_development_dependency 'guard-rspec', '~> 4.2' 30 | gem.add_development_dependency 'rspec', '~> 3.0' 31 | gem.add_development_dependency 'sidekiq', '~> 5.0' 32 | gem.add_development_dependency 'redis', '~> 3.0' 33 | gem.add_development_dependency 'redis-namespace', '~> 1.5' 34 | gem.add_development_dependency 'tries', '~> 0.3' 35 | gem.add_development_dependency 'timecop', '~> 0.7' 36 | gem.add_development_dependency 'sqlite3', '~> 1.3' 37 | gem.add_development_dependency 'appraisal', '~> 2.1' 38 | gem.add_runtime_dependency 'rails', '>= 6.0' 39 | gem.add_runtime_dependency 'gem_config', '~> 0.3' 40 | end 41 | -------------------------------------------------------------------------------- /spec/services/asyncable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Services::Asyncable do 4 | describe '#perform' do 5 | it 'calls `call` with the correct args' do 6 | expect { AsyncService.new.perform 'test', pelle: 'fant' }.to raise_error(%w(test baz fant).to_json) 7 | 8 | # If the `call` method arguments contains kwargs and the last argument to `perform` is a Hash, 9 | # it's keys should be symbolized. The reason is that the arguments to `perform` are serialized to 10 | # the database before Sidekiq picks them up, i.e. symbol keys are converted to strings. 11 | expect { AsyncService.new.perform 'test', 'pelle' => 'fant' }.to raise_error(%w(test baz fant).to_json) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/services/base_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Services::Base do 4 | let(:model_ids) { (1..5).to_a.shuffle } 5 | let(:model_objects) { model_ids.map { |id| Model.new(id) } } 6 | let(:model_ids_and_objects) { model_ids[0..2] + model_objects[3..-1] } 7 | 8 | describe '#find_objects' do 9 | context 'when passing in objects' do 10 | it 'returns the same objects' do 11 | expect(Services::Models::FindObjectsTest.call(model_objects)).to match_array(model_objects) 12 | end 13 | end 14 | 15 | context 'when passing in IDs' do 16 | it 'returns the objects for the IDs' do 17 | expect(Services::Models::FindObjectsTest.call(model_ids)).to match_array(model_objects) 18 | end 19 | end 20 | 21 | context 'when passing in objects and IDs' do 22 | it 'returns the objects plus the objects for the IDs' do 23 | expect(Services::Models::FindObjectsTest.call(model_ids_and_objects)).to match_array(model_objects) 24 | end 25 | end 26 | 27 | context 'when passing in a single object or ID' do 28 | it 'returns an array containing the object' do 29 | [model_ids.first, model_objects.first].each do |id_or_object| 30 | expect(Services::Models::FindObjectsTest.call(id_or_object)).to match_array([model_objects.first]) 31 | end 32 | end 33 | end 34 | end 35 | 36 | describe '#find_object' do 37 | context 'when passing in a single object or ID' do 38 | it 'returns the object' do 39 | [model_ids.first, model_objects.first].each do |id_or_object| 40 | expect(Services::Models::FindObjectTest.call(id_or_object)).to eq(model_objects.first) 41 | end 42 | end 43 | end 44 | 45 | context 'when passing in something else than a single object or ID' do 46 | it 'raises an error' do 47 | [%w(foo bar), nil, Object.new].each do |object| 48 | expect { Services::Models::FindObjectTest.call(object) }.to raise_error(Services::Models::FindObjectTest::Error) 49 | end 50 | end 51 | end 52 | end 53 | 54 | describe '#find_ids' do 55 | context 'when passing in objects' do 56 | it 'returns the IDs for the objects' do 57 | expect(Services::Models::FindIdsTest.call(model_objects)).to match_array(model_ids) 58 | end 59 | end 60 | 61 | context 'when passing in IDs' do 62 | it 'returns the same IDs' do 63 | expect(Services::Models::FindIdsTest.call(model_ids)).to match_array(model_ids) 64 | end 65 | end 66 | 67 | context 'when passing in objects and IDs' do 68 | it 'returns the IDs for the objects plus the passed in IDs' do 69 | expect(Services::Models::FindIdsTest.call(model_ids_and_objects)).to match_array(model_ids) 70 | end 71 | end 72 | 73 | context 'when passing in a single object or ID' do 74 | it 'returns an array containing the ID' do 75 | [model_ids.first, model_objects.first].each do |id_or_object| 76 | expect(Services::Models::FindIdsTest.call(id_or_object)).to match_array([model_ids.first]) 77 | end 78 | end 79 | end 80 | end 81 | 82 | describe '#find_id' do 83 | context 'when passing in a single object or ID' do 84 | it 'returns the ID' do 85 | [model_ids.first, model_objects.first].each do |id_or_object| 86 | expect(Services::Models::FindIdTest.call(id_or_object)).to eq(model_ids.first) 87 | end 88 | end 89 | end 90 | 91 | context 'when passing in something else than a single object or ID' do 92 | it 'raises an error' do 93 | [%w(foo bar), nil, Object.new].each do |object| 94 | expect { Services::Models::FindIdTest.call(object) }.to raise_error(Services::Models::FindIdTest::Error) 95 | end 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/services/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Services do 4 | context 'configuration' do 5 | describe 'logger' do 6 | it 'uses the null logger by default' do 7 | expect(Services.configuration.logger).to be_a(Services::Logger::Null) 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/services/logger/redis_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Services::Logger::Redis do 4 | let(:key) { 'custom_log_key' } 5 | let(:redis) { Redis.new(url: REDIS_URL) } 6 | let(:logger) { described_class.new(redis, key) } 7 | let(:logs) { 8 | [ 9 | { 10 | time: Date.new(2014, 9, 15), 11 | message: "One day baby we'll be old", 12 | severity: :info, 13 | meta: { 14 | foo: 'bar', 15 | class: Services::Base.to_s, 16 | object: redis.to_s 17 | } 18 | }, { 19 | time: Date.new(2014, 10, 10), 20 | message: "Oh baby, we'll be old", 21 | severity: :warning, 22 | meta: { 23 | true: true, 24 | false: false, 25 | nil: nil 26 | } 27 | }, { 28 | time: Date.new(2014, 11, 17), 29 | message: 'And think of all the stories', 30 | severity: :critical, 31 | meta: { 32 | one: 2, 33 | three: 3.14 34 | } 35 | }, { 36 | time: Date.new(2014, 11, 17), 37 | message: 'That we could have told', 38 | severity: :debug 39 | } 40 | ] 41 | } 42 | let(:fetched_logs) { 43 | logs.reverse.map do |log| 44 | log[:time] = log[:time].to_time 45 | %i(message severity).each do |k| 46 | log[k] = log[k].try(:to_s) || '' 47 | end 48 | log[:meta] = if log.key?(:meta) 49 | log[:meta].stringify_keys 50 | else 51 | {} 52 | end 53 | log.stringify_keys 54 | end 55 | } 56 | 57 | def create_logs 58 | logs.each do |log| 59 | Timecop.freeze log[:time] do 60 | args = [log[:message]] 61 | args.push log[:meta] || {} 62 | args.push log[:severity] if log.key?(:severity) 63 | logger.log *args 64 | end 65 | end 66 | end 67 | 68 | def logs_in_db 69 | redis.lrange(key, 0, -1).map do |json| 70 | data = JSON.load(json) 71 | data['time'] = Time.at(data['time']) 72 | data 73 | end 74 | end 75 | 76 | before do 77 | redis.del key 78 | end 79 | 80 | context 'when logs are present' do 81 | before do 82 | create_logs 83 | expect(logs_in_db.size).to eq(logs.size) 84 | end 85 | 86 | describe '#size' do 87 | it 'returns the amount of logs' do 88 | expect(logger.size).to eq(logs.size) 89 | end 90 | end 91 | 92 | describe '#fetch' do 93 | it 'returns all logs' do 94 | expect(logger.fetch).to eq(fetched_logs) 95 | end 96 | end 97 | 98 | describe '#clear' do 99 | it 'returns all logs' do 100 | expect(fetched_logs).to eq(logger.clear) 101 | end 102 | 103 | it 'clears all log entries' do 104 | expect { logger.clear }.to change { logs_in_db }.to([]) 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/services/modules/call_logger_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Services::Base::CallLogger do 4 | include_context 'capture logs' 5 | 6 | context 'when call logging is enabled' do 7 | it 'logs start and end' do 8 | service, args = EmptyService.new, %w(foo bar) 9 | service.call *args 10 | caller_regex = /\A#{Regexp.escape __FILE__}:\d+\z/ 11 | expect(logs.first).to match( 12 | message: "START with args: #{args}, kwargs: {}", 13 | meta: { 14 | caller: a_string_matching(caller_regex), 15 | service: service.class.to_s, 16 | id: a_kind_of(String) 17 | }, 18 | severity: 'info' 19 | ) 20 | expect(logs.last).to match( 21 | message: 'END', 22 | meta: { 23 | duration: a_value_within(0.1).of(0.0), 24 | service: service.class.to_s, 25 | id: a_kind_of(String) 26 | }, 27 | severity: 'info' 28 | ) 29 | end 30 | 31 | describe 'logging the caller' do 32 | let(:service_calling_service) { ServiceCallingService.new } 33 | let(:called_service) { EmptyService.new } 34 | 35 | it 'filters out caller paths from lib folder' do 36 | require 'services/call_proxy' 37 | Services::CallProxy.call(called_service, :call) 38 | caller_regex = /\A#{Regexp.escape __FILE__}:\d+/ 39 | expect( 40 | logs.detect do |log| 41 | log[:meta][:caller] =~ caller_regex 42 | end 43 | ).to be_present 44 | end 45 | 46 | context 'when Rails is not defined' do 47 | it 'logs the complete caller path' do 48 | service_calling_service.call called_service 49 | caller_regex = /\A#{Regexp.escape PROJECT_ROOT.join(TEST_SERVICES_PATH).to_s}:\d+/ 50 | expect( 51 | logs.detect do |log| 52 | log[:meta][:caller] =~ caller_regex 53 | end 54 | ).to be_present 55 | end 56 | end 57 | 58 | context 'when Rails is defined' do 59 | before do 60 | class Rails 61 | def self.root 62 | PROJECT_ROOT 63 | end 64 | end 65 | end 66 | 67 | after do 68 | Object.send :remove_const, :Rails 69 | end 70 | 71 | it 'logs the caller path relative to `Rails.root`' do 72 | service_calling_service.call called_service 73 | caller_regex = /\A#{Regexp.escape TEST_SERVICES_PATH.to_s}:\d+/ 74 | expect( 75 | logs.detect do |log| 76 | log[:meta][:caller] =~ caller_regex 77 | end 78 | ).to be_present 79 | end 80 | end 81 | end 82 | end 83 | 84 | context 'when call logging is disabled' do 85 | it 'does not log start and end' do 86 | expect(EmptyServiceWithoutCallLogging.call_logging_disabled).to eq(true) 87 | expect { EmptyServiceWithoutCallLogging.call }.to_not change { logs } 88 | end 89 | end 90 | 91 | it 'logs exceptions' do 92 | [ErrorService, ErrorServiceWithoutCallLogging].each do |klass| 93 | expect { klass.call rescue nil }.to change { logs } 94 | end 95 | end 96 | 97 | if RUBY_VERSION > '2.1' 98 | it 'logs exception causes' do 99 | service = NestedExceptionService.new 100 | expect { service.call }.to raise_error(service.class::Error) 101 | %w(NestedError1 NestedError2).each do |error| 102 | message_regex = /caused by: #{service.class}::#{error}/ 103 | expect( 104 | logs.detect do |log| 105 | log[:message] =~ message_regex 106 | end 107 | ).to be_present 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /spec/services/modules/exception_wrapper_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Services::Base::ExceptionWrapper do 4 | if StandardError.new.respond_to?(:cause) 5 | it 'does not wrap service errors or subclasses' do 6 | expect do 7 | ErrorService.call 8 | end.to raise_error do |error| 9 | expect(error).to be_a(ErrorService::Error) 10 | expect(error.message).to eq('I am a service error raised by ErrorService.') 11 | expect(error.cause).to be_nil 12 | end 13 | 14 | class ServiceWithCustomError < Services::Base 15 | CustomError = Class.new(self::Error) 16 | def call 17 | raise CustomError.new('I am a custom error.') 18 | end 19 | end 20 | expect do 21 | ServiceWithCustomError.call 22 | end.to raise_error do |error| 23 | expect(error).to be_a(ServiceWithCustomError::CustomError) 24 | expect(error.message).to eq('I am a custom error.') 25 | expect(error.cause).to be_nil 26 | end 27 | end 28 | 29 | it 'wraps all other exceptions' do 30 | class ServiceWithStandardError < Services::Base 31 | def call 32 | raise 'I am a StandardError.' 33 | end 34 | end 35 | expect do 36 | ServiceWithStandardError.call 37 | end.to raise_error do |error| 38 | expect(error).to be_a(ServiceWithStandardError::Error) 39 | expect(error.message).to eq('I am a StandardError.') 40 | expect(error.cause).to be_a(StandardError) 41 | expect(error.cause.message).to eq('I am a StandardError.') 42 | end 43 | 44 | class ServiceWithCustomStandardError < Services::Base 45 | CustomStandardError = Class.new(StandardError) 46 | def call 47 | raise CustomStandardError, 'I am a custom StandardError.' 48 | end 49 | end 50 | expect do 51 | ServiceWithCustomStandardError.call 52 | end.to raise_error do |error| 53 | expect(error).to be_a(ServiceWithCustomStandardError::Error) 54 | expect(error.message).to eq('I am a custom StandardError.') 55 | expect(error.cause).to be_a(ServiceWithCustomStandardError::CustomStandardError) 56 | expect(error.cause.message).to eq('I am a custom StandardError.') 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/services/modules/uniqueness_checker_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | shared_examples 'checking the uniqueness properly' do 4 | it 'notices when the same job is executed multiple times' do 5 | wait_for_job_to_run_and_finish service_class, *args, 'fail', true do 6 | # Check that error is raised when on_error is "fail" 7 | puts 'Checking on_error = fail' 8 | if defined?(fail_args) 9 | puts "* with fail args #{fail_args}" 10 | 3.times do 11 | fail_args.each do |fail_arg_group| 12 | service = service_class.new 13 | expect(service).to_not receive(:do_work) 14 | expect { service.call(*fail_arg_group, 'fail', false) }.to raise_error(service_class::NotUniqueError) 15 | end 16 | end 17 | end 18 | if defined?(pass_args) 19 | puts "* with pass args #{pass_args}" 20 | 3.times do 21 | pass_args.each do |pass_arg_group| 22 | service = service_class.new 23 | expect(service).to receive(:do_work) 24 | expect { service.call(*pass_arg_group, 'fail', false) }.to_not raise_error 25 | end 26 | end 27 | end 28 | 29 | # Check that no error is raised when on_error is "ignore" 30 | puts 'Checking on_error = ignore' 31 | if defined?(fail_args) 32 | puts "* with fail args #{fail_args}" 33 | 3.times do 34 | fail_args.each do |fail_arg_group| 35 | service = service_class.new 36 | expect(service).to receive(:do_work) 37 | expect { service.call(*fail_arg_group, 'ignore', false) }.to_not raise_error 38 | end 39 | end 40 | end 41 | if defined?(pass_args) 42 | puts "* with pass args #{pass_args}" 43 | 3.times do 44 | pass_args.each do |pass_arg_group| 45 | service = service_class.new 46 | expect(service).to receive(:do_work) 47 | expect { service.call(*pass_arg_group, 'ignore', false) }.to_not raise_error 48 | end 49 | end 50 | end 51 | 52 | # Check that service is rescheduled when on_error is "reschedule" 53 | puts 'Checking on_error = reschedule' 54 | if defined?(fail_args) 55 | puts "* with fail args #{fail_args}" 56 | 3.times do 57 | fail_args.each do |fail_arg_group| 58 | service = service_class.new 59 | expect(service).to_not receive(:do_work) 60 | expect(service_class).to receive(:call_in).with(a_kind_of(Integer), *fail_arg_group, 'reschedule', false) 61 | expect { service.call(*fail_arg_group, 'reschedule', false) }.to_not raise_error 62 | end 63 | end 64 | end 65 | if defined?(pass_args) 66 | puts "* with pass args #{pass_args}" 67 | 3.times do 68 | pass_args.each do |pass_arg_group| 69 | service = service_class.new 70 | expect(service).to receive(:do_work) 71 | expect(service_class).to_not receive(:call_in) 72 | expect { service.call(*pass_arg_group, 'reschedule', false) }.to_not raise_error 73 | end 74 | end 75 | end 76 | end 77 | 78 | # Check that all Redis keys are deleted 79 | key_pattern = "#{described_class::KEY_PREFIX}*" 80 | expect(Services.redis.keys(key_pattern)).to be_empty 81 | end 82 | end 83 | 84 | describe Services::Base::UniquenessChecker do 85 | context 'when the service checks for uniqueness with the default args' do 86 | it_behaves_like 'checking the uniqueness properly' do 87 | let(:service_class) { UniqueService } 88 | let(:args) { [] } 89 | let(:fail_args) { [] } 90 | end 91 | end 92 | 93 | context 'when the service checks for uniqueness with custom args' do 94 | it_behaves_like 'checking the uniqueness properly' do 95 | let(:service_class) { UniqueWithCustomArgsService } 96 | let(:args) { ['foo', 1, 'bar'] } 97 | let(:fail_args) { [['foo', 1, 'pelle']] } 98 | let(:pass_args) { [['foo', 2, 'bar']] } 99 | end 100 | end 101 | 102 | context 'when the service checks for uniqueness multiple times' do 103 | it_behaves_like 'checking the uniqueness properly' do 104 | let(:service_class) { UniqueMultipleService } 105 | let(:args) { ['foo', 1, true] } 106 | let(:fail_args) { args.map { |arg| [arg] } } 107 | let(:pass_args) { [['pelle']] } 108 | end 109 | end 110 | 111 | context 'when the service does not check for uniqueness' do 112 | it_behaves_like 'checking the uniqueness properly' do 113 | let(:service_class) { NonUniqueService } 114 | let(:args) { [] } 115 | let(:pass_args) { [] } 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /spec/services/query_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require SUPPORT_DIR.join('activerecord_models_and_services') 3 | 4 | describe Services::Query do 5 | include_context 'capture logs' 6 | 7 | it 'has call logging disabled by default' do 8 | expect { Services::Posts::Find.call [] }.to_not change { logs } 9 | end 10 | 11 | describe '.convert_condition_objects_to_ids' do 12 | let(:comment) { Comment.create! } 13 | let(:comments) { (1..3).map { Comment.create! } } 14 | 15 | it 'converts condition objects to ids' do 16 | { 17 | comment => comment.id, 18 | comments => comments.map(&:id), 19 | Comment.all => Comment.all.map { |comment| { id: comment.id } } 20 | }.each do |condition_before, condition_after| 21 | expect { Services::Posts::FindRaiseConditions.call [], comment: condition_before }.to raise_error({ comment_id: condition_after }.to_json) 22 | end 23 | end 24 | end 25 | 26 | describe 'calling without IDs parameter' do 27 | let(:post) { Post.create! title: 'Superpost!' } 28 | 29 | it 'works' do 30 | expect(Services::Posts::Find.call title: post.title).to eq([post]) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | require 'tries' 3 | require 'redis' 4 | require 'sidekiq' 5 | require 'timecop' 6 | require 'active_support' 7 | require 'active_support/core_ext' 8 | 9 | require_relative '../lib/services' 10 | 11 | PROJECT_ROOT = Pathname.new(File.expand_path('../..', __FILE__)) 12 | SUPPORT_DIR = Pathname.new(File.expand_path('../support', __FILE__)) 13 | TEST_SERVICES_PATH = Pathname.new(File.join('spec', 'support', 'test_services.rb')) 14 | CALL_PROXY_DESTINATION = PROJECT_ROOT.join('lib', 'services', 'call_proxy.rb') 15 | CALL_PROXY_SOURCE = SUPPORT_DIR.join('call_proxy.rb') 16 | SIDEKIQ_PIDFILE = SUPPORT_DIR.join('sidekiq.pid') 17 | WAIT = 0.5 18 | START_TIMEOUT = 5 19 | SIDEKIQ_TIMEOUT = 20 20 | REDIS_URL = 'redis://localhost:6379/0' 21 | 22 | %w(shared helpers test_services).each do |file| 23 | require SUPPORT_DIR.join(file) 24 | end 25 | 26 | Redis.current = Redis.new(url: REDIS_URL) 27 | 28 | Sidekiq.configure_client do |config| 29 | config.redis = { url: REDIS_URL, namespace: 'sidekiq', size: 1 } 30 | end 31 | 32 | Sidekiq.configure_server do |config| 33 | config.redis = { url: REDIS_URL, namespace: 'sidekiq' } 34 | end 35 | 36 | RSpec.configure do |config| 37 | config.run_all_when_everything_filtered = true 38 | config.filter_run :focus 39 | config.order = 'random' 40 | 41 | config.before :suite do 42 | # Start Sidekiq 43 | sidekiq_options = { 44 | concurrency: 10, 45 | daemon: true, 46 | timeout: SIDEKIQ_TIMEOUT, 47 | verbose: true, 48 | require: __FILE__, 49 | logfile: SUPPORT_DIR.join('log', 'sidekiq.log'), 50 | pidfile: SIDEKIQ_PIDFILE 51 | } 52 | system "bundle exec sidekiq #{options_hash_to_string(sidekiq_options)}" 53 | 54 | # Copy call proxy 55 | FileUtils.cp CALL_PROXY_SOURCE, CALL_PROXY_DESTINATION 56 | 57 | # Wait for Sidekiq to start 58 | i = 0 59 | while !File.exist?(SIDEKIQ_PIDFILE) 60 | puts 'Waiting for Sidekiq to start...' 61 | sleep WAIT 62 | i += WAIT 63 | raise "Sidekiq didn't start in #{i} seconds." if i >= START_TIMEOUT 64 | end 65 | end 66 | 67 | config.after :suite do 68 | # Stop Sidekiq 69 | system "bundle exec sidekiqctl stop #{SIDEKIQ_PIDFILE} #{SIDEKIQ_TIMEOUT}" 70 | 71 | # Delete call proxy 72 | FileUtils.rm CALL_PROXY_DESTINATION 73 | 74 | i = 0 75 | while File.exist?(SIDEKIQ_PIDFILE) 76 | puts 'Waiting for Sidekiq to stop...' 77 | sleep WAIT 78 | i += WAIT 79 | raise "Sidekiq didn't stop in #{i} seconds." if i >= SIDEKIQ_TIMEOUT + 1 80 | end 81 | end 82 | 83 | config.after :each do 84 | wait_for_all_jobs_to_finish 85 | end 86 | end 87 | 88 | def options_hash_to_string(options) 89 | options.map { |k, v| "--#{k} #{v}" }.join(' ') 90 | end 91 | -------------------------------------------------------------------------------- /spec/support/activerecord_models_and_services.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | 3 | ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') 4 | 5 | ActiveRecord::Schema.define do 6 | create_table :posts, force: true do |t| 7 | t.string :title 8 | t.text :body 9 | end 10 | 11 | create_table :comments, force: true do |t| 12 | t.string :body 13 | t.references :post 14 | end 15 | end 16 | 17 | class Post < ActiveRecord::Base 18 | has_many :comments 19 | end 20 | 21 | class Comment < ActiveRecord::Base 22 | belongs_to :post 23 | end 24 | 25 | module Services 26 | module Posts 27 | class FindRaiseConditions < Services::Query 28 | convert_condition_objects_to_ids :comment 29 | 30 | private 31 | 32 | def process(scope, condition, value) 33 | if condition == :comment_id 34 | raise({ condition => value }.to_json) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | 41 | module Services 42 | module Posts 43 | class Find < Services::Query 44 | convert_condition_objects_to_ids :comment 45 | 46 | private 47 | 48 | def process(scope, condition, value) 49 | case condition 50 | when :title, :body 51 | scope.where(condition => value) 52 | when :comment_id 53 | scope.joins(:comments).where("#{Comment.table_name}.id" => value) 54 | end 55 | end 56 | end 57 | end 58 | end 59 | 60 | module Services 61 | module Comments 62 | class Find < Services::Query 63 | convert_condition_objects_to_ids :post 64 | 65 | private 66 | 67 | def process(scope, condition, value) 68 | case condition 69 | when :body, :post_id 70 | scope.where(condition => value) 71 | end 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /spec/support/call_proxy.rb: -------------------------------------------------------------------------------- 1 | # This is just a helper file to ensure that 2 | # the services lib folder appears in the caller 3 | # locations. 4 | 5 | module Services 6 | class CallProxy 7 | def self.call(object, method) 8 | object.public_send method 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/helpers.rb: -------------------------------------------------------------------------------- 1 | require 'sidekiq/api' 2 | 3 | ExpectedDataNotFoundError = Class.new(StandardError) 4 | 5 | def wait_for(&block) 6 | 60.tries on: ExpectedDataNotFoundError, delay: 0.1 do 7 | block.call or raise ExpectedDataNotFoundError 8 | end 9 | end 10 | 11 | def worker_with_jid(jid) 12 | Sidekiq::Workers.new.detect do |_, _, work| 13 | work['payload']['jid'] == jid 14 | end 15 | end 16 | 17 | def wait_for_all_jobs_to_finish 18 | wait_for do 19 | Sidekiq::Workers.new.size == 0 20 | end 21 | end 22 | 23 | def wait_for_job_to_run(job_class, *args, **kwargs, &block) 24 | job_class.call_async(*args, **kwargs).tap do |jid| 25 | wait_for { worker_with_jid(jid) } 26 | block.call if block_given? 27 | end 28 | end 29 | 30 | def wait_for_job_to_run_and_finish(job_class, *args, **kwargs, &block) 31 | wait_for_job_to_run(job_class, *args, **kwargs, &block).tap do |jid| 32 | wait_for { worker_with_jid(jid).nil? } 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/support/log/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manuelmeurer/services/2215adde9f1b9476785843ab0ed6e877488f69c5/spec/support/log/.gitkeep -------------------------------------------------------------------------------- /spec/support/redis-cli: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manuelmeurer/services/2215adde9f1b9476785843ab0ed6e877488f69c5/spec/support/redis-cli -------------------------------------------------------------------------------- /spec/support/redis-server: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manuelmeurer/services/2215adde9f1b9476785843ab0ed6e877488f69c5/spec/support/redis-server -------------------------------------------------------------------------------- /spec/support/shared.rb: -------------------------------------------------------------------------------- 1 | RSpec.shared_context 'capture logs' do 2 | let(:logger) { spy('logger') } 3 | let(:logs) { [] } 4 | 5 | before do 6 | Services.configuration.logger = logger 7 | allow(logger).to receive(:log) do |message, meta, severity| 8 | logs << { 9 | message: message, 10 | meta: meta, 11 | severity: severity 12 | } 13 | end 14 | end 15 | 16 | after do 17 | Services.configuration.logger = Services::Logger::Null.new 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/test_services.rb: -------------------------------------------------------------------------------- 1 | class Model 2 | class << self 3 | def table_name 4 | 'models' 5 | end 6 | 7 | # Stub ActiveRecord methods 8 | %i(select order where limit page per).each do |m| 9 | define_method m do |*args| 10 | self 11 | end 12 | end 13 | end 14 | 15 | attr_reader :id 16 | 17 | def initialize(id) 18 | @id = id 19 | ModelRepository.add self 20 | end 21 | 22 | def ==(another_model) 23 | self.id == another_model.id 24 | end 25 | end 26 | 27 | class ModelRepository 28 | def self.add(model) 29 | @models ||= [] 30 | @models << model 31 | end 32 | 33 | def self.find(id) 34 | return nil unless defined?(@models) 35 | @models.detect do |model| 36 | model.id == id 37 | end 38 | end 39 | end 40 | 41 | module Services 42 | module Models 43 | class Query < Services::Query 44 | private 45 | 46 | def process(scope, conditions) 47 | scope 48 | end 49 | end 50 | 51 | class Find < Services::Base 52 | def call(ids) 53 | ids.map { |id| ModelRepository.find id }.compact 54 | end 55 | end 56 | 57 | class FindObjectsTest < Services::Base 58 | def call(ids_or_objects) 59 | find_objects ids_or_objects 60 | end 61 | end 62 | 63 | class FindObjectTest < Services::Base 64 | def call(id_or_object) 65 | find_object id_or_object 66 | end 67 | end 68 | 69 | class FindIdsTest < Services::Base 70 | def call(ids_or_objects) 71 | find_ids ids_or_objects 72 | end 73 | end 74 | 75 | class FindIdTest < Services::Base 76 | def call(id_or_object) 77 | find_id id_or_object 78 | end 79 | end 80 | end 81 | end 82 | 83 | class EmptyService < Services::Base 84 | def call(*args, **kwargs) 85 | end 86 | end 87 | 88 | class EmptyServiceWithoutCallLogging < Services::Base 89 | disable_call_logging 90 | 91 | def call(*args, **kwargs) 92 | end 93 | end 94 | 95 | class ErrorService < Services::Base 96 | def call 97 | raise Error, "I am a service error raised by #{self.class}." 98 | end 99 | end 100 | 101 | class ErrorServiceWithoutCallLogging < Services::Base 102 | disable_call_logging 103 | 104 | def call 105 | raise Error, "I am a service error raised by #{self.class}." 106 | end 107 | end 108 | 109 | class ServiceCallingService < Services::Base 110 | def call(service) 111 | service.call 112 | end 113 | end 114 | 115 | class UniqueService < Services::Base 116 | def call(on_error, sleep) 117 | check_uniqueness on_error: on_error 118 | do_work 119 | # Sleep 6 seconds, this needs to be at least the time 120 | # between Sidekiq's "heartbeats", which occur every 5 seconds. 121 | sleep 6 if sleep 122 | end 123 | def do_work; end 124 | end 125 | 126 | class UniqueWithCustomArgsService < Services::Base 127 | def call(uniqueness_arg1, uniqueness_arg2, ignore_arg, on_error, sleep) 128 | check_uniqueness uniqueness_arg1, uniqueness_arg2, on_error: on_error 129 | do_work 130 | # Sleep 6 seconds, this needs to be at least the time 131 | # between Sidekiq's "heartbeats", which occur every 5 seconds. 132 | sleep 6 if sleep 133 | end 134 | def do_work; end 135 | end 136 | 137 | class UniqueMultipleService < Services::Base 138 | def call(*args, on_error, sleep) 139 | args.each do |arg| 140 | check_uniqueness arg, on_error: on_error 141 | end 142 | do_work 143 | # Sleep 6 seconds, this needs to be at least the time 144 | # between Sidekiq's "heartbeats", which occur every 5 seconds. 145 | sleep 6 if sleep 146 | end 147 | def do_work; end 148 | end 149 | 150 | class NonUniqueService < Services::Base 151 | def call(on_error, sleep) 152 | do_work 153 | # Sleep 6 seconds, this needs to be at least the time 154 | # between Sidekiq's "heartbeats", which occur every 5 seconds. 155 | sleep 6 if sleep 156 | end 157 | def do_work; end 158 | end 159 | 160 | class NestedExceptionService < Services::Base 161 | NestedError1 = Class.new(Error) 162 | NestedError2 = Class.new(Error) 163 | 164 | def call 165 | begin 166 | begin 167 | raise NestedError2 168 | rescue NestedError2 169 | raise NestedError1 170 | end 171 | rescue NestedError1 172 | raise Error 173 | end 174 | end 175 | end 176 | 177 | class AsyncService < Services::Base 178 | def call(foo, bar: 'baz', pelle:) 179 | raise [foo, bar, pelle].to_json 180 | end 181 | end 182 | --------------------------------------------------------------------------------