api_engine_base 0.1.1 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +37 -6
  3. data/app/controllers/api_engine_base/admin_controller.rb +104 -0
  4. data/app/controllers/api_engine_base/application_controller.rb +45 -11
  5. data/app/controllers/api_engine_base/auth/plain_text_controller.rb +1 -1
  6. data/app/controllers/api_engine_base/user_controller.rb +49 -0
  7. data/app/models/api_engine_base/application_record.rb +38 -0
  8. data/app/models/user.rb +13 -4
  9. data/app/services/api_engine_base/README.md +49 -0
  10. data/app/services/api_engine_base/argument_validation/README.md +192 -0
  11. data/app/services/api_engine_base/argument_validation/class_methods.rb +2 -3
  12. data/app/services/api_engine_base/argument_validation/instance_methods.rb +13 -1
  13. data/app/services/api_engine_base/authorize/validate.rb +49 -0
  14. data/app/services/api_engine_base/jwt/authenticate_user.rb +22 -7
  15. data/app/services/api_engine_base/jwt/login_create.rb +1 -1
  16. data/app/services/api_engine_base/service_base.rb +4 -5
  17. data/app/services/api_engine_base/user_attributes/modify.rb +68 -0
  18. data/app/services/api_engine_base/user_attributes/roles.rb +27 -0
  19. data/config/routes.rb +11 -0
  20. data/db/migrate/20241117043720_create_api_engine_base_users.rb +2 -0
  21. data/lib/api_engine_base/authorization/default.yml +34 -0
  22. data/lib/api_engine_base/authorization/entity.rb +101 -0
  23. data/lib/api_engine_base/authorization/role.rb +101 -0
  24. data/lib/api_engine_base/authorization.rb +85 -0
  25. data/lib/api_engine_base/configuration/admin/config.rb +18 -0
  26. data/lib/api_engine_base/configuration/application/config.rb +2 -2
  27. data/lib/api_engine_base/configuration/authorization/config.rb +24 -0
  28. data/lib/api_engine_base/configuration/config.rb +19 -1
  29. data/lib/api_engine_base/configuration/user/config.rb +56 -0
  30. data/lib/api_engine_base/engine.rb +38 -6
  31. data/lib/api_engine_base/error.rb +5 -0
  32. data/lib/api_engine_base/schema/admin/users.rb +15 -0
  33. data/lib/api_engine_base/schema/error/invalid_argument_response.rb +1 -1
  34. data/lib/api_engine_base/schema/page.rb +14 -0
  35. data/lib/api_engine_base/schema/user.rb +28 -0
  36. data/lib/api_engine_base/schema.rb +5 -0
  37. data/lib/api_engine_base/spec_helper.rb +4 -3
  38. data/lib/api_engine_base/version.rb +1 -1
  39. data/lib/api_engine_base.rb +2 -2
  40. metadata +22 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 237d043d261233c9e45d4b7466dcacbfb4792636246fa2e211e765ee21b75141
4
- data.tar.gz: 55d598e1abaf7a12a253fbc9ea8d010c9a11ac9ca5ea293e2a30f6cb42ffc759
3
+ metadata.gz: 5f2182e3fbdabb9d8dee95b45cd2308d4c9f60f1fe93b9cde7a4e1c543cbc2a8
4
+ data.tar.gz: e9a9063e61ff9f3815533db89a783a38da4eab6860c7511d86a4a3fcd8943c19
5
5
  SHA512:
6
- metadata.gz: 53180702bea719474e8f9922858da3c1af7b6c44ce3ad1db78bb5c633c039b9b5bb7a524984a637e41dd16499478f896731f033a8c8beece0441eff76ac40fa9
7
- data.tar.gz: '03397ea3efb5e7a68554ae600d4eda0d0f467d2990a14dbb4e1bf2ea2cd13a96e82abd679894e2943c51e097eab095c10434116303c1004404626d84d2805c5a'
6
+ metadata.gz: 36eaf340ded210a8dc26d5d10de1fbfacec838bef501618efff5c3adaad8dace49b3217e36ca614627bda213504511778dccef2c2d421c7978b238dbaca2b978
7
+ data.tar.gz: a849ee1f598893dc670b3f40b1984cbfbde3efa2b50c33e6c823b7a0360967c000e25583874174105466bec1f4920f76aa9247c7d0e8b05348d29ae935cc2dd4
data/README.md CHANGED
@@ -1,8 +1,7 @@
1
1
  # ApiEngineBase
