railsmith 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +64 -0
- data/LICENSE.txt +21 -0
- data/MIGRATION.md +156 -0
- data/README.md +249 -0
- data/Rakefile +14 -0
- data/docs/cookbook.md +605 -0
- data/docs/legacy-adoption.md +283 -0
- data/docs/quickstart.md +110 -0
- data/lib/generators/railsmith/domain/domain_generator.rb +57 -0
- data/lib/generators/railsmith/domain/templates/domain.rb.tt +14 -0
- data/lib/generators/railsmith/install/install_generator.rb +21 -0
- data/lib/generators/railsmith/install/templates/railsmith.rb +10 -0
- data/lib/generators/railsmith/model_service/model_service_generator.rb +121 -0
- data/lib/generators/railsmith/model_service/templates/model_service.rb.tt +28 -0
- data/lib/generators/railsmith/operation/operation_generator.rb +88 -0
- data/lib/generators/railsmith/operation/templates/operation.rb.tt +27 -0
- data/lib/railsmith/arch_checks/cli.rb +79 -0
- data/lib/railsmith/arch_checks/direct_model_access_checker.rb +94 -0
- data/lib/railsmith/arch_checks/missing_service_usage_checker.rb +206 -0
- data/lib/railsmith/arch_checks/violation.rb +14 -0
- data/lib/railsmith/arch_checks.rb +7 -0
- data/lib/railsmith/arch_report.rb +96 -0
- data/lib/railsmith/base_service/bulk_actions.rb +77 -0
- data/lib/railsmith/base_service/bulk_contract.rb +56 -0
- data/lib/railsmith/base_service/bulk_execution.rb +68 -0
- data/lib/railsmith/base_service/bulk_params.rb +56 -0
- data/lib/railsmith/base_service/crud_actions.rb +63 -0
- data/lib/railsmith/base_service/crud_error_mapping.rb +78 -0
- data/lib/railsmith/base_service/crud_model_resolution.rb +36 -0
- data/lib/railsmith/base_service/crud_record_helpers.rb +60 -0
- data/lib/railsmith/base_service/crud_transactions.rb +31 -0
- data/lib/railsmith/base_service/domain_context_propagation.rb +29 -0
- data/lib/railsmith/base_service/dup_helpers.rb +15 -0
- data/lib/railsmith/base_service/validation.rb +67 -0
- data/lib/railsmith/base_service.rb +96 -0
- data/lib/railsmith/configuration.rb +18 -0
- data/lib/railsmith/cross_domain_guard.rb +90 -0
- data/lib/railsmith/cross_domain_warning_formatter.rb +66 -0
- data/lib/railsmith/deep_dup.rb +20 -0
- data/lib/railsmith/domain_context.rb +44 -0
- data/lib/railsmith/errors.rb +50 -0
- data/lib/railsmith/instrumentation.rb +64 -0
- data/lib/railsmith/railtie.rb +10 -0
- data/lib/railsmith/result.rb +60 -0
- data/lib/railsmith/version.rb +5 -0
- data/lib/railsmith.rb +31 -0
- data/lib/tasks/railsmith.rake +24 -0
- data/sig/railsmith.rbs +4 -0
- metadata +116 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
class BaseService
|
|
5
|
+
# Defines the bulk result/value contract.
|
|
6
|
+
# @api private
|
|
7
|
+
module BulkContract
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def bulk_value(operation:, items:, results:, transaction_mode:)
|
|
11
|
+
item_payloads =
|
|
12
|
+
items.zip(results).each_with_index.map do |(item, result), index|
|
|
13
|
+
bulk_item_payload(item:, result:, index:)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
operation: operation.to_s,
|
|
18
|
+
transaction_mode: transaction_mode.to_s,
|
|
19
|
+
items: item_payloads,
|
|
20
|
+
summary: bulk_summary(results)
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def bulk_item_payload(item:, result:, index:)
|
|
25
|
+
{
|
|
26
|
+
index:,
|
|
27
|
+
input: item,
|
|
28
|
+
success: result.success?,
|
|
29
|
+
value: result.success? ? result.value : nil,
|
|
30
|
+
error: result.failure? ? result.error.to_h : nil
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def bulk_summary(results)
|
|
35
|
+
success_count = results.count(&:success?)
|
|
36
|
+
failure_count = results.count(&:failure?)
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
total: results.size,
|
|
40
|
+
success_count:,
|
|
41
|
+
failure_count:,
|
|
42
|
+
all_succeeded: failure_count.zero?
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def bulk_meta(model_klass, operation:, transaction_mode:, limit:)
|
|
47
|
+
{
|
|
48
|
+
model: model_klass.name,
|
|
49
|
+
operation: operation.to_s,
|
|
50
|
+
transaction_mode: transaction_mode.to_s,
|
|
51
|
+
limit:
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
class BaseService
|
|
5
|
+
# Implements bulk execution strategies (transaction modes, limits).
|
|
6
|
+
# @api private
|
|
7
|
+
module BulkExecution
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
# This project targets Ruby versions where anonymous block forwarding (`&`) may be unavailable.
|
|
11
|
+
# rubocop:disable Style/ArgumentsForwarding
|
|
12
|
+
def apply_bulk_operation(model_klass, operation:, items:, transaction_mode:, &block)
|
|
13
|
+
limit = bulk_limit
|
|
14
|
+
return bulk_limit_exceeded_result(limit:, count: items.size) if items.size > limit
|
|
15
|
+
|
|
16
|
+
results = apply_bulk_results(model_klass, items, transaction_mode:, &block)
|
|
17
|
+
|
|
18
|
+
Result.success(
|
|
19
|
+
value: bulk_value(operation:, items:, results:, transaction_mode:),
|
|
20
|
+
meta: bulk_meta(model_klass, operation:, transaction_mode:, limit:)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
# rubocop:enable Style/ArgumentsForwarding
|
|
24
|
+
|
|
25
|
+
# rubocop:disable Style/ArgumentsForwarding
|
|
26
|
+
def apply_bulk_results(model_klass, items, transaction_mode:, &block)
|
|
27
|
+
return apply_bulk_all_or_nothing(model_klass, items, &block) if transaction_mode == :all_or_nothing
|
|
28
|
+
|
|
29
|
+
apply_bulk_best_effort(model_klass, items, &block)
|
|
30
|
+
end
|
|
31
|
+
# rubocop:enable Style/ArgumentsForwarding
|
|
32
|
+
|
|
33
|
+
def apply_bulk_best_effort(model_klass, items, &block)
|
|
34
|
+
bulk_map(items) do |item|
|
|
35
|
+
with_transaction(model_klass) { block.call(item) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def apply_bulk_all_or_nothing(model_klass, items, &block)
|
|
40
|
+
results = nil
|
|
41
|
+
transaction_wrapper(model_klass) do
|
|
42
|
+
results = bulk_map(items) { |item| block.call(item) }
|
|
43
|
+
rollback_transaction if results.any?(&:failure?)
|
|
44
|
+
end
|
|
45
|
+
results
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def bulk_map(items)
|
|
49
|
+
results = []
|
|
50
|
+
items.each_slice(bulk_batch_size) do |slice|
|
|
51
|
+
slice.each do |item|
|
|
52
|
+
results << yield(item)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
results
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def bulk_limit_exceeded_result(limit:, count:)
|
|
59
|
+
Result.failure(
|
|
60
|
+
error: Errors.validation_error(
|
|
61
|
+
message: "Bulk limit exceeded",
|
|
62
|
+
details: { limit:, count: }
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
class BaseService
|
|
5
|
+
# Parses and normalizes bulk action params.
|
|
6
|
+
# @api private
|
|
7
|
+
module BulkParams
|
|
8
|
+
DEFAULT_BULK_LIMIT = 1000
|
|
9
|
+
DEFAULT_BATCH_SIZE = 100
|
|
10
|
+
TRANSACTION_MODE_ALL_OR_NOTHING = :all_or_nothing
|
|
11
|
+
TRANSACTION_MODE_BEST_EFFORT = :best_effort
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def bulk_items
|
|
16
|
+
return [] unless params.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
items = params[:items]
|
|
19
|
+
return items if items.is_a?(Array)
|
|
20
|
+
|
|
21
|
+
[]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def bulk_limit
|
|
25
|
+
return DEFAULT_BULK_LIMIT unless params.is_a?(Hash)
|
|
26
|
+
|
|
27
|
+
configured = params[:limit]
|
|
28
|
+
return DEFAULT_BULK_LIMIT unless configured.is_a?(Integer)
|
|
29
|
+
return DEFAULT_BULK_LIMIT if configured <= 0
|
|
30
|
+
|
|
31
|
+
configured
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def bulk_batch_size
|
|
35
|
+
return DEFAULT_BATCH_SIZE unless params.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
configured = params[:batch_size]
|
|
38
|
+
return DEFAULT_BATCH_SIZE unless configured.is_a?(Integer)
|
|
39
|
+
return DEFAULT_BATCH_SIZE if configured <= 0
|
|
40
|
+
|
|
41
|
+
configured
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def bulk_transaction_mode
|
|
45
|
+
return TRANSACTION_MODE_ALL_OR_NOTHING unless params.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
mode = params[:transaction_mode]
|
|
48
|
+
mode = mode.to_sym if mode.respond_to?(:to_sym)
|
|
49
|
+
|
|
50
|
+
return mode if [TRANSACTION_MODE_ALL_OR_NOTHING, TRANSACTION_MODE_BEST_EFFORT].include?(mode)
|
|
51
|
+
|
|
52
|
+
TRANSACTION_MODE_ALL_OR_NOTHING
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
class BaseService
|
|
5
|
+
# Default `create`/`update`/`destroy` action implementations.
|
|
6
|
+
# @api private
|
|
7
|
+
module CrudActions
|
|
8
|
+
def create
|
|
9
|
+
model_klass = model_class
|
|
10
|
+
return missing_model_class_result unless model_klass
|
|
11
|
+
|
|
12
|
+
with_transaction(model_klass) do
|
|
13
|
+
record = build_record(model_klass, sanitize_attributes(attributes_params))
|
|
14
|
+
persist_write(record, method_name: :save)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def update
|
|
19
|
+
model_klass = model_class
|
|
20
|
+
return missing_model_class_result unless model_klass
|
|
21
|
+
|
|
22
|
+
with_transaction(model_klass) do
|
|
23
|
+
record_result = find_record(model_klass, record_id)
|
|
24
|
+
return record_result if record_result.failure?
|
|
25
|
+
|
|
26
|
+
record = record_result.value
|
|
27
|
+
assign_attributes(record, sanitize_attributes(attributes_params))
|
|
28
|
+
persist_write(record, method_name: :save)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def destroy
|
|
33
|
+
model_klass = model_class
|
|
34
|
+
return missing_model_class_result unless model_klass
|
|
35
|
+
|
|
36
|
+
with_transaction(model_klass) do
|
|
37
|
+
record_result = find_record(model_klass, record_id)
|
|
38
|
+
return record_result if record_result.failure?
|
|
39
|
+
|
|
40
|
+
persist_write(record_result.value, method_name: :destroy)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def persist_write(record, method_name:)
|
|
47
|
+
record.public_send(method_name)
|
|
48
|
+
|
|
49
|
+
return Result.success(value: record) if write_succeeded?(record, method_name: method_name)
|
|
50
|
+
|
|
51
|
+
Result.failure(error: validation_error_for_record(record))
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
Result.failure(error: map_exception_to_error(e))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def write_succeeded?(record, method_name:)
|
|
57
|
+
return record.destroyed? || record.errors.empty? if method_name == :destroy
|
|
58
|
+
|
|
59
|
+
record.errors.empty?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
class BaseService
|
|
5
|
+
# Maps common persistence exceptions to Railsmith error payloads.
|
|
6
|
+
# @api private
|
|
7
|
+
module CrudErrorMapping
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def validation_error_for_record(record)
|
|
11
|
+
details =
|
|
12
|
+
if record.respond_to?(:errors) && record.errors.respond_to?(:to_hash)
|
|
13
|
+
{ errors: record.errors.to_hash(true) }
|
|
14
|
+
else
|
|
15
|
+
{ errors: { base: ["Validation failed"] } }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Errors.validation_error(details:)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def map_exception_to_error(exception)
|
|
22
|
+
mapped = map_active_record_exception(exception)
|
|
23
|
+
return mapped unless mapped.nil?
|
|
24
|
+
|
|
25
|
+
Errors.unexpected(details: { exception_class: exception.class.name, message: exception.message })
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def map_active_record_exception(exception)
|
|
29
|
+
return nil unless defined?(ActiveRecord)
|
|
30
|
+
|
|
31
|
+
not_found_error(exception) ||
|
|
32
|
+
record_invalid_error(exception) ||
|
|
33
|
+
not_unique_error(exception) ||
|
|
34
|
+
stale_object_error(exception)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def not_found_error(exception)
|
|
38
|
+
return nil unless defined?(ActiveRecord::RecordNotFound)
|
|
39
|
+
return nil unless exception.is_a?(ActiveRecord::RecordNotFound)
|
|
40
|
+
|
|
41
|
+
Errors.not_found(message: "Record not found", details: { message: exception.message })
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def record_invalid_error(exception)
|
|
45
|
+
return nil unless defined?(ActiveRecord::RecordInvalid)
|
|
46
|
+
return nil unless exception.is_a?(ActiveRecord::RecordInvalid)
|
|
47
|
+
|
|
48
|
+
record = exception.record
|
|
49
|
+
return nil if record.nil?
|
|
50
|
+
|
|
51
|
+
validation_error_for_record(record)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def not_unique_error(exception)
|
|
55
|
+
return nil unless defined?(ActiveRecord::RecordNotUnique)
|
|
56
|
+
return nil unless exception.is_a?(ActiveRecord::RecordNotUnique)
|
|
57
|
+
|
|
58
|
+
Errors.conflict(message: "Conflict", details: { message: exception.message })
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def stale_object_error(exception)
|
|
62
|
+
return nil unless defined?(ActiveRecord::StaleObjectError)
|
|
63
|
+
return nil unless exception.is_a?(ActiveRecord::StaleObjectError)
|
|
64
|
+
|
|
65
|
+
Errors.conflict(message: "Conflict", details: { message: exception.message })
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def missing_model_class_result
|
|
69
|
+
Result.failure(
|
|
70
|
+
error: Errors.validation_error(
|
|
71
|
+
message: "Model class not configured",
|
|
72
|
+
details: { service: self.class.name }
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
class BaseService
|
|
5
|
+
# Model resolution helpers for CRUD defaults.
|
|
6
|
+
# @api private
|
|
7
|
+
module CrudModelResolution
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def model_class
|
|
11
|
+
explicit = self.class.model
|
|
12
|
+
return explicit unless explicit.nil?
|
|
13
|
+
|
|
14
|
+
infer_model_class
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def infer_model_class
|
|
18
|
+
return nil unless self.class.name
|
|
19
|
+
|
|
20
|
+
name = self.class.name.to_s
|
|
21
|
+
return nil unless name.end_with?("Service")
|
|
22
|
+
|
|
23
|
+
safe_constantize(name.delete_suffix("Service"))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def safe_constantize(constant_name)
|
|
27
|
+
return constant_name.constantize if constant_name.respond_to?(:constantize)
|
|
28
|
+
return nil unless defined?(ActiveSupport::Inflector)
|
|
29
|
+
|
|
30
|
+
ActiveSupport::Inflector.safe_constantize(constant_name.to_s)
|
|
31
|
+
rescue NameError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
class BaseService
|
|
5
|
+
# Record building and lookup helpers for CRUD defaults.
|
|
6
|
+
# @api private
|
|
7
|
+
module CrudRecordHelpers
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def attributes_params
|
|
11
|
+
return params.fetch(:attributes) if params.is_a?(Hash) && params[:attributes].is_a?(Hash)
|
|
12
|
+
return params if params.is_a?(Hash)
|
|
13
|
+
|
|
14
|
+
{}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def sanitize_attributes(attributes)
|
|
18
|
+
attributes
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def record_id
|
|
22
|
+
return params[:id] if params.is_a?(Hash) && params.key?(:id)
|
|
23
|
+
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def find_record(model_klass, id)
|
|
28
|
+
return missing_id_result if id.nil?
|
|
29
|
+
|
|
30
|
+
record = model_klass.find_by(id:)
|
|
31
|
+
return Result.success(value: record) unless record.nil?
|
|
32
|
+
|
|
33
|
+
not_found_result(model_klass, id)
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
Result.failure(error: map_exception_to_error(e))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def build_record(model_klass, attributes)
|
|
39
|
+
model_klass.new(attributes)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def assign_attributes(record, attributes)
|
|
43
|
+
record.assign_attributes(attributes)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def missing_id_result
|
|
47
|
+
Result.failure(error: Errors.validation_error(details: { missing: ["id"] }))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def not_found_result(model_klass, id)
|
|
51
|
+
Result.failure(
|
|
52
|
+
error: Errors.not_found(
|
|
53
|
+
message: "Record not found",
|
|
54
|
+
details: { model: model_klass.name, id: }
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
class BaseService
|
|
5
|
+
# Transaction helpers for write-path actions.
|
|
6
|
+
# @api private
|
|
7
|
+
module CrudTransactions
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def with_transaction(model_klass)
|
|
11
|
+
result = nil
|
|
12
|
+
transaction_wrapper(model_klass) do
|
|
13
|
+
result = yield
|
|
14
|
+
rollback_transaction if result.is_a?(Result) && result.failure?
|
|
15
|
+
end
|
|
16
|
+
result
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def transaction_wrapper(model_klass, &)
|
|
20
|
+
return model_klass.transaction(&) if model_klass.respond_to?(:transaction)
|
|
21
|
+
return ActiveRecord::Base.transaction(&) if defined?(ActiveRecord::Base)
|
|
22
|
+
|
|
23
|
+
yield
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def rollback_transaction
|
|
27
|
+
raise ActiveRecord::Rollback if defined?(ActiveRecord::Rollback)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
class BaseService
|
|
5
|
+
# Exposes the current domain from context and emits instrumentation events
|
|
6
|
+
# so domain tags flow into observability tooling on every service call.
|
|
7
|
+
# @api private
|
|
8
|
+
module DomainContextPropagation
|
|
9
|
+
# Returns the domain key from the service context, or nil when not set.
|
|
10
|
+
# A nil domain is permitted in flexible mode.
|
|
11
|
+
def current_domain
|
|
12
|
+
DomainContext.normalize_current_domain(context[:current_domain])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# Wraps action execution with a domain-tagged instrumentation event.
|
|
18
|
+
def execute_action(action:)
|
|
19
|
+
Railsmith::CrossDomainGuard.emit_if_violation(instance: self, action: action)
|
|
20
|
+
Instrumentation.instrument(
|
|
21
|
+
"service.call",
|
|
22
|
+
service: self.class.name,
|
|
23
|
+
action: action,
|
|
24
|
+
domain: current_domain
|
|
25
|
+
) { super }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
class BaseService
|
|
5
|
+
# Deep-duplication helpers for params/context immutability.
|
|
6
|
+
# @api private
|
|
7
|
+
module DupHelpers
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def deep_dup(value)
|
|
11
|
+
Railsmith.deep_dup(value)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
class BaseService
|
|
5
|
+
# Parameter validation helpers for service actions.
|
|
6
|
+
module Validation
|
|
7
|
+
# Explicit validation helper intended to be called from action methods.
|
|
8
|
+
#
|
|
9
|
+
# Supports either:
|
|
10
|
+
# - required_keys: simple presence checks on Hash-like params
|
|
11
|
+
# - contract: a dry-validation-like contract responding to `call(input)` and returning
|
|
12
|
+
# an object that responds to `success?` and `errors`
|
|
13
|
+
def validate(input = params, required_keys: [], contract: nil)
|
|
14
|
+
return validate_with_contract(contract, input) if contract
|
|
15
|
+
|
|
16
|
+
validate_required_keys(input, required_keys)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def validate_required_keys(input, required_keys)
|
|
22
|
+
hash = input.is_a?(Hash) ? input : {}
|
|
23
|
+
missing = required_keys.reject { |key| present_value?(hash[key]) }
|
|
24
|
+
|
|
25
|
+
return Result.success(value: hash) if missing.empty?
|
|
26
|
+
|
|
27
|
+
Result.failure(
|
|
28
|
+
error: Errors.validation_error(
|
|
29
|
+
message: "Validation failed",
|
|
30
|
+
details: { missing: missing.map(&:to_s) }
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate_with_contract(contract, input)
|
|
36
|
+
result = contract.call(input)
|
|
37
|
+
return Result.success(value: input) if contract_success?(result)
|
|
38
|
+
|
|
39
|
+
Result.failure(
|
|
40
|
+
error: Errors.validation_error(
|
|
41
|
+
message: "Validation failed",
|
|
42
|
+
details: { errors: contract_errors(result) }
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Result.failure(error: Errors.unexpected(message: e.message))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def contract_success?(result)
|
|
50
|
+
result.respond_to?(:success?) && result.success?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def contract_errors(result)
|
|
54
|
+
return result.errors if result.respond_to?(:errors)
|
|
55
|
+
|
|
56
|
+
{ base: ["Invalid contract result"] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def present_value?(value)
|
|
60
|
+
return false if value.nil?
|
|
61
|
+
return false if value.respond_to?(:empty?) && value.empty?
|
|
62
|
+
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
# Base service entrypoint with explicit (non-hook) lifecycle.
|
|
5
|
+
class BaseService
|
|
6
|
+
require_relative "base_service/dup_helpers"
|
|
7
|
+
require_relative "base_service/validation"
|
|
8
|
+
require_relative "base_service/crud_actions"
|
|
9
|
+
require_relative "base_service/bulk_params"
|
|
10
|
+
require_relative "base_service/bulk_execution"
|
|
11
|
+
require_relative "base_service/bulk_contract"
|
|
12
|
+
require_relative "base_service/bulk_actions"
|
|
13
|
+
require_relative "base_service/crud_model_resolution"
|
|
14
|
+
require_relative "base_service/crud_record_helpers"
|
|
15
|
+
require_relative "base_service/crud_error_mapping"
|
|
16
|
+
require_relative "base_service/crud_transactions"
|
|
17
|
+
require_relative "base_service/domain_context_propagation"
|
|
18
|
+
include DupHelpers
|
|
19
|
+
include Validation
|
|
20
|
+
include CrudActions
|
|
21
|
+
include BulkActions
|
|
22
|
+
prepend DomainContextPropagation
|
|
23
|
+
|
|
24
|
+
include CrudModelResolution
|
|
25
|
+
include CrudRecordHelpers
|
|
26
|
+
include CrudErrorMapping
|
|
27
|
+
include CrudTransactions
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def call(action:, params: {}, context: {})
|
|
31
|
+
new(params:, context:).call(action:)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def model(model_class = nil)
|
|
35
|
+
return @model_class if model_class.nil?
|
|
36
|
+
|
|
37
|
+
@model_class = model_class
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Bounded-context key for this service (optional). When set, mismatches
|
|
41
|
+
# against +context[:current_domain]+ emit warn-only instrumentation unless
|
|
42
|
+
# the pair is listed in +Railsmith.configuration.cross_domain_allowlist+.
|
|
43
|
+
def service_domain(domain_key = nil)
|
|
44
|
+
return @service_domain if domain_key.nil?
|
|
45
|
+
|
|
46
|
+
@service_domain = DomainContext.normalize_current_domain(domain_key)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
attr_reader :params, :context
|
|
51
|
+
|
|
52
|
+
def initialize(params:, context:)
|
|
53
|
+
@params = deep_dup(params || {})
|
|
54
|
+
@context = deep_dup(context || {})
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def call(action:)
|
|
58
|
+
normalized_action = normalize_action(action)
|
|
59
|
+
return invalid_action_result(action: normalized_action) unless valid_action?(normalized_action)
|
|
60
|
+
|
|
61
|
+
result = execute_action(action: normalized_action)
|
|
62
|
+
normalize_result(result)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def execute_action(action:)
|
|
68
|
+
public_send(action)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_result(value)
|
|
72
|
+
return value if value.is_a?(Result)
|
|
73
|
+
|
|
74
|
+
Result.success(value:)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def valid_action?(action)
|
|
78
|
+
action.is_a?(Symbol) && respond_to?(action, true)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalize_action(action)
|
|
82
|
+
return action.to_sym if action.respond_to?(:to_sym)
|
|
83
|
+
|
|
84
|
+
action
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def invalid_action_result(action:)
|
|
88
|
+
Result.failure(
|
|
89
|
+
error: Errors.validation_error(
|
|
90
|
+
message: "Invalid action",
|
|
91
|
+
details: { action: action }
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railsmith
|
|
4
|
+
# Stores global settings used by gem components.
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_accessor :warn_on_cross_domain_calls, :strict_mode,
|
|
7
|
+
:cross_domain_allowlist, :on_cross_domain_violation,
|
|
8
|
+
:fail_on_arch_violations
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@warn_on_cross_domain_calls = true
|
|
12
|
+
@strict_mode = false
|
|
13
|
+
@cross_domain_allowlist = []
|
|
14
|
+
@on_cross_domain_violation = nil
|
|
15
|
+
@fail_on_arch_violations = false
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|