api_engine_base 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +32 -0
  5. data/app/controllers/api_engine_base/application_controller.rb +47 -0
  6. data/app/controllers/api_engine_base/auth/plain_text_controller.rb +132 -0
  7. data/app/controllers/api_engine_base/username_controller.rb +26 -0
  8. data/app/controllers/concerns/api_engine_base/schematizable.rb +5 -0
  9. data/app/helpers/api_engine_base/application_helper.rb +4 -0
  10. data/app/helpers/api_engine_base/schema_helper.rb +29 -0
  11. data/app/jobs/api_engine_base/application_job.rb +4 -0
  12. data/app/mailers/api_engine_base/application_mailer.rb +8 -0
  13. data/app/mailers/api_engine_base/email_verification_mailer.rb +12 -0
  14. data/app/models/api_engine_base/application_record.rb +7 -0
  15. data/app/models/user.rb +50 -0
  16. data/app/models/user_secret.rb +72 -0
  17. data/app/services/api_engine_base/argument_validation/class_methods.rb +179 -0
  18. data/app/services/api_engine_base/argument_validation/instance_methods.rb +136 -0
  19. data/app/services/api_engine_base/argument_validation.rb +11 -0
  20. data/app/services/api_engine_base/jwt/authenticate_user.rb +71 -0
  21. data/app/services/api_engine_base/jwt/decode.rb +21 -0
  22. data/app/services/api_engine_base/jwt/encode.rb +15 -0
  23. data/app/services/api_engine_base/jwt/login_create.rb +21 -0
  24. data/app/services/api_engine_base/jwt/time_delay_token.rb +17 -0
  25. data/app/services/api_engine_base/login_strategy/plain_text/create.rb +42 -0
  26. data/app/services/api_engine_base/login_strategy/plain_text/email_verification/generate.rb +29 -0
  27. data/app/services/api_engine_base/login_strategy/plain_text/email_verification/required.rb +20 -0
  28. data/app/services/api_engine_base/login_strategy/plain_text/email_verification/send.rb +23 -0
  29. data/app/services/api_engine_base/login_strategy/plain_text/email_verification/verify.rb +24 -0
  30. data/app/services/api_engine_base/login_strategy/plain_text/login.rb +50 -0
  31. data/app/services/api_engine_base/secrets/cleanse.rb +14 -0
  32. data/app/services/api_engine_base/secrets/generate.rb +62 -0
  33. data/app/services/api_engine_base/secrets/verify.rb +27 -0
  34. data/app/services/api_engine_base/secrets.rb +15 -0
  35. data/app/services/api_engine_base/service_base.rb +90 -0
  36. data/app/services/api_engine_base/service_logging.rb +41 -0
  37. data/app/services/api_engine_base/username/available.rb +64 -0
  38. data/app/views/api_engine_base/email_verification_mailer/verify_email.html.erb +26 -0
  39. data/config/routes.rb +23 -0
  40. data/db/migrate/20241117043720_create_api_engine_base_users.rb +33 -0
  41. data/db/migrate/20241204065708_create_api_engine_base_user_secrets.rb +16 -0
  42. data/lib/api_engine_base/configuration/application/config.rb +40 -0
  43. data/lib/api_engine_base/configuration/base.rb +11 -0
  44. data/lib/api_engine_base/configuration/config.rb +59 -0
  45. data/lib/api_engine_base/configuration/email/config.rb +87 -0
  46. data/lib/api_engine_base/configuration/jwt/config.rb +22 -0
  47. data/lib/api_engine_base/configuration/login/config.rb +18 -0
  48. data/lib/api_engine_base/configuration/login/strategy/plain_text/config.rb +57 -0
  49. data/lib/api_engine_base/configuration/login/strategy/plain_text/email_verify.rb +50 -0
  50. data/lib/api_engine_base/configuration/login/strategy/plain_text/lockable.rb +27 -0
  51. data/lib/api_engine_base/configuration/otp/config.rb +54 -0
  52. data/lib/api_engine_base/configuration/username/check.rb +31 -0
  53. data/lib/api_engine_base/configuration/username/config.rb +41 -0
  54. data/lib/api_engine_base/engine.rb +21 -0
  55. data/lib/api_engine_base/schema/error/base.rb +15 -0
  56. data/lib/api_engine_base/schema/error/invalid_argument.rb +15 -0
  57. data/lib/api_engine_base/schema/error/invalid_argument_response.rb +17 -0
  58. data/lib/api_engine_base/schema/plain_text/create_user_request.rb +18 -0
  59. data/lib/api_engine_base/schema/plain_text/create_user_response.rb +17 -0
  60. data/lib/api_engine_base/schema/plain_text/email_verify_request.rb +11 -0
  61. data/lib/api_engine_base/schema/plain_text/email_verify_response.rb +11 -0
  62. data/lib/api_engine_base/schema/plain_text/email_verify_send_request.rb +9 -0
  63. data/lib/api_engine_base/schema/plain_text/email_verify_send_response.rb +11 -0
  64. data/lib/api_engine_base/schema/plain_text/login_request.rb +15 -0
  65. data/lib/api_engine_base/schema/plain_text/login_response.rb +13 -0
  66. data/lib/api_engine_base/schema.rb +25 -0
  67. data/lib/api_engine_base/spec_helper.rb +18 -0
  68. data/lib/api_engine_base/version.rb +5 -0
  69. data/lib/api_engine_base.rb +33 -0
  70. data/lib/generators/api_engine_base/configure/USAGE +8 -0
  71. data/lib/generators/api_engine_base/configure/configure_generator.rb +12 -0
  72. data/lib/tasks/auto_annotate_models.rake +60 -0
  73. metadata +216 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 237d043d261233c9e45d4b7466dcacbfb4792636246fa2e211e765ee21b75141
