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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require 'grape-entity'
|
|
2
|
+
|
|
3
|
+
module Facera
|
|
4
|
+
module Grape
|
|
5
|
+
class EntityGenerator
|
|
6
|
+
def self.for(entity, facet)
|
|
7
|
+
entity_name = entity.name
|
|
8
|
+
visibility = facet.field_visibility_for(entity_name)
|
|
9
|
+
|
|
10
|
+
# If no visibility rules, expose all fields
|
|
11
|
+
unless visibility
|
|
12
|
+
return create_default_entity(entity)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
create_facet_entity(entity, visibility, facet)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.for_collection(entity, facet)
|
|
19
|
+
entity_class = self.for(entity, facet)
|
|
20
|
+
|
|
21
|
+
Class.new(::Grape::Entity) do
|
|
22
|
+
expose :data, using: entity_class, documentation: { is_array: true }
|
|
23
|
+
expose :meta do |obj, options|
|
|
24
|
+
{
|
|
25
|
+
total: obj[:total] || obj.dig(:meta, :total) || 0,
|
|
26
|
+
limit: obj[:limit] || obj.dig(:meta, :limit) || 20,
|
|
27
|
+
offset: obj[:offset] || obj.dig(:meta, :offset) || 0
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def self.create_default_entity(entity)
|
|
36
|
+
attrs = entity.attributes
|
|
37
|
+
|
|
38
|
+
Class.new(::Grape::Entity) do
|
|
39
|
+
attrs.each do |name, _attr|
|
|
40
|
+
expose name
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.create_facet_entity(entity, visibility, facet)
|
|
46
|
+
visible_fields = visibility.visible_field_names(entity)
|
|
47
|
+
aliases = visibility.field_aliases
|
|
48
|
+
computed = visibility.computed_fields
|
|
49
|
+
format_type = facet.format
|
|
50
|
+
|
|
51
|
+
Class.new(::Grape::Entity) do
|
|
52
|
+
# Expose visible base fields
|
|
53
|
+
visible_fields.each do |field_name|
|
|
54
|
+
aliased_name = aliases[field_name]
|
|
55
|
+
|
|
56
|
+
if aliased_name
|
|
57
|
+
# Field with alias
|
|
58
|
+
expose field_name, as: aliased_name
|
|
59
|
+
else
|
|
60
|
+
# Regular field
|
|
61
|
+
expose field_name
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Expose computed fields
|
|
66
|
+
computed.each do |field_name, block|
|
|
67
|
+
expose field_name do |obj, options|
|
|
68
|
+
context = build_computation_context(obj, options)
|
|
69
|
+
context.instance_eval(&block)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Apply format-specific transformations
|
|
74
|
+
if format_type == :structured
|
|
75
|
+
format_with(:iso_timestamp) { |dt| dt&.iso8601 }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def self.build_computation_context(obj, options)
|
|
81
|
+
# Convert to Context to allow easy field access in blocks
|
|
82
|
+
data = obj.is_a?(Hash) ? obj : {}
|
|
83
|
+
Facera::Context.new(data.merge(options[:context] || {}))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/facera/grape.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require_relative 'grape/entity_generator'
|
|
2
|
+
require_relative 'grape/endpoint_generator'
|
|
3
|
+
require_relative 'grape/api_generator'
|
|
4
|
+
|
|
5
|
+
module Facera
|
|
6
|
+
module Grape
|
|
7
|
+
class << self
|
|
8
|
+
# Generate a Grape API for a specific facet
|
|
9
|
+
def api_for(facet_name)
|
|
10
|
+
APIGenerator.for_facet(facet_name)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module Facera
|
|
2
|
+
class Introspection
|
|
3
|
+
class << self
|
|
4
|
+
def inspect_all
|
|
5
|
+
{
|
|
6
|
+
version: Facera::VERSION,
|
|
7
|
+
cores: inspect_cores,
|
|
8
|
+
facets: inspect_facets,
|
|
9
|
+
mounted: inspect_mounted
|
|
10
|
+
}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def inspect_cores
|
|
14
|
+
Registry.cores.map do |name, core|
|
|
15
|
+
{
|
|
16
|
+
name: name,
|
|
17
|
+
entities: core.entities.map { |entity_name, entity|
|
|
18
|
+
{
|
|
19
|
+
name: entity_name,
|
|
20
|
+
attributes: entity.attributes.map { |attr_name, attr|
|
|
21
|
+
{
|
|
22
|
+
name: attr_name,
|
|
23
|
+
type: attr.type,
|
|
24
|
+
required: attr.required?,
|
|
25
|
+
immutable: attr.immutable?,
|
|
26
|
+
enum_values: attr.enum_values
|
|
27
|
+
}.compact
|
|
28
|
+
},
|
|
29
|
+
required_attributes: entity.attributes.select { |_, a| a.required? }.keys,
|
|
30
|
+
immutable_attributes: entity.attributes.select { |_, a| a.immutable? }.keys
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
capabilities: core.capabilities.map { |cap_name, cap|
|
|
34
|
+
{
|
|
35
|
+
name: cap_name,
|
|
36
|
+
type: cap.type,
|
|
37
|
+
entity: cap.entity_name,
|
|
38
|
+
required_params: cap.required_params,
|
|
39
|
+
optional_params: cap.optional_params,
|
|
40
|
+
preconditions: cap.preconditions.count,
|
|
41
|
+
validations: cap.validations.count,
|
|
42
|
+
transitions_to: cap.transitions,
|
|
43
|
+
sets_fields: cap.field_setters
|
|
44
|
+
}.compact
|
|
45
|
+
},
|
|
46
|
+
invariants: core.invariants.map { |inv|
|
|
47
|
+
{
|
|
48
|
+
name: inv.name,
|
|
49
|
+
description: inv.description
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def inspect_facets
|
|
57
|
+
Registry.facets.map do |name, facet|
|
|
58
|
+
{
|
|
59
|
+
name: name,
|
|
60
|
+
core: facet.core_name,
|
|
61
|
+
description: facet.description,
|
|
62
|
+
exposures: facet.field_visibilities.map { |entity_name, visibility|
|
|
63
|
+
{
|
|
64
|
+
entity: entity_name,
|
|
65
|
+
visible_fields: visibility.visible_fields,
|
|
66
|
+
hidden_fields: visibility.hidden_fields,
|
|
67
|
+
computed_fields: visibility.computed_fields.keys,
|
|
68
|
+
field_aliases: visibility.field_aliases
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
capabilities: {
|
|
72
|
+
allowed: facet.capability_access.allowed_capabilities,
|
|
73
|
+
denied: facet.capability_access.denied_capabilities,
|
|
74
|
+
total: facet.capability_access.allowed_capabilities == :all ? 'all' : facet.capability_access.allowed_capabilities.count
|
|
75
|
+
},
|
|
76
|
+
scopes: facet.capability_access.scopes.keys,
|
|
77
|
+
error_verbosity: facet.error_verbosity,
|
|
78
|
+
format: facet.format,
|
|
79
|
+
rate_limit: facet.rate_limit,
|
|
80
|
+
audit_logging: facet.audit_enabled
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def inspect_mounted
|
|
86
|
+
return {} unless defined?(Facera::AutoMount)
|
|
87
|
+
|
|
88
|
+
# This will be populated after auto_mount! is called
|
|
89
|
+
{
|
|
90
|
+
base_path: Facera.configuration.base_path,
|
|
91
|
+
version: Facera.configuration.version,
|
|
92
|
+
facets: Facera.configuration.instance_variable_get(:@facet_paths) || {}
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def inspect_core(core_name)
|
|
97
|
+
core = Registry.cores[core_name]
|
|
98
|
+
return nil unless core
|
|
99
|
+
|
|
100
|
+
inspect_cores.find { |c| c[:name] == core_name }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def inspect_facet(facet_name)
|
|
104
|
+
facet = Registry.facets[facet_name]
|
|
105
|
+
return nil unless facet
|
|
106
|
+
|
|
107
|
+
inspect_facets.find { |f| f[:name] == facet_name }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require 'grape'
|
|
2
|
+
require_relative 'introspection'
|
|
3
|
+
require_relative 'openapi_generator'
|
|
4
|
+
|
|
5
|
+
module Facera
|
|
6
|
+
class IntrospectionAPI < ::Grape::API
|
|
7
|
+
format :json
|
|
8
|
+
|
|
9
|
+
desc 'Get complete introspection data'
|
|
10
|
+
get :introspect do
|
|
11
|
+
Facera::Introspection.inspect_all
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
desc 'Get all cores'
|
|
15
|
+
get :cores do
|
|
16
|
+
Facera::Introspection.inspect_cores
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
desc 'Get specific core'
|
|
20
|
+
params do
|
|
21
|
+
requires :name, type: Symbol, desc: 'Core name'
|
|
22
|
+
end
|
|
23
|
+
get 'cores/:name' do
|
|
24
|
+
core = Facera::Introspection.inspect_core(params[:name].to_sym)
|
|
25
|
+
error!('Core not found', 404) unless core
|
|
26
|
+
core
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
desc 'Get all facets'
|
|
30
|
+
get :facets do
|
|
31
|
+
Facera::Introspection.inspect_facets
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
desc 'Get specific facet'
|
|
35
|
+
params do
|
|
36
|
+
requires :name, type: Symbol, desc: 'Facet name'
|
|
37
|
+
end
|
|
38
|
+
get 'facets/:name' do
|
|
39
|
+
facet = Facera::Introspection.inspect_facet(params[:name].to_sym)
|
|
40
|
+
error!('Facet not found', 404) unless facet
|
|
41
|
+
facet
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
desc 'Get mounted configuration'
|
|
45
|
+
get :mounted do
|
|
46
|
+
Facera::Introspection.inspect_mounted
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
desc 'Get OpenAPI spec for all facets'
|
|
50
|
+
get :openapi do
|
|
51
|
+
Facera::OpenAPIGenerator.generate_all
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
desc 'Get OpenAPI spec for specific facet'
|
|
55
|
+
params do
|
|
56
|
+
requires :name, type: Symbol, desc: 'Facet name'
|
|
57
|
+
end
|
|
58
|
+
get 'openapi/:name' do
|
|
59
|
+
begin
|
|
60
|
+
Facera::OpenAPIGenerator.for_facet(params[:name].to_sym)
|
|
61
|
+
rescue => e
|
|
62
|
+
error!(e.message, 404)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Facera
|
|
2
|
+
class Invariant
|
|
3
|
+
attr_reader :name, :description, :block
|
|
4
|
+
|
|
5
|
+
def initialize(name, description: nil, &block)
|
|
6
|
+
@name = name.to_sym
|
|
7
|
+
@description = description
|
|
8
|
+
@block = block
|
|
9
|
+
|
|
10
|
+
raise Error, "Invariant '#{name}' must have a block" unless @block
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def validate(context)
|
|
14
|
+
context.instance_eval(&block)
|
|
15
|
+
rescue StandardError => e
|
|
16
|
+
raise Error, "Invariant '#{name}' validation failed: #{e.message}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def check(context)
|
|
20
|
+
result = validate(context)
|
|
21
|
+
return true if result
|
|
22
|
+
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
module Facera
|
|
2
|
+
class Loader
|
|
3
|
+
attr_reader :load_paths, :logger
|
|
4
|
+
|
|
5
|
+
def initialize(load_paths: nil, logger: nil)
|
|
6
|
+
@load_paths = load_paths || detect_load_paths
|
|
7
|
+
@logger = logger || Logger.new($stdout)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def load_all!
|
|
11
|
+
load_cores!
|
|
12
|
+
load_adapters!
|
|
13
|
+
load_facets!
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def load_cores!
|
|
17
|
+
core_files = discover_files('cores')
|
|
18
|
+
|
|
19
|
+
if core_files.any?
|
|
20
|
+
@logger.info "📦 Loading cores..."
|
|
21
|
+
core_files.each do |file|
|
|
22
|
+
require file
|
|
23
|
+
@logger.info " ✓ #{File.basename(file, '.rb')}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
core_files.count
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def load_adapters!
|
|
31
|
+
adapter_files = discover_files('adapters')
|
|
32
|
+
|
|
33
|
+
if adapter_files.any?
|
|
34
|
+
@logger.info "🔌 Loading adapters..."
|
|
35
|
+
adapter_files.each do |file|
|
|
36
|
+
require file
|
|
37
|
+
|
|
38
|
+
# Auto-register adapter if it matches a core
|
|
39
|
+
adapter_name = File.basename(file, '.rb')
|
|
40
|
+
core_name = adapter_name.gsub(/_adapter$/, '').to_sym
|
|
41
|
+
|
|
42
|
+
# Try to find the adapter class
|
|
43
|
+
adapter_class_name = adapter_name.split('_').map(&:capitalize).join
|
|
44
|
+
|
|
45
|
+
if Object.const_defined?(adapter_class_name)
|
|
46
|
+
adapter_class = Object.const_get(adapter_class_name)
|
|
47
|
+
|
|
48
|
+
# Check if matching core exists
|
|
49
|
+
if Registry.cores[core_name]
|
|
50
|
+
AdapterRegistry.register(core_name, adapter_class)
|
|
51
|
+
@logger.info " ✓ #{File.basename(file, '.rb')} → linked to :#{core_name} core"
|
|
52
|
+
else
|
|
53
|
+
@logger.warn " ⚠#{File.basename(file, '.rb')} → no matching :#{core_name} core found"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
adapter_files.count
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def load_facets!
|
|
63
|
+
facet_files = discover_files('facets')
|
|
64
|
+
|
|
65
|
+
if facet_files.any?
|
|
66
|
+
@logger.info "🎠Loading facets..."
|
|
67
|
+
facet_files.each do |file|
|
|
68
|
+
require file
|
|
69
|
+
@logger.info " ✓ #{File.basename(file, '.rb')}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
facet_files.count
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def detect_load_paths
|
|
79
|
+
paths = []
|
|
80
|
+
|
|
81
|
+
if defined?(Rails)
|
|
82
|
+
# Rails application
|
|
83
|
+
paths << Rails.root.to_s
|
|
84
|
+
else
|
|
85
|
+
# Non-Rails: look for conventional directories
|
|
86
|
+
base_paths = [Dir.pwd, File.expand_path('..', Dir.pwd)]
|
|
87
|
+
|
|
88
|
+
base_paths.each do |base|
|
|
89
|
+
# Check if cores/ or facets/ exist at this level
|
|
90
|
+
if File.directory?(File.join(base, 'cores')) ||
|
|
91
|
+
File.directory?(File.join(base, 'facets'))
|
|
92
|
+
paths << base
|
|
93
|
+
break
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Check in app/ subdirectory
|
|
97
|
+
app_path = File.join(base, 'app')
|
|
98
|
+
if File.directory?(File.join(app_path, 'cores')) ||
|
|
99
|
+
File.directory?(File.join(app_path, 'facets'))
|
|
100
|
+
paths << app_path
|
|
101
|
+
break
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
paths
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def discover_files(type)
|
|
110
|
+
files = []
|
|
111
|
+
|
|
112
|
+
@load_paths.each do |base_path|
|
|
113
|
+
# Try app/cores or app/facets (Rails-style)
|
|
114
|
+
pattern = File.join(base_path, 'app', type, '**/*.rb')
|
|
115
|
+
files.concat(Dir.glob(pattern))
|
|
116
|
+
|
|
117
|
+
# Try cores/ or facets/ directly (non-Rails)
|
|
118
|
+
pattern = File.join(base_path, type, '**/*.rb')
|
|
119
|
+
files.concat(Dir.glob(pattern))
|
|
120
|
+
|
|
121
|
+
# Try lib/cores or lib/facets
|
|
122
|
+
pattern = File.join(base_path, 'lib', type, '**/*.rb')
|
|
123
|
+
files.concat(Dir.glob(pattern))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
files.uniq.sort
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
class << self
|
|
131
|
+
def load_all!(load_paths: nil)
|
|
132
|
+
loader = Loader.new(load_paths: load_paths)
|
|
133
|
+
loader.load_all!
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def load_cores!(load_paths: nil)
|
|
137
|
+
loader = Loader.new(load_paths: load_paths)
|
|
138
|
+
loader.load_cores!
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def load_adapters!(load_paths: nil)
|
|
142
|
+
loader = Loader.new(load_paths: load_paths)
|
|
143
|
+
loader.load_adapters!
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def load_facets!(load_paths: nil)
|
|
147
|
+
loader = Loader.new(load_paths: load_paths)
|
|
148
|
+
loader.load_facets!
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
require 'logger'
|