2
- Short description and motivation.
2
+ This is an API only base engine to build on top of. This Engine takes care of all Authentication, Token Refresh, and RBAC Roles so that you do not have to! For all applications, you can get right to work on implementing the code directly related to your project rather than dealing with the administrative overhead.
3
3
 
4
- ## Usage
5
- How to use my plugin.
4
+ While this gem is heavily opinionated, everything can be configured to your liking.
6
5
 
7
6
  ## Installation
8
7
  Add this line to your application's Gemfile:
@@ -21,8 +20,40 @@ Or install it yourself as:
21
20
  $ gem install api_engine_base
22
21
  ```
23
22
 
24
- ## Contributing
25
- Contribution directions go here.
23
+ ## Initializing ApiEngineBase
24
+ Please follow all steps in [Initializing ApiEngineBase](docs/initializing.md)
25
+
26
+
27
+ ## Available Routes
28
+
29
+ For more info, check out [Controllers ReadMe](docs/controllers.md)
30
+
31
+ Additionally, You can check out [RSpec Integration Testing](/spec/integration_test)
32
+
33
+ ## Available Models
34
+
35
+ ApiEngineBase provides several Models at the in the root namespace. Core Models like `User` and `UserSecret` are readily available. Don't forget! You can add additional methods to these classes by opening them back up.
36
+
37
+ For more info, check out [Models ReadMe](doc/models.md)
38
+
39
+ ## Authentication (JWT BearerToken)
40
+ Authentication ensures that we know which user is requesting the action. When the Engine is unable to authenticate, a `401` status code is returned.
41
+
42
+ For more info, check out [Authentication ReadMe](docs/authentication.md)
43
+
44
+ ## Authorization (RBAC)
45
+ Authorization is only done after authentication. This is the act of ensuring that the user can perform the action it is requesting. Put differently, I know who you are, but I need to validate you have permissions to complete the action. When the engine is unable to authorize the user, a `403` status code is returned.
46
+
47
+ For more info, check out [Authentication ReadMe](docs/authorization.md)
48
+
49
+ ## Sensitive Changes
50
+
51
+ For more info, check out [Sensitive Routes](docs/sensitive_routes.md)
52
+
53
+ ## ServiceBase
54
+ ServiceBase is built on top of Interactor. The ServiceBase is the heart of all logic for ApiEngineBase. It includes Logging and enhanced ArgumentValidation that can directly return back to the API request.
55
+
56
+ For more info, check out [ServiceBase ReadMe](app/services/api_engine_base/README.md)
26
57
 
27
58
  ## License
28
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
59
+ The engine is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiEngineBase
4
+ class AdminController < ::ApiEngineBase::ApplicationController
5
+ include ApiEngineBase::SchemaHelper
6
+
7
+ before_action :authenticate_user!
8
+ before_action :authorize_user!
9
+ before_action :user!, only: [:modify, :modify_role]
10
+
11
+ # Pagination is needed here
12
+ def show
13
+ schemafied_users = User.all.map { ApiEngineBase::Schema::User.convert_user_object(user: _1) }
14
+ schema = ApiEngineBase::Schema::Admin::Users.new(users: schemafied_users)
15
+ schema_succesful!(status: 200, schema:)
16
+ end
17
+
18
+ def modify
19
+ result = ApiEngineBase::UserAttributes::Modify.(user:, admin_user:, **modify_params)
20
+ if result.success?
21
+ schema = ApiEngineBase::Schema::User.convert_user_object(user: user.reload)
22
+ status = 201
23
+ schema_succesful!(status:, schema:)
24
+ else
25
+ if result.invalid_arguments
26
+ invalid_arguments!(
27
+ status: 400,
28
+ message: result.msg,
29
+ argument_object: result.invalid_argument_hash,
30
+ schema: ApiEngineBase::Schema::PlainText::LoginRequest
31
+ )
32
+ else
33
+ server_error!(result:)
34
+ end
35
+ end
36
+ end
37
+
38
+ def modify_role
39
+ result = ApiEngineBase::UserAttributes::Roles.(user:, admin_user:, roles: params[:roles] || [])
40
+ if result.success?
41
+ schema = ApiEngineBase::Schema::User.convert_user_object(user: user.reload)
42
+ status = 201
43
+ schema_succesful!(status:, schema:)
44
+ else
45
+ if result.invalid_arguments
46
+ invalid_arguments!(
47
+ status: 400,
48
+ message: result.msg,
49
+ argument_object: result.invalid_argument_hash,
50
+ schema: ApiEngineBase::Schema::PlainText::LoginRequest
51
+ )
52
+ else
53
+ server_error!(result:)
54
+ end
55
+ end
56
+ end
57
+
58
+ def impersonate
59
+ # TODO: @matt-taylor
60
+ end
61
+
62
+ private
63
+
64
+ def server_error!(result:)
65
+ status = 500
66
+ schema = ApiEngineBase::Schema::Error::Base.new(status:, message: result.msg)
67
+ render(json: schema.to_h, status:)
68
+ end
69
+
70
+ def modify_params
71
+ {
72
+ email: params[:email],
73
+ email_validated: safe_boolean(value: params[:email_validated]),
74
+ first_name: params[:first_name],
75
+ last_name: params[:last_name],
76
+ username: params[:username],
77
+ verifier_token: safe_boolean(value: params[:verifier_token]),
78
+ }.compact
79
+ end
80
+
81
+ def admin_user
82
+ # current_user is defined via authenticate_user! before action
83
+ current_user
84
+ end
85
+
86
+ def user!
87
+ _user = User.where(id: params[:user_id]).first
88
+ if _user
89
+ @user = _user
90
+ return true
91
+ end
92
+
93
+ status = 400
94
+ schema = ApiEngineBase::Schema::Error::Base.new(status:, message: "Invalid user")
95
+ render(json: schema.to_h, status:)
96
+ # Must return false so callbacks know to halt propagation
97
+ false
98
+ end
99
+
100
+ def user
101
+ @user ||= nil
102
+ end
103
+ end
104
+ end
@@ -2,12 +2,21 @@
2
2
 
3
3
  module ApiEngineBase
4
4
  class ApplicationController < ActionController::API
5
- AUTHORIZATION_HEADER = "AUTHORIZATION"
5
+ AUTHENTICATION_HEADER = "Authentication"
6
+ AUTHENTICATION_EXPIRE_HEADER = "X-Authentication-Expire"
7
+ AUTHENTICATION_WITH_RESET = "X-Authentication-Reset"
8
+
9
+ def safe_boolean(value:)
10
+ return nil unless [true, false, "true", "false", "0", "1", 0, 1].include?(value)
11
+
12
+ ActiveModel::Type::Boolean.new.cast(value)
13
+ end
6
14
 
7
15
  ###
8
- # AUTHORIZATION_HEADER="Bearer: {token value}"
16
+ # Authenticate user via the passed in header
17
+ # AUTHENTICATION_HEADER="Bearer: {token value}"
9
18
  def authenticate_user!(bypass_email_validation: false)
10
- raw_token = request.headers[AUTHORIZATION_HEADER]
19
+ raw_token = request.headers[AUTHENTICATION_HEADER]
11
20
  if raw_token.nil?
12
21
  status = 401
13
22
  schema = ApiEngineBase::Schema::Error::Base.new(status:, message: "Bearer token missing")
@@ -16,9 +25,14 @@ module ApiEngineBase
16
25
  end
17
26
 
18
27
  token = raw_token.split("Bearer:")[1].strip
19
- result = ApiEngineBase::Jwt::AuthenticateUser.(token:, bypass_email_validation:)
28
+ with_reset = safe_boolean(value: request.headers[AUTHENTICATION_WITH_RESET])
29
+ result = ApiEngineBase::Jwt::AuthenticateUser.(token:, bypass_email_validation:, with_reset:)
20
30
  if result.success?
21
31
  @current_user = result.user
32
+ response.set_header(AUTHENTICATION_EXPIRE_HEADER, result.expires_at)
33
+ if with_reset
34
+ response.set_header(AUTHENTICATION_WITH_RESET, result.generated_token)
35
+ end
22
36
  true
23
37
  else
24
38
  status = 401
@@ -29,19 +43,39 @@ module ApiEngineBase
29
43
  end
30
44
  end
31
45
 
46
+ ###
47
+ # Authenticate user via the passed in header without validating email
32
48
  def authenticate_user_without_email_verification!
33
49
  authenticate_user!(bypass_email_validation: true)
34
50
  end
35
51
 
36
- def current_user
37
- @current_user ||= nil
52
+ ###
53
+ # After Authenticating user, see if the user needs authorization on the route
54
+ def authorize_user!
55
+ if current_user.nil?
56
+ Rails.logger.error { "Current User is not defined. This means that authenticate_user! was not called" }
57
+ status = 401
58
+ schema = ApiEngineBase::Schema::Error::Base.new(status:, message: "Bearer token missing")
59
+ render(json: schema.to_h, status:)
60
+ return false
61
+ end
62
+ result = ApiEngineBase::Authorize::Validate.(user: current_user, controller: self.class, method: params[:action])
63
+
64
+ if result.success?
65
+ @current_user = result.user
66
+ true
67
+ else
68
+ # Current user is not authorized for the current Controller#action
69
+ status = 403
70
+ schema = ApiEngineBase::Schema::Error::Base.new(status:, message: result.msg)
71
+ render(json: schema.to_h, status:)
72
+ # Must return false so callbacks know to halt propagation
73
+ false
74
+ end
38
75
  end
39
76
 
40
- def add_to_body
41
- # {
42
- # token_valid_till:,
43
- # needs_email_verification:,
44
- # }
77
+ def current_user
78
+ @current_user ||= nil
45
79
  end
46
80
  end
47
81
  end
@@ -12,7 +12,7 @@ module ApiEngineBase
12
12
  if result.success?
13
13
  schema = ApiEngineBase::Schema::PlainText::LoginResponse.new(
14
14
  token: result.token,
15
- header_name: AUTHORIZATION_HEADER,
15
+ header_name: AUTHENTICATION_HEADER,
16
16
  message: "Successfully logged user in"
17
17
  )
18
18
  status = 201
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiEngineBase
4
+ class UserController < ::ApiEngineBase::ApplicationController
5
+ include ApiEngineBase::SchemaHelper
6
+
7
+ before_action :authenticate_user!
8
+
9
+ def show
10
+ schema = ApiEngineBase::Schema::User.convert_user_object(user: current_user)
11
+ schema_succesful!(status: 200, schema:)
12
+ end
13
+
14
+ def modify
15
+ result = ApiEngineBase::UserAttributes::Modify.(user: current_user, **modify_params)
16
+ if result.success?
17
+ schema = ApiEngineBase::Schema::User.convert_user_object(user: current_user.reload)
18
+ status = 201
19
+ schema_succesful!(status:, schema:)
20
+ else
21
+ if result.invalid_arguments
22
+ invalid_arguments!(
23
+ status: 400,
24
+ message: result.msg,
25
+ argument_object: result.invalid_argument_hash,
26
+ schema: ApiEngineBase::Schema::PlainText::LoginRequest
27
+ )
28
+ else
29
+ status = 500
30
+ schema = ApiEngineBase::Schema::Error::Base.new(status:, message: result.msg)
31
+ render(json: schema.to_h, status:)
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def modify_params
39
+ {
40
+ email: params[:email],
41
+ email_validated: safe_boolean(value: params[:email_validated]),
42
+ first_name: params[:first_name],
43
+ last_name: params[:last_name],
44
+ username: params[:username],
45
+ verifier_token: safe_boolean(value: params[:verifier_token]),
46
+ }.compact
47
+ end
48
+ end
49
+ end
@@ -3,5 +3,43 @@
3
3
  module ApiEngineBase
4
4
  class ApplicationRecord < ActiveRecord::Base
5
5
  self.abstract_class = true
6
+
7
+ def self.attribute_to_type_mapping
8
+ @attribute_to_type_mapping ||= begin
9
+ mapping = ActiveSupport::HashWithIndifferentAccess.new
10
+ columns_hash.each do |attribute_name, metadata|
11
+ base = nil
12
+ ruby_type = nil
13
+ allowed_types = nil
14
+ serialized_type = nil
15
+ case metadata.type
16
+ when :string, :text
17
+ base = ruby_type = String
18
+ when :integer, :bigint
19
+ base = ruby_type = Integer
20
+ when :datetime, :time, :date
21
+ base = String
22
+ ruby_type = [DateTime, Time]
23
+ when :float, :decimal
24
+ base = ruby_type = Float
25
+ when :boolean
26
+ base = "Boolean"
27
+ ruby_type = [TrueClass, FalseClass]
28
+ allowed_types = [true, false]
29
+ else
30
+ # All else fails convert to String and continue
31
+ base = ruby_type = String
32
+ end
33
+
34
+ attribute_type = attribute_types[attribute_name]
35
+ if attribute_type.is_a?(ActiveRecord::Type::Serialized)
36
+ serialized_type = attribute_type.coder.object_class
37
+ end
38
+ mapping[attribute_name] = { serialized_type:, base:, ruby_type:, allowed_types: }.compact
39
+ end
40
+
41
+ mapping
42
+ end
43
+ end
6
44
  end
7
45
  end
data/app/models/user.rb CHANGED
@@ -16,6 +16,7 @@
16
16
  # password_consecutive_fail :integer default(0)
17
17
  # password_digest :string(255) default(""), not null
18
18
  # recovery_password_digest :string(255) default(""), not null
19
+ # roles :string(255) default([])
19
20
  # successful_login :integer default(0)
20
21
  # username :string(255)
21
22
  # verifier_token :string(255)
@@ -35,16 +36,24 @@ class User < ApiEngineBase::ApplicationRecord
35
36
  validates :username, uniqueness: true
36
37
  validates :email, uniqueness: true
37
38
 
39
+ ###
40
+ # Serialize the roles column to check for inclusion easily
41
+ serialize :roles, coder: JSON, type: Array
42
+
38
43
  def full_name
39
44
  "#{first_name} #{last_name}"
40
45
  end
41
46
 
42
- def retreive_verifier_token!
43
- return verifier_token if verifier_token
44
-
47
+ def reset_verifier_token!
45
48
  value = SecureRandom.alphanumeric(32)
46
- update!(verifier_token: value)
49
+ update!(verifier_token: value, verifier_token_last_reset: Time.now)
47
50
 
48
51
  value
49
52
  end
53
+
54
+ def retreive_verifier_token!
55
+ return verifier_token if verifier_token
56
+
57
+ reset_verifier_token!
58
+ end
50
59
  end
@@ -0,0 +1,49 @@
1
+ # ApiEngineBase Service
2
+
3
+ `ApiEngineBase::ServiceBase` an abstraction around the Ruby Gem Interactor. It dds custom functionality to the base Service and is intended to be an inherited Class to create Application logic code. All Services in `ApiEngineBase` utilize this base Service class for convenience and DRYness.
4
+
5
+ ## What does ServiceBase offer
6
+
7
+ ### Logging
8
+ `ServiceBase` offers a convenient way to tag logs. It keeps track of:
9
+ - The start of the the Logic call
10
+ - The time it took to complete the logic
11
+ - The status of the logic
12
+
13
+ Additionally, it provides some convenience methods for logging
14
+ - `log_debug`
15
+ - `log_info`
16
+ - `log_warn`
17
+ - `log_error`
18
+
19
+ ### Argument Validation
20
+ Argument Validation is the powerhouse behind ServiceBase
21
+
22
+ Customized argument validation can be created by adding the method `validate!`
23
+ ```ruby
24
+ class MyServiceClass < ApiEngineBase::ServiceBase
25
+
26
+ def call
27
+ end
28
+
29
+ def validate!
30
+ # run custom validations before executing call
31
+ end
32
+ end
33
+ ```
34
+
35
+ Other more complex Argument validation includes:
36
+ - Validating Presence of Argument
37
+ - Validating Type of argument
38
+ - Validating a composition of argument values (At least, At Most, Exactly)
39
+ - Delegate context variable to the class for simplicity
40
+ - Validating Argument length or size is `<` `≤` `==` `>` `≥`
41
+
42
+ For More information, Check out the [ArgumentValidation ReadMe](argument_validation/README.md)
43
+
44
+
45
+ ## Basic Examples:
46
+ Check out the examples used in this directory!
47
+
48
+
49
+
@@ -0,0 +1,192 @@
1
+ # Argument Validation
2
+
3
+ Argument validation provides a robust framework to ensure correctness of arguments before executing any application logic code. This was created because when in use with an API, this can help provide reusable messaging directly back to the API when the parameters are incorrect.
4
+
5
+ ## Argument Validation
6
+ Argument Validation provides service object code assurances on what to expect for inputted arguments.
7
+
8
+ Available arguments:
9
+ - `default`: Default value to set the argument when not provided by user
10
+ - `is_a`: The allowed types of the passed in argument. Will also check if the type is in the ancestral tree
11
+ - `is_one`: Checks a direct comparison if the input is one of these values. Note: while not disallowed, `is_a` and `is_one` should not be used together
12
+ - `length`: (used with operators exclusively) When set to true, the operators will use the length of value rather than the exact value
13
+ - `lt`: When provided, argument must be less than this value
14
+ - `lte`: When provided, argument must be less than or equal to this value
15
+ - `eq`: When provided, argument must be equal to this value
16
+ - `gte`: When provided, argument must be greater than or equal to this value
17
+ - `gt`: When provided, argument must be greater than this value
18
+ - `delegation`: (Default set to true) - Sets the delegation on the object. This allows you to reference the argument name rather than the context.{argument_name}
19
+ - `sensitive`: This marks the argument as sensitive. It will scrub the value of the argument when returning the context to the caller
20
+ - `required`: When set, this marks the argument as required. If not provided, validations are not run. When provided, validations must pass
21
+
22
+ ## Argument Composition
23
+ Argument Compositions are made up of 1 or more Argument Validations. The intention of compositions are to ensure `at_most`, `at_least`, or `exactly` X argument validations are provided by the user.
24
+
25
+ ### Composition: At Most
26
+ At most composition expects at most X arguments to get passed into the instance.
27
+
28
+ ```ruby
29
+ class ServiceExample < ApiEngineBase::ServiceBase
30
+ at_most 2, :name_of_composition, required: true do
31
+ validate :email, is_a: String
32
+ validate :phone, is_a: String
33
+ validate :username, is_a: String
34
+ end
35
+
36
+ def call; end
37
+ end
38
+ ```
39
+
40
+ ```ruby
41
+ rails-app(dev)> ServiceExample.(email: "email", phone: "phone", username: "username")
42
+ => # Composite Key failure for name_of_composition [name_of_composition]. Expected at most 2 keys assigned. Provided values for the following keys: [:email, :phone, :username]. Available keys [:email, :phone, :username] (ApiEngineBase::ServiceBase::CompositionValidationError)
43
+ ```
44
+
45
+ ### Composition: At Least
46
+ At least composition expects at least X arguments to get passed into the instance.
47
+
48
+ ```ruby
49
+ class ServiceExample < ApiEngineBase::ServiceBase
50
+ at_least 2, :name_of_composition, required: true do
51
+ validate :email, is_a: String
52
+ validate :phone, is_a: String
53
+ validate :username, is_a: String
54
+ end
55
+
56
+ def call; end
57
+ end
58
+ ```
59
+
60
+ ```ruby
61
+ rails-app(dev)> ServiceExample.(email: "email")
62
+ => # Composite Key failure for name_of_composition [name_of_composition]. Expected at least 2 keys assigned. Available keys. Provided values for the following keys: [:email]. Available keys [:email, :phone, :username] (ApiEngineBase::ServiceBase::CompositionValidationError)
63
+ ```
64
+
65
+ **Noteworthy**: `at_least` can take in any integer for its `count`. However, we found that most people just need one. For that reason, the convenience method of `at_least_one` was created. It can be used without the `count` argument in `at_least`
66
+
67
+ ### Composition: Compose Exact
68
+ Compose Exact composition expects exactly X arguments to get passed into the instance. For this composition to be valid, there must be X or more validations.
69
+
70
+ ```ruby
71
+ class ServiceExample < ApiEngineBase::ServiceBase
72
+ compose_exact 2, :name_of_composition, required: true do
73
+ validate :email, is_a: String
74
+ validate :phone, is_a: String
75
+ validate :username, is_a: String
76
+ end
77
+
78
+ def call; end
79
+ end
80
+ ```
81
+ ```ruby
82
+ rails-app(dev)> ServiceExample.(email: "email")
83
+ => # Composite Key failure for name_of_composition [name_of_composition]. Expected [2] of the keys to have a value assigned. But 1 keys were assigned. Provided values for the following keys: [:email]. Available keys [:email, :phone, :username] (ApiEngineBase::ServiceBase::CompositionValidationError)
84
+ ```
85
+
86
+ **Noteworthy**: `compose_exact` can take any `count` value to dynamically provision the exact component. However, we found that we almost only just needed count == 1. We have provided a convenience method of `one_of` without the `count` variable to simplify. There are quite a few examples of this already created
87
+
88
+ ### Custom Compositions
89
+ All compositions are built on top of the same underlying function. This allows you to build additional compositions to add custom logic for validations and what not.
90
+ Check out the [ClassMethods Source Code](class_methods.rb) on what method arguments are required.
91
+
92
+
93
+ ## Argument validation Failures
94
+ When an argument validation fails (whether that is a single `validate` or a composition), there are 3 options on what to do:
95
+
96
+ ### Raise an error (Default)
97
+ As you can see in the examples above, the default for argument validation failures is to raise the following error
98
+ ```ruby
99
+ ApiEngineBase::ServiceBase::CompositionValidationError
100
+ ```
101
+
102
+ The expected behavior is:
103
+ - Downstream code catches the failure and handles it correctly
104
+ - Service Logic code is not executed
105
+
106
+ This failure mode can get explicitly set via:
107
+ ```ruby
108
+ class ServiceExample < ApiEngineBase::ServiceBase
109
+ on_argument_validation :raise
110
+
111
+ one_of :name_of_composition, required: true do
112
+ validate :email, is_a: String
113
+ validate :phone, is_a: String
114
+ validate :username, is_a: String
115
+ end
116
+
117
+ def call; end
118
+ end
119
+ ```
120
+
121
+ ### Fail the context Early (Recommended)
122
+ Failing the context early is we recommend to do for your service objects. This mode provides an exceptionally amount of context into **HOW** the validation failed and what needs to get corrected.
123
+
124
+
125
+ The expected behavior is:
126
+ - Downstream code checks for `result.failure?` and continues accordingly
127
+ - Service Logic code is not executed
128
+ - Nothing is raised
129
+
130
+ ```ruby
131
+ class ServiceExample < ApiEngineBase::ServiceBase
132
+ on_argument_validation :fail_early
133
+
134
+ one_of :name_of_composition, required: true do
135
+ validate :email, is_a: String
136
+ validate :phone, is_a: String
137
+ validate :username, is_a: String
138
+ end
139
+
140
+ def call; end
141
+ end
142
+ ```
143
+ ```ruby
144
+ result = ServiceExample.(email: :not_a_string)
145
+ if result.success?
146
+ else
147
+ if result.invalid_arguments
148
+ # context.fail! was called by argument validation
149
+ puts result.invalid_arguments
150
+ puts result.invalid_argument_hash
151
+ puts result.invalid_argument_keys
152
+ else
153
+ # context.fail! was called by user
154
+ end
155
+ end
156
+ => true
157
+ => {:email=>{:msg=>"Parameter [email] must be of type String. Given Symbol [not_a_string]", :required=>nil, :is_a=>String}}
158
+ => [:email]
159
+
160
+ result = ServiceExample.()
161
+ result.invalid_arguments
162
+ => true
163
+ result.invalid_argument_hash
164
+ => {:name_of_composition=>{:msg=>"Composite Key failure for name_of_composition [name_of_composition]. Expected [1] of the keys to have a value assigned. But no key was assigned. Provided values for the following keys: []. Available keys [:email, :phone, :username]", :required=>nil, :is_a=>nil}}
165
+ result.invalid_argument_keys
166
+ => [:name_of_composition]
167
+
168
+ result = ServiceExample.(email: 7, username: 8)
169
+ result.invalid_arguments
170
+ => true
171
+ result.invalid_argument_hash
172
+ => {:email=>{:msg=>"Parameter [email] must be of type String. Given Integer [7]", :required=>nil, :is_a=>String}, :username=>{:msg=>"Parameter [username] must be of type String. Given Integer [8]", :required=>nil, :is_a=>String}, :name_of_composition=>{:msg=>"Composite Key failure for name_of_composition [name_of_composition]. Expected [1] of the keys to have a value assigned. But 2 keys were assigned. Provided values for the following keys: [:email, :username]. Available keys [:email, :phone, :username]", :required=>nil, :is_a=>nil}}
173
+ => [:email, :username, :name_of_composition]
174
+ ```
175
+
176
+ ### Log and Continue (Not Recommended)
177
+ This mode will allow you to log the validation failure and continue. We do not recommend this
178
+
179
+
180
+ ```ruby
181
+ class ServiceExample < ApiEngineBase::ServiceBase
182
+ on_argument_validation :log
183
+
184
+ one_of :name_of_composition, required: true do
185
+ validate :email, is_a: String
186
+ validate :phone, is_a: String
187
+ validate :username, is_a: String
188
+ end
189
+
190
+ def call; end
191
+ end
192
+ ```
@@ -22,8 +22,7 @@ module ApiEngineBase::ArgumentValidation
22
22
  raise ApiEngineBase::ServiceBase::CompositionValidationError, "Count must be greater than 0" if count < 1
23
23
 
24
24
  validation_proc = Proc.new do |input_count, keys|
25
-
26
- language = (input_count > 1) ? "But more than #{count} did" : "But no key had a value"
25
+ language = (input_count > 0) ? "But #{input_count} keys were assigned" : "But no key was assigned"
27
26
  {
28
27
  message: "Expected [#{count}] of the keys to have a value assigned. #{language}",
29
28
  is_valid: (input_count == count),
@@ -111,7 +110,7 @@ module ApiEngineBase::ArgumentValidation
111
110
  end
112
111
  end
113
112
 
114
- def validate(name, default: nil, length: false, matches: nil, is_a: nil, is_one: nil, lt: nil, lte: nil, eq: nil, gt: nil, gte: nil, delegation: true, sensitive: false, required: false)
113
+ def validate(name, default: nil, length: false, is_a: nil, is_one: nil, lt: nil, lte: nil, eq: nil, gt: nil, gte: nil, delegation: true, sensitive: false, required: false)
115
114
  if __existing_names.include?(name)
116
115
  raise ApiEngineBase::ServiceBase::NameConflictError, "Duplicate key name found. [#{name}] can only be defined once"
117
116
  end
@@ -50,7 +50,19 @@ module ApiEngineBase::ArgumentValidation
50
50
  end
51
51
 
52
52
  if is_a = metadata[:is_a]
53
- if Array(is_a).none? { _1 === value }
53
+ direct_type = false
54
+ ancestor_type = false
55
+
56
+ # Check if direct type of `is_a` Integer === 5 => true
57
+ direct_type = Array(is_a).none? { _1 === value }
58
+
59
+ # If it is a direct type, we dont need to do any other type of checking
60
+ if direct_type == true
61
+ lineage = value.ancestors rescue []
62
+ # Check inclusion in ancestor list
63
+ ancestor_type = Array(is_a).none? { lineage.include?(_1) }
64
+ end
65
+ if direct_type && ancestor_type
54
66
  __failed_argument_validation(msg: "Parameter [#{metadata[:name]}] must be of type #{is_a}. Given #{value.class} [#{value}]", argument: metadata[:name], metadata:)
55
67
  end
56
68
  end