4
+ data.tar.gz: 55d598e1abaf7a12a253fbc9ea8d010c9a11ac9ca5ea293e2a30f6cb42ffc759
5
+ SHA512:
6
+ metadata.gz: 53180702bea719474e8f9922858da3c1af7b6c44ce3ad1db78bb5c633c039b9b5bb7a524984a637e41dd16499478f896731f033a8c8beece0441eff76ac40fa9
7
+ data.tar.gz: '03397ea3efb5e7a68554ae600d4eda0d0f467d2990a14dbb4e1bf2ea2cd13a96e82abd679894e2943c51e097eab095c10434116303c1004404626d84d2805c5a'
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # ApiEngineBase
2
+ Short description and motivation.
3
+
4
+ ## Usage
5
+ How to use my plugin.
6
+
7
+ ## Installation
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem "api_engine_base"
12
+ ```
13
+
14
+ And then execute:
15
+ ```bash
16
+ $ bundle
17
+ ```
18
+
19
+ Or install it yourself as:
20
+ ```bash
21
+ $ gem install api_engine_base
22
+ ```
23
+
24
+ ## Contributing
25
+ Contribution directions go here.
26
+
27
+ ## License
28
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,32 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'ApiEngineBase'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.md')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ APP_RAKEFILE = File.expand_path("rails_app/Rakefile", __dir__)
18
+ load 'rails/tasks/engine.rake'
19
+
20
+ load 'rails/tasks/statistics.rake'
21
+
22
+ require 'bundler/gem_tasks'
23
+
24
+ require 'rake/testtask'
25
+
26
+ Rake::TestTask.new(:test) do |t|
27
+ t.libs << 'test'
28
+ t.pattern = 'test/**/*_test.rb'
29
+ t.verbose = false
30
+ end
31
+
32
+ task default: :test
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiEngineBase
4
+ class ApplicationController < ActionController::API
5
+ AUTHORIZATION_HEADER = "AUTHORIZATION"
6
+
7
+ ###
8
+ # AUTHORIZATION_HEADER="Bearer: {token value}"
9
+ def authenticate_user!(bypass_email_validation: false)
10
+ raw_token = request.headers[AUTHORIZATION_HEADER]
11
+ if raw_token.nil?
12
+ status = 401
13
+ schema = ApiEngineBase::Schema::Error::Base.new(status:, message: "Bearer token missing")
14
+ render(json: schema.to_h, status:)
15
+ return false
16
+ end
17
+
18
+ token = raw_token.split("Bearer:")[1].strip
19
+ result = ApiEngineBase::Jwt::AuthenticateUser.(token:, bypass_email_validation:)
20
+ if result.success?
21
+ @current_user = result.user
22
+ true
23
+ else
24
+ status = 401
25
+ schema = ApiEngineBase::Schema::Error::Base.new(status:, message: result.msg)
26
+ render(json: schema.to_h, status:)
27
+ # Must return false so callbacks know to halt propagation
28
+ false
29
+ end
30
+ end
31
+
32
+ def authenticate_user_without_email_verification!
33
+ authenticate_user!(bypass_email_validation: true)
34
+ end
35
+
36
+ def current_user
37
+ @current_user ||= nil
38
+ end
39
+
40
+ def add_to_body
41
+ # {
42
+ # token_valid_till:,
43
+ # needs_email_verification:,
44
+ # }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,132 @@
1
+ module ApiEngineBase
2
+ module Auth
3
+ class PlainTextController < ::ApiEngineBase::ApplicationController
4
+ include ApiEngineBase::SchemaHelper
5
+
6
+ before_action :authenticate_user_without_email_verification!, only: [:email_verify_post, :email_verify_resend_post]
7
+
8
+ # POST /auth/login
9
+ # Login to the application and create/set the JWT token
10
+ def login_post
11
+ result = ApiEngineBase::LoginStrategy::PlainText::Login.(**login_params)
12
+ if result.success?
13
+ schema = ApiEngineBase::Schema::PlainText::LoginResponse.new(
14
+ token: result.token,
15
+ header_name: AUTHORIZATION_HEADER,
16
+ message: "Successfully logged user in"
17
+ )
18
+ status = 201
19
+ schema_succesful!(status:, schema:)
20
+ else
21
+ if result.invalid_arguments
22
+ invalid_arguments!(
23
+ status: 401,
24
+ message: result.msg,
25
+ argument_object: result.invalid_argument_hash,
26
+ schema: ApiEngineBase::Schema::PlainText::LoginRequest
27
+ )
28
+ else
29
+ json_result = { msg: result.msg }
30
+ status = 400
31
+ render(json: schema.to_h, status:)
32
+ end
33
+ end
34
+ end
35
+
36
+ # POST /auth/create
37
+ # New PlainText user creation
38
+ def create_post
39
+ result = ApiEngineBase::LoginStrategy::PlainText::Create.(**create_params)
40
+ if result.success?
41
+ schema = ApiEngineBase::Schema::PlainText::CreateUserResponse.new(
42
+ full_name: result.user.full_name,
43
+ first_name: result.first_name,
44
+ last_name: result.last_name,
45
+ username: result.username,
46
+ email: result.email,
47
+ msg: "Successfully created new User",
48
+ )
49
+ status = 201
50
+ schema_succesful!(status:, schema:)
51
+ else
52
+ if result.invalid_arguments
53
+ invalid_arguments!(
54
+ status: 400,
55
+ message: result.msg,
56
+ argument_object: result.invalid_argument_hash,
57
+ schema: ApiEngineBase::Schema::PlainText::CreateUserRequest
58
+ )
59
+ end
60
+ end
61
+ end
62
+
63
+ # POST /auth/email/verify
64
+ # Verifies a logged in users email verification code when enabled
65
+ def email_verify_post
66
+ if current_user.email_validated
67
+ schema = ApiEngineBase::Schema::PlainText::EmailVerifyResponse.new(message: "Email is already verified.")
68
+ status = 200
69
+ schema_succesful!(status:, schema:)
70
+ else
71
+ result = ApiEngineBase::LoginStrategy::PlainText::EmailVerification::Verify.(user: current_user, code: params[:code])
72
+ if result.success?
73
+ schema = ApiEngineBase::Schema::PlainText::EmailVerifyResponse.new(message: "Successfully verified email")
74
+ status = 201
75
+ schema_succesful!(status:, schema:)
76
+ else
77
+ if result.invalid_arguments
78
+ invalid_arguments!(
79
+ status: result.status || 403,
80
+ message: result.msg,
81
+ argument_object: result.invalid_argument_hash,
82
+ schema: ApiEngineBase::Schema::PlainText::EmailVerifyRequest
83
+ )
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ # POST /auth/email/send
90
+ # Sends a logged in users email verification code
91
+ def email_verify_resend_post
92
+ if current_user.email_validated
93
+ schema = ApiEngineBase::Schema::PlainText::EmailVerifyResponse.new(message: "Email is already verified. No code required")
94
+ status = 200
95
+ schema_succesful!(status:, schema:)
96
+ else
97
+ result = ApiEngineBase::LoginStrategy::PlainText::EmailVerification::Send.(user: current_user)
98
+ if result.success?
99
+ schema = ApiEngineBase::Schema::PlainText::EmailVerifyResponse.new(message: "Successfully sent Email verification code")
100
+ status = 201
101
+ schema_succesful!(status:, schema:)
102
+ else
103
+ schema = ApiEngineBase::Schema::Error::Base.new(status:, message: result.msg)
104
+ status = result.status || 401
105
+ render(json: schema.to_h, status:)
106
+ end
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ def login_params
113
+ {
114
+ username: params[:username],
115
+ email: params[:email],
116
+ password: params[:password],
117
+ }
118
+ end
119
+
120
+ def create_params
121
+ {
122
+ first_name: params[:first_name],
123
+ last_name: params[:last_name],
124
+ username: params[:username],
125
+ email: params[:email],
126
+ password: params[:password],
127
+ password_confirmation: params[:password_confirmation],
128
+ }
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,26 @@
1
+ module ApiEngineBase
2
+ class UsernameController < ::ApiEngineBase::ApplicationController
3
+
4
+ # GET /username/available/:username
5
+ def username_availability
6
+ result = ApiEngineBase::Username::Available.(username: params[:username])
7
+
8
+ if result.success?
9
+ json_result = {
10
+ username: {
11
+ available: result.available,
12
+ valid: result.valid,
13
+ description: ApiEngineBase.config.username.username_failure_message
14
+ }
15
+ }
16
+ status = 200
17
+ else
18
+ json_result = { msg: result.msg }
19
+ json_result[:invalid_arguments] = true if result.invalid_arguments
20
+ status = 401
21
+ end
22
+
23
+ render json: json_result, status: status
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,5 @@
1
+ module ApiEngineBase
2
+ class Schematizable
3
+ end
4
+ end
5
+
@@ -0,0 +1,4 @@
1
+ module ApiEngineBase
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiEngineBase
4
+ module SchemaHelper
5
+ def schema_succesful!(schema:, status:)
6
+ render(json: schema.to_h, status:)
7
+ end
8
+
9
+ def invalid_arguments!(message:, argument_object:, schema:, status:)
10
+ bad_arguments = argument_object.map do |key, metadata|
11
+ ApiEngineBase::Schema::Error::InvalidArgument.new(
12
+ schema:,
13
+ argument: key,
14
+ argument_type: metadata[:type],
15
+ reason: metadata[:msg],
16
+ )
17
+ end
18
+
19
+ result = ApiEngineBase::Schema::Error::InvalidArgumentResponse.new(
20
+ invalid_arguments: bad_arguments,
21
+ invalid_argument_keys: argument_object.keys,
22
+ status:,
23
+ message:,
24
+ )
25
+
26
+ render(json: result.to_h, status:)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,4 @@
1
+ module ApiEngineBase
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiEngineBase
4
+ class ApplicationMailer < ActionMailer::Base
5
+ default from: "from@example.com"
6
+ layout "mailer"
7
+ end
8
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiEngineBase
4
+ class EmailVerificationMailer < ApplicationMailer
5
+ def verify_email(email, user, code)
6
+ subject = "Welcome to #{}"
7
+ @user = user
8
+ @code = code
9
+ mail(to: email, subject:)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiEngineBase
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # == Schema Information
4
+ #
5
+ # Table name: users
6
+ #
7
+ # id :bigint not null, primary key
8
+ # email :string(255) default(""), not null
9
+ # email_validated :boolean default(FALSE)
10
+ # first_name :string(255) default(""), not null
11
+ # last_known_timezone :string(255)
12
+ # last_known_timezone_update :datetime
13
+ # last_login :datetime
14
+ # last_login_strategy :string(255)
15
+ # last_name :string(255) default(""), not null
16
+ # password_consecutive_fail :integer default(0)
17
+ # password_digest :string(255) default(""), not null
18
+ # recovery_password_digest :string(255) default(""), not null
19
+ # successful_login :integer default(0)
20
+ # username :string(255)
21
+ # verifier_token :string(255)
22
+ # verifier_token_last_reset :datetime
23
+ # created_at :datetime not null
24
+ # updated_at :datetime not null
25
+ #
26
+ # Indexes
27
+ #
28
+ # index_users_on_username (username) UNIQUE
29
+ #
30
+ require "securerandom"
31
+
32
+ class User < ApiEngineBase::ApplicationRecord
33
+ has_secure_password
34
+
35
+ validates :username, uniqueness: true
36
+ validates :email, uniqueness: true
37
+
38
+ def full_name
39
+ "#{first_name} #{last_name}"
40
+ end
41
+
42
+ def retreive_verifier_token!
43
+ return verifier_token if verifier_token
44
+
45
+ value = SecureRandom.alphanumeric(32)
46
+ update!(verifier_token: value)
47
+
48
+ value
49
+ end
50
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # == Schema Information
4
+ #
5
+ # Table name: user_secrets
6
+ #
7
+ # id :bigint not null, primary key
8
+ # death_time :datetime
9
+ # extra :string(255)
10
+ # reason :string(255)
11
+ # secret :string(255)
12
+ # use_count :integer default(0)
13
+ # use_count_max :integer
14
+ # created_at :datetime not null
15
+ # updated_at :datetime not null
16
+ # user_id :bigint not null
17
+ #
18
+ # Indexes
19
+ #
20
+ # index_user_secrets_on_secret (secret) UNIQUE
21
+ # index_user_secrets_on_user_id (user_id)
22
+ #
23
+ # Foreign Keys
24
+ #
25
+ # fk_rails_... (user_id => users.id)
26
+ #
27
+ class UserSecret < ApplicationRecord
28
+ belongs_to :user
29
+
30
+ def self.find_record(secret:, reason: nil, access_count: true)
31
+ params = { secret:, reason: }.compact
32
+ record = where(**params).first
33
+ return { found: false } if record.nil?
34
+
35
+ record.access_count! if access_count
36
+
37
+ {
38
+ found: true,
39
+ valid: record.is_valid?,
40
+ record: record,
41
+ user: record.user,
42
+ }
43
+ end
44
+
45
+ def invalid_reason
46
+ arr = []
47
+ arr << "Expired secret." if !still_alive?
48
+ arr << "Secret used too many times." if !valid_use_count?
49
+
50
+ arr
51
+ end
52
+
53
+ def access_count!
54
+ update(use_count: use_count + 1)
55
+ end
56
+
57
+ def is_valid?
58
+ valid_use_count? && still_alive?
59
+ end
60
+
61
+ def valid_use_count?
62
+ return true if use_count_max.nil?
63
+
64
+ use_count <= use_count_max
65
+ end
66
+
67
+ def still_alive?
68
+ return true if death_time.nil?
69
+
70
+ death_time > Time.now
71
+ end
72
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiEngineBase::ArgumentValidation
4
+ module ClassMethods
5
+ ON_ARGUMENT_VALIDATION = [
6
+ DEFAULT_VALIDATION = :raise,
7
+ :fail_early,
8
+ :log,
9
+ ]
10
+
11
+ def on_argument_validation_assigned
12
+ @on_argument_validation ||= DEFAULT_VALIDATION
13
+ end
14
+
15
+ def on_argument_validation(set)
16
+ raise "Must be one of #{ON_ARGUMENT_VALIDATION}" unless ON_ARGUMENT_VALIDATION.include?(set)
17
+
18
+ @on_argument_validation = set
19
+ end
20
+
21
+ def compose_exact(count, name, required:, delegation: false, &block)
22
+ raise ApiEngineBase::ServiceBase::CompositionValidationError, "Count must be greater than 0" if count < 1
23
+
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"
27
+ {
28
+ message: "Expected [#{count}] of the keys to have a value assigned. #{language}",
29
+ is_valid: (input_count == count),
30
+ requirement: "Exactly #{count} key(s) of #{keys} must be provided.",
31
+ }
32
+ end
33
+
34
+ composition_validation_proc = Proc.new do
35
+ keys = compositions[__stacked_type.last[:name]][:keys]
36
+ if keys.length < count
37
+ raise ApiEngineBase::ServiceBase::CompositionValidationError, "Composition configuration error. Key [#{name}] expects EXACTLY #{count} keys to create an instance. Only #{keys.length} is provided for the composition. Please add more keys or reduce the expectation"
38
+ end
39
+ end
40
+ composition(name:, type: :compose_exact, required:, delegation:, composition_validation_proc:, validation_proc:, &block)
41
+ end
42
+
43
+ def at_most(count, name, required:, &block)
44
+ raise ApiEngineBase::ServiceBase::CompositionValidationError, "Count must be greater than 0" if count < 1
45
+
46
+ validation_proc = Proc.new do |input_count, keys|
47
+ {
48
+ message: "Expected at most #{count} keys assigned",
49
+ is_valid: (input_count <= count),
50
+ requirement: "Validation Error: At most #{count} key(s) of #{keys} can be provided.",
51
+ }
52
+ end
53
+
54
+ composition(name:, type: :at_most, required:, delegation: false, validation_proc:, &block)
55
+ end
56
+
57
+ def at_least(count, name, required:, &block)
58
+ raise ApiEngineBase::ServiceBase::CompositionValidationError, "Count must be greater than 0" if count < 1
59
+
60
+ validation_proc = Proc.new do |input_count, keys|
61
+ {
62
+ message: "Expected at least #{count} keys assigned. Available keys",
63
+ is_valid: (input_count >= count),
64
+ requirement: "Validation Error: At least #{count} key(s) of #{keys} must be provided.",
65
+ }
66
+ end
67
+
68
+ composition_validation_proc = Proc.new do
69
+ keys = compositions[__stacked_type.last[:name]][:keys]
70
+ if keys.length < count
71
+ raise ApiEngineBase::ServiceBase::CompositionValidationError, "Composition configuration error. Key [#{name}] expects AT LEAST #{count} keys to create an instance. Only #{keys.length} is provided for the composition. Please add more keys or reduce the expectation"
72
+ end
73
+ end
74
+ composition(name:, type: :at_least, required:, delegation: false, composition_validation_proc:, validation_proc:, &block)
75
+ end
76
+
77
+ def at_least_one(name, required:, &block)
78
+ at_least(1, name, required:, &block)
79
+ end
80
+
81
+ def one_of(name, required:, delegation: true, &block)
82
+ compose_exact(1, name, required:, delegation:, &block)
83
+ end
84
+
85
+ def composition(name:, type:, required:, delegation:, validation_proc:, composition_validation_proc: nil, &block)
86
+ compositions[name] ||= { type:, name:, keys: [], required:, delegation:, validation_proc: }
87
+ if __stacked_type.map { _1[:type] }.include?(type)
88
+ raise ApiEngineBase::ServiceBase::NestedDuplicateTypeError, "Duplicate Nested type's are not allowed. #{type} composition was included more than once"
89
+ end
90
+
91
+ __stacked_type << { type:, name: }
92
+
93
+ yield
94
+
95
+ if composition_validation_proc
96
+ composition_validation_proc.()
97
+ end
98
+
99
+ if __existing_names.include?(name)
100
+ raise ApiEngineBase::ServiceBase::NameConflictError, "Name conflict for #{name}. Duplicated as a key"
101
+ end
102
+
103
+ __existing_names << name
104
+ # returning from the yield...pop it from the stack.
105
+ # This allows us to know the current depth of where we are in the nested stack
106
+ __stacked_type.pop
107
+
108
+ if delegation
109
+ delegate name, to: :context
110
+ delegate :"#{name}_key", to: :context
111
+ end
112
+ end
113
+
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)
115
+ if __existing_names.include?(name)
116
+ raise ApiEngineBase::ServiceBase::NameConflictError, "Duplicate key name found. [#{name}] can only be defined once"
117
+ end
118
+
119
+ __existing_names << name
120
+
121
+ if default
122
+ if is_a
123
+ if Array(is_a).none? { _1 === default }
124
+ raise ApiEngineBase::ServiceBase::DefaultValueError, "Default value provided [#{default}] does not match any `is_a` value(s) of #{is_a}."
125
+ end
126
+ end
127
+
128
+ if is_one
129
+ if Array(is_one).none? { _1 == default }
130
+ raise ApiEngineBase::ServiceBase::DefaultValueError, "Default value provided [#{default}] does not match any `is_one` value(s) of #{is_one}."
131
+ end
132
+ end
133
+ end
134
+
135
+ if __stacked_type.length > 0
136
+ compositions[__stacked_type.last[:name]][:keys] << name
137
+ end
138
+
139
+ validate_params << {
140
+ name:,
141
+ is_a:,
142
+ lt:,
143
+ lte:,
144
+ eq:,
145
+ gt:,
146
+ gte:,
147
+ length:,
148
+ required:,
149
+ is_one:,
150
+ default:,
151
+ }
152
+ sensitive_params << name if sensitive
153
+
154
+ if delegation
155
+ delegate name, to: :context
156
+ end
157
+ end
158
+
159
+ def sensitive_params
160
+ @sensitive_params ||= []
161
+ end
162
+
163
+ def validate_params
164
+ @validate_params ||= []
165
+ end
166
+
167
+ def compositions
168
+ @compositions ||= {}
169
+ end
170
+
171
+ def __stacked_type
172
+ @__stacked_type ||= []
173
+ end
174
+
175
+ def __existing_names
176
+ @__existing_names ||= []
177
+ end
178
+ end
179
+ end