facera 0.1.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/.DS_Store +0 -0
- data/.github/workflows/gem-push.yml +42 -0
- data/.github/workflows/ruby.yml +35 -0
- data/.gitignore +39 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +137 -0
- data/Gemfile +9 -0
- data/LICENSE +21 -0
- data/README.md +309 -0
- data/Rakefile +6 -0
- data/examples/01_core_dsl.rb +132 -0
- data/examples/02_facet_system.rb +216 -0
- data/examples/03_api_generation.rb +117 -0
- data/examples/04_auto_mounting.rb +182 -0
- data/examples/05_adapters.rb +196 -0
- data/examples/README.md +184 -0
- data/examples/server/README.md +376 -0
- data/examples/server/adapters/payment_adapter.rb +139 -0
- data/examples/server/application.rb +17 -0
- data/examples/server/config/facera.rb +33 -0
- data/examples/server/config.ru +10 -0
- data/examples/server/cores/payment_core.rb +82 -0
- data/examples/server/facets/external_facet.rb +38 -0
- data/examples/server/facets/internal_facet.rb +33 -0
- data/examples/server/facets/operator_facet.rb +48 -0
- data/facera.gemspec +30 -0
- data/img/facera.png +0 -0
- data/lib/facera/adapter.rb +83 -0
- data/lib/facera/attribute.rb +95 -0
- data/lib/facera/auto_mount.rb +124 -0
- data/lib/facera/capability.rb +117 -0
- data/lib/facera/capability_access.rb +59 -0
- data/lib/facera/configuration.rb +83 -0
- data/lib/facera/context.rb +29 -0
- data/lib/facera/core.rb +65 -0
- data/lib/facera/dsl.rb +41 -0
- data/lib/facera/entity.rb +50 -0
- data/lib/facera/error_formatter.rb +100 -0
- data/lib/facera/errors.rb +40 -0
- data/lib/facera/executor.rb +265 -0
- data/lib/facera/facet.rb +103 -0
- data/lib/facera/field_visibility.rb +69 -0
- data/lib/facera/generators/core_generator.rb +23 -0
- data/lib/facera/generators/facet_generator.rb +25 -0
- data/lib/facera/generators/install_generator.rb +64 -0
- data/lib/facera/generators/templates/core.rb.tt +49 -0
- data/lib/facera/generators/templates/facet.rb.tt +23 -0
- data/lib/facera/grape/api_generator.rb +59 -0
- data/lib/facera/grape/endpoint_generator.rb +316 -0
- data/lib/facera/grape/entity_generator.rb +89 -0
- data/lib/facera/grape.rb +14 -0
- data/lib/facera/introspection.rb +111 -0
- data/lib/facera/introspection_api.rb +66 -0
- data/lib/facera/invariant.rb +26 -0
- data/lib/facera/loader.rb +153 -0
- data/lib/facera/openapi_generator.rb +338 -0
- data/lib/facera/railtie.rb +51 -0
- data/lib/facera/registry.rb +34 -0
- data/lib/facera/tasks/routes.rake +66 -0
- data/lib/facera/version.rb +3 -0
- data/lib/facera.rb +35 -0
- metadata +137 -0
data/lib/facera/core.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Facera
|
|
2
|
+
class Core
|
|
3
|
+
attr_reader :name, :entities, :capabilities, :invariants
|
|
4
|
+
|
|
5
|
+
def initialize(name)
|
|
6
|
+
@name = name.to_sym
|
|
7
|
+
@entities = {}
|
|
8
|
+
@capabilities = {}
|
|
9
|
+
@invariants = {}
|
|
10
|
+
@current_entity = nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def entity(name, &block)
|
|
14
|
+
entity_obj = Entity.new(name)
|
|
15
|
+
@entities[name.to_sym] = entity_obj
|
|
16
|
+
@current_entity = entity_obj
|
|
17
|
+
|
|
18
|
+
entity_obj.instance_eval(&block) if block_given?
|
|
19
|
+
|
|
20
|
+
@current_entity = nil
|
|
21
|
+
entity_obj
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def capability(name, type:, &block)
|
|
25
|
+
capability_obj = Capability.new(name, type: type)
|
|
26
|
+
@capabilities[name.to_sym] = capability_obj
|
|
27
|
+
|
|
28
|
+
capability_obj.instance_eval(&block) if block_given?
|
|
29
|
+
|
|
30
|
+
capability_obj
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def invariant(name, description: nil, &block)
|
|
34
|
+
invariant_obj = Invariant.new(name, description: description, &block)
|
|
35
|
+
@invariants[name.to_sym] = invariant_obj
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def find_entity(name)
|
|
39
|
+
@entities[name.to_sym] or raise Error, "Entity '#{name}' not found in core '#{@name}'"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def find_capability(name)
|
|
43
|
+
@capabilities[name.to_sym] or raise Error, "Capability '#{name}' not found in core '#{@name}'"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def find_invariant(name)
|
|
47
|
+
@invariants[name.to_sym]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_invariants(context)
|
|
51
|
+
errors = []
|
|
52
|
+
|
|
53
|
+
@invariants.each do |name, invariant|
|
|
54
|
+
begin
|
|
55
|
+
result = invariant.check(context)
|
|
56
|
+
errors << "Invariant '#{name}' failed" unless result
|
|
57
|
+
rescue Error => e
|
|
58
|
+
errors << e.message
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
errors
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/facera/dsl.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Facera
|
|
2
|
+
module DSL
|
|
3
|
+
def self.included(base)
|
|
4
|
+
base.extend(ClassMethods)
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
module ClassMethods
|
|
8
|
+
def define_core(name, &block)
|
|
9
|
+
core = Core.new(name)
|
|
10
|
+
core.instance_eval(&block) if block_given?
|
|
11
|
+
Registry.register_core(name, core)
|
|
12
|
+
core
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def define_facet(name, core:, &block)
|
|
16
|
+
facet = Facet.new(name, core: core)
|
|
17
|
+
facet.instance_eval(&block) if block_given?
|
|
18
|
+
Registry.register_facet(name, facet)
|
|
19
|
+
facet
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def cores
|
|
23
|
+
Registry.cores
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def facets
|
|
27
|
+
Registry.facets
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def find_core(name)
|
|
31
|
+
Registry.find_core(name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def find_facet(name)
|
|
35
|
+
Registry.find_facet(name)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
extend DSL::ClassMethods
|
|
41
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Facera
|
|
2
|
+
class Entity
|
|
3
|
+
attr_reader :name, :attributes
|
|
4
|
+
|
|
5
|
+
def initialize(name)
|
|
6
|
+
@name = name.to_sym
|
|
7
|
+
@attributes = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def attribute(name, type, **options)
|
|
11
|
+
attr = Attribute.new(name, type, **options)
|
|
12
|
+
@attributes[attr.name] = attr
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def find_attribute(name)
|
|
16
|
+
@attributes[name.to_sym]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def required_attributes
|
|
20
|
+
@attributes.values.select(&:required?)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def immutable_attributes
|
|
24
|
+
@attributes.values.select(&:immutable?)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def validate_data(data)
|
|
28
|
+
errors = []
|
|
29
|
+
|
|
30
|
+
# Check required attributes
|
|
31
|
+
required_attributes.each do |attr|
|
|
32
|
+
if data[attr.name].nil? && data[attr.name.to_s].nil?
|
|
33
|
+
errors << "#{attr.name} is required"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Validate each provided attribute
|
|
38
|
+
data.each do |key, value|
|
|
39
|
+
attr = find_attribute(key)
|
|
40
|
+
next unless attr
|
|
41
|
+
|
|
42
|
+
unless attr.validate_value(value)
|
|
43
|
+
errors << "#{key} has invalid value for type #{attr.type}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
errors
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module Facera
|
|
2
|
+
class ErrorFormatter
|
|
3
|
+
VERBOSITY_LEVELS = [:minimal, :detailed, :structured].freeze
|
|
4
|
+
|
|
5
|
+
def initialize(verbosity = :minimal)
|
|
6
|
+
@verbosity = verbosity.to_sym
|
|
7
|
+
validate_verbosity!
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def format(error)
|
|
11
|
+
case @verbosity
|
|
12
|
+
when :minimal
|
|
13
|
+
format_minimal(error)
|
|
14
|
+
when :detailed
|
|
15
|
+
format_detailed(error)
|
|
16
|
+
when :structured
|
|
17
|
+
format_structured(error)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def validate_verbosity!
|
|
24
|
+
unless VERBOSITY_LEVELS.include?(@verbosity)
|
|
25
|
+
raise Error, "Invalid verbosity level '#{@verbosity}'. Valid levels: #{VERBOSITY_LEVELS.join(', ')}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def format_minimal(error)
|
|
30
|
+
{
|
|
31
|
+
error: error.class.name.split('::').last.gsub('Error', '').downcase,
|
|
32
|
+
message: error.message
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def format_detailed(error)
|
|
37
|
+
result = {
|
|
38
|
+
error: error.class.name,
|
|
39
|
+
message: error.message,
|
|
40
|
+
timestamp: Time.now.iso8601
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
case error
|
|
44
|
+
when ValidationError
|
|
45
|
+
result[:validation_errors] = error.errors
|
|
46
|
+
when InvariantError
|
|
47
|
+
result[:invariant_errors] = error.invariant_errors
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
result[:backtrace] = error.backtrace.first(10) if error.backtrace
|
|
51
|
+
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def format_structured(error)
|
|
56
|
+
result = {
|
|
57
|
+
type: error.class.name.split('::').last,
|
|
58
|
+
message: error.message,
|
|
59
|
+
timestamp: Time.now.iso8601,
|
|
60
|
+
severity: severity_for(error)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case error
|
|
64
|
+
when ValidationError
|
|
65
|
+
result[:details] = {
|
|
66
|
+
validation_errors: error.errors.map { |e| { field: extract_field(e), message: e } }
|
|
67
|
+
}
|
|
68
|
+
when InvariantError
|
|
69
|
+
result[:details] = {
|
|
70
|
+
invariant_violations: error.invariant_errors
|
|
71
|
+
}
|
|
72
|
+
when NotFoundError
|
|
73
|
+
result[:details] = { resource: extract_resource(error.message) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
result
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def severity_for(error)
|
|
80
|
+
case error
|
|
81
|
+
when ValidationError, PreconditionError
|
|
82
|
+
'warning'
|
|
83
|
+
when UnauthorizedError
|
|
84
|
+
'error'
|
|
85
|
+
when NotFoundError
|
|
86
|
+
'info'
|
|
87
|
+
else
|
|
88
|
+
'error'
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def extract_field(error_message)
|
|
93
|
+
error_message.split(' ').first
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def extract_resource(message)
|
|
97
|
+
message.split(' ').first
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Facera
|
|
2
|
+
class FaceraError < StandardError; end
|
|
3
|
+
|
|
4
|
+
class ValidationError < FaceraError
|
|
5
|
+
attr_reader :errors
|
|
6
|
+
|
|
7
|
+
def initialize(errors)
|
|
8
|
+
@errors = errors.is_a?(Array) ? errors : [errors]
|
|
9
|
+
super(@errors.join(", "))
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class UnauthorizedError < FaceraError
|
|
14
|
+
def initialize(message = "Unauthorized access")
|
|
15
|
+
super(message)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class NotFoundError < FaceraError
|
|
20
|
+
def initialize(resource, id = nil)
|
|
21
|
+
message = id ? "#{resource} with id '#{id}' not found" : "#{resource} not found"
|
|
22
|
+
super(message)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class PreconditionError < FaceraError
|
|
27
|
+
def initialize(message = "Precondition failed")
|
|
28
|
+
super(message)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class InvariantError < FaceraError
|
|
33
|
+
attr_reader :invariant_errors
|
|
34
|
+
|
|
35
|
+
def initialize(invariant_errors)
|
|
36
|
+
@invariant_errors = invariant_errors
|
|
37
|
+
super("Invariant violations: #{invariant_errors.join(', ')}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
module Facera
|
|
2
|
+
class Executor
|
|
3
|
+
attr_reader :facet, :capability, :params, :context
|
|
4
|
+
|
|
5
|
+
def self.run(facet:, capability:, params: {}, context: {})
|
|
6
|
+
new(facet: facet, capability: capability, params: params, context: context).execute
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(facet:, capability:, params: {}, context: {})
|
|
10
|
+
@facet = facet.is_a?(Symbol) ? Registry.find_facet(facet) : facet
|
|
11
|
+
@capability = capability.is_a?(Symbol) ? @facet.core.find_capability(capability) : capability
|
|
12
|
+
@params = normalize_params(params)
|
|
13
|
+
@context = context
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def execute
|
|
17
|
+
# Check if capability is allowed in this facet
|
|
18
|
+
unless facet.capability_allowed?(capability.name)
|
|
19
|
+
raise UnauthorizedError, "Capability '#{capability.name}' is not allowed in facet '#{facet.name}'"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Validate parameters
|
|
23
|
+
validate_params!
|
|
24
|
+
|
|
25
|
+
# Apply facet scoping if present
|
|
26
|
+
apply_scoping!
|
|
27
|
+
|
|
28
|
+
# Check preconditions
|
|
29
|
+
check_preconditions!
|
|
30
|
+
|
|
31
|
+
# Execute the capability logic
|
|
32
|
+
result = execute_capability
|
|
33
|
+
|
|
34
|
+
# Validate invariants if result is an entity (not a collection or metadata structure)
|
|
35
|
+
validate_invariants!(result) if should_validate_result?(result)
|
|
36
|
+
|
|
37
|
+
result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def normalize_params(params)
|
|
43
|
+
# Convert string keys to symbols
|
|
44
|
+
params.transform_keys(&:to_sym)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_params!
|
|
48
|
+
errors = capability.validate_params(params)
|
|
49
|
+
|
|
50
|
+
raise ValidationError.new(errors) if errors.any?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def apply_scoping!
|
|
54
|
+
return unless facet.has_scope_for?(capability.name)
|
|
55
|
+
|
|
56
|
+
scope_block = facet.capability_scope(capability.name)
|
|
57
|
+
scope_params = context_eval(&scope_block)
|
|
58
|
+
|
|
59
|
+
# Merge scope params into params
|
|
60
|
+
@params.merge!(scope_params) if scope_params.is_a?(Hash)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def check_preconditions!
|
|
64
|
+
return if capability.preconditions.empty?
|
|
65
|
+
|
|
66
|
+
unless capability.check_preconditions(build_context)
|
|
67
|
+
raise PreconditionError, "Precondition failed for capability '#{capability.name}'"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def execute_capability
|
|
72
|
+
# Priority 1: Execute block defined inline
|
|
73
|
+
if capability.has_execute_block?
|
|
74
|
+
return execute_inline_block
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Priority 2: Call adapter if registered
|
|
78
|
+
adapter = AdapterRegistry.get(facet.core.name)
|
|
79
|
+
if adapter
|
|
80
|
+
return execute_adapter(adapter)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Fallback: Mock implementation
|
|
84
|
+
execute_mock_implementation
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def execute_inline_block
|
|
88
|
+
# Execute the block defined in the capability
|
|
89
|
+
context = build_context
|
|
90
|
+
result = context.instance_exec(params, &capability.execute_block)
|
|
91
|
+
|
|
92
|
+
# Apply field setters if result is a hash
|
|
93
|
+
if result.is_a?(Hash) && capability.field_setters.any?
|
|
94
|
+
capability.field_setters.each do |field, value|
|
|
95
|
+
result[field] = value.is_a?(Proc) ? value.call : value
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
result
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def execute_adapter(adapter_class)
|
|
103
|
+
adapter_instance = adapter_class.new
|
|
104
|
+
|
|
105
|
+
# Call the appropriate adapter method
|
|
106
|
+
case capability.type
|
|
107
|
+
when :create
|
|
108
|
+
adapter_instance.send("create_#{capability.entity_name}", params)
|
|
109
|
+
when :get
|
|
110
|
+
adapter_instance.send("get_#{capability.entity_name}", params)
|
|
111
|
+
when :list
|
|
112
|
+
adapter_instance.send("list_#{capability.entity_name}s", params)
|
|
113
|
+
when :action
|
|
114
|
+
# For actions, call the method by capability name
|
|
115
|
+
adapter_instance.send(capability.name, params)
|
|
116
|
+
when :update
|
|
117
|
+
adapter_instance.send("update_#{capability.entity_name}", params)
|
|
118
|
+
when :delete
|
|
119
|
+
adapter_instance.send("delete_#{capability.entity_name}", params)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def execute_mock_implementation
|
|
124
|
+
# Fallback mock implementation (for testing/prototyping)
|
|
125
|
+
case capability.type
|
|
126
|
+
when :create
|
|
127
|
+
create_result
|
|
128
|
+
when :get
|
|
129
|
+
get_result
|
|
130
|
+
when :list
|
|
131
|
+
list_result
|
|
132
|
+
when :action
|
|
133
|
+
action_result
|
|
134
|
+
when :update
|
|
135
|
+
update_result
|
|
136
|
+
when :delete
|
|
137
|
+
delete_result
|
|
138
|
+
else
|
|
139
|
+
{}
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def create_result
|
|
144
|
+
# Mock created entity
|
|
145
|
+
entity_attrs = params.dup
|
|
146
|
+
entity_attrs[:id] = generate_id
|
|
147
|
+
entity_attrs[:created_at] = Time.now
|
|
148
|
+
entity_attrs
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def get_result
|
|
152
|
+
# Mock fetched entity
|
|
153
|
+
base_data = mock_entity_data
|
|
154
|
+
base_data[:id] = params[:id] # Use the requested ID
|
|
155
|
+
base_data
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def list_result
|
|
159
|
+
# Mock collection
|
|
160
|
+
{
|
|
161
|
+
data: [mock_entity_data],
|
|
162
|
+
meta: {
|
|
163
|
+
total: 1,
|
|
164
|
+
limit: params[:limit] || 20,
|
|
165
|
+
offset: params[:offset] || 0
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def action_result
|
|
171
|
+
# Mock entity after action
|
|
172
|
+
result = get_result
|
|
173
|
+
|
|
174
|
+
# Apply transitions
|
|
175
|
+
if capability.transitions.any?
|
|
176
|
+
result[:status] = capability.transitions.first
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Apply field setters
|
|
180
|
+
capability.field_setters.each do |field, value|
|
|
181
|
+
result[field] = value.is_a?(Proc) ? value.call : value
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
result
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def update_result
|
|
188
|
+
get_result.merge(params.except(:id))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def delete_result
|
|
192
|
+
{ success: true, id: params[:id] }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def should_validate_result?(result)
|
|
196
|
+
return false unless result.is_a?(Hash)
|
|
197
|
+
return false if capability.type == :list # List returns collection structure
|
|
198
|
+
return false if capability.type == :delete # Delete returns success message
|
|
199
|
+
return false unless capability.entity_name
|
|
200
|
+
|
|
201
|
+
true
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def validate_invariants!(result)
|
|
205
|
+
return unless capability.entity_name
|
|
206
|
+
|
|
207
|
+
entity = facet.core.find_entity(capability.entity_name)
|
|
208
|
+
errors = entity.validate_data(result)
|
|
209
|
+
|
|
210
|
+
# Also check core invariants
|
|
211
|
+
invariant_errors = facet.core.validate_invariants(build_context(result))
|
|
212
|
+
errors.concat(invariant_errors)
|
|
213
|
+
|
|
214
|
+
raise InvariantError.new(invariant_errors) if invariant_errors.any?
|
|
215
|
+
raise ValidationError.new(errors) if errors.any?
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def build_context(data = {})
|
|
219
|
+
# Create a context object with data that can be used in blocks
|
|
220
|
+
Context.new(data.merge(params).merge(context))
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def context_eval(&block)
|
|
224
|
+
build_context.instance_eval(&block)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def generate_id
|
|
228
|
+
require 'securerandom'
|
|
229
|
+
SecureRandom.uuid
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def mock_entity_data
|
|
233
|
+
# Generate mock data based on entity definition
|
|
234
|
+
return {} unless capability.entity_name
|
|
235
|
+
|
|
236
|
+
entity = facet.core.find_entity(capability.entity_name)
|
|
237
|
+
data = {
|
|
238
|
+
id: generate_id,
|
|
239
|
+
created_at: Time.now
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
entity.attributes.each do |name, attr|
|
|
243
|
+
next if [:id, :created_at, :updated_at].include?(name)
|
|
244
|
+
data[name] = mock_value_for_type(attr.type)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
data
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def mock_value_for_type(type)
|
|
251
|
+
case type
|
|
252
|
+
when :string then "sample"
|
|
253
|
+
when :integer then 42
|
|
254
|
+
when :money then 100.0
|
|
255
|
+
when :uuid then generate_id
|
|
256
|
+
when :enum then :pending
|
|
257
|
+
when :boolean then true
|
|
258
|
+
when :hash then {}
|
|
259
|
+
when :array then []
|
|
260
|
+
when :timestamp, :datetime then Time.now
|
|
261
|
+
else nil
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
data/lib/facera/facet.rb
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
module Facera
|
|
2
|
+
class Facet
|
|
3
|
+
attr_reader :name, :core_name, :description, :field_visibilities, :capability_access,
|
|
4
|
+
:error_verbosity, :format, :rate_limit, :audit_enabled
|
|
5
|
+
|
|
6
|
+
def initialize(name, core:)
|
|
7
|
+
@name = name.to_sym
|
|
8
|
+
@core_name = core.to_sym
|
|
9
|
+
@description = nil
|
|
10
|
+
@field_visibilities = {}
|
|
11
|
+
@capability_access = CapabilityAccess.new
|
|
12
|
+
@error_verbosity = :minimal
|
|
13
|
+
@format = :json
|
|
14
|
+
@rate_limit = nil
|
|
15
|
+
@audit_enabled = false
|
|
16
|
+
@audit_options = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def core
|
|
20
|
+
@core ||= Registry.find_core(@core_name)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def description(text = nil)
|
|
24
|
+
return @description if text.nil?
|
|
25
|
+
@description = text
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def expose(entity_name, &block)
|
|
29
|
+
visibility = FieldVisibility.new(entity_name)
|
|
30
|
+
@field_visibilities[entity_name.to_sym] = visibility
|
|
31
|
+
visibility.instance_eval(&block) if block_given?
|
|
32
|
+
visibility
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def allow_capabilities(*capability_names)
|
|
36
|
+
@capability_access.allow(*capability_names)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def deny_capabilities(*capability_names)
|
|
40
|
+
@capability_access.deny(*capability_names)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def scope(capability_name, &block)
|
|
44
|
+
@capability_access.scope(capability_name, &block)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def error_verbosity(level = nil)
|
|
48
|
+
return @error_verbosity if level.nil?
|
|
49
|
+
@error_verbosity = level.to_sym
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def format(format_type = nil)
|
|
53
|
+
return @format if format_type.nil?
|
|
54
|
+
@format = format_type.to_sym
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def rate_limit(requests: nil, per: nil)
|
|
58
|
+
return @rate_limit if requests.nil?
|
|
59
|
+
@rate_limit = { requests: requests, per: per.to_sym }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def audit_all_operations(**options)
|
|
63
|
+
@audit_enabled = true
|
|
64
|
+
@audit_options = options
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def field_visibility_for(entity_name)
|
|
68
|
+
@field_visibilities[entity_name.to_sym]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def visible_fields_for(entity_name)
|
|
72
|
+
visibility = field_visibility_for(entity_name)
|
|
73
|
+
return [] unless visibility
|
|
74
|
+
|
|
75
|
+
entity = core.find_entity(entity_name)
|
|
76
|
+
visibility.all_visible_fields(entity)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def capability_allowed?(capability_name)
|
|
80
|
+
@capability_access.allowed?(capability_name)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def capability_scope(capability_name)
|
|
84
|
+
@capability_access.scope_for(capability_name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def has_scope_for?(capability_name)
|
|
88
|
+
@capability_access.has_scope?(capability_name)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def allowed_capabilities
|
|
92
|
+
@capability_access.allowed_capability_names(core)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def error_formatter
|
|
96
|
+
@error_formatter ||= ErrorFormatter.new(@error_verbosity)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def format_error(error)
|
|
100
|
+
error_formatter.format(error)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|