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.
- checksums.yaml +4 -4
- data/README.md +37 -6
- data/app/controllers/api_engine_base/admin_controller.rb +104 -0
- data/app/controllers/api_engine_base/application_controller.rb +45 -11
- data/app/controllers/api_engine_base/auth/plain_text_controller.rb +1 -1
- data/app/controllers/api_engine_base/user_controller.rb +49 -0
- data/app/models/api_engine_base/application_record.rb +38 -0
- data/app/models/user.rb +13 -4
- data/app/services/api_engine_base/README.md +49 -0
- data/app/services/api_engine_base/argument_validation/README.md +192 -0
- data/app/services/api_engine_base/argument_validation/class_methods.rb +2 -3
- data/app/services/api_engine_base/argument_validation/instance_methods.rb +13 -1
- data/app/services/api_engine_base/authorize/validate.rb +49 -0
- data/app/services/api_engine_base/jwt/authenticate_user.rb +22 -7
- data/app/services/api_engine_base/jwt/login_create.rb +1 -1
- data/app/services/api_engine_base/service_base.rb +4 -5
- data/app/services/api_engine_base/user_attributes/modify.rb +68 -0
- data/app/services/api_engine_base/user_attributes/roles.rb +27 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20241117043720_create_api_engine_base_users.rb +2 -0
- data/lib/api_engine_base/authorization/default.yml +34 -0
- data/lib/api_engine_base/authorization/entity.rb +101 -0
- data/lib/api_engine_base/authorization/role.rb +101 -0
- data/lib/api_engine_base/authorization.rb +85 -0
- data/lib/api_engine_base/configuration/admin/config.rb +18 -0
- data/lib/api_engine_base/configuration/application/config.rb +2 -2
- data/lib/api_engine_base/configuration/authorization/config.rb +24 -0
- data/lib/api_engine_base/configuration/config.rb +19 -1
- data/lib/api_engine_base/configuration/user/config.rb +56 -0
- data/lib/api_engine_base/engine.rb +38 -6
- data/lib/api_engine_base/error.rb +5 -0
- data/lib/api_engine_base/schema/admin/users.rb +15 -0
- data/lib/api_engine_base/schema/error/invalid_argument_response.rb +1 -1
- data/lib/api_engine_base/schema/page.rb +14 -0
- data/lib/api_engine_base/schema/user.rb +28 -0
- data/lib/api_engine_base/schema.rb +5 -0
- data/lib/api_engine_base/spec_helper.rb +4 -3
- data/lib/api_engine_base/version.rb +1 -1
- data/lib/api_engine_base.rb +2 -2
- metadata +22 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5f2182e3fbdabb9d8dee95b45cd2308d4c9f60f1fe93b9cde7a4e1c543cbc2a8
|
4
|
+
data.tar.gz: e9a9063e61ff9f3815533db89a783a38da4eab6860c7511d86a4a3fcd8943c19
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 36eaf340ded210a8dc26d5d10de1fbfacec838bef501618efff5c3adaad8dace49b3217e36ca614627bda213504511778dccef2c2d421c7978b238dbaca2b978
|
7
|
+
data.tar.gz: a849ee1f598893dc670b3f40b1984cbfbde3efa2b50c33e6c823b7a0360967c000e25583874174105466bec1f4920f76aa9247c7d0e8b05348d29ae935cc2dd4
|
data/README.md
CHANGED
@@ -1,8 +1,7 @@
|
|
1
1
|
# ApiEngineBase
|
2
|
-
|
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
|
-
|
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
|
-
##
|
25
|
-
|
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
|
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
|
-
|
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
|
-
#
|
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[
|
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
|
-
|
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
|
-
|
37
|
-
|
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
|
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:
|
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
|
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,
|
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
|
-
|
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
|