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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/.DS_Store +0 -0
  3. data/.github/workflows/gem-push.yml +42 -0
  4. data/.github/workflows/ruby.yml +35 -0
  5. data/.gitignore +39 -0
  6. data/.rspec +3 -0
  7. data/CHANGELOG.md +137 -0
  8. data/Gemfile +9 -0
  9. data/LICENSE +21 -0
  10. data/README.md +309 -0
  11. data/Rakefile +6 -0
  12. data/examples/01_core_dsl.rb +132 -0
  13. data/examples/02_facet_system.rb +216 -0
  14. data/examples/03_api_generation.rb +117 -0
  15. data/examples/04_auto_mounting.rb +182 -0
  16. data/examples/05_adapters.rb +196 -0
  17. data/examples/README.md +184 -0
  18. data/examples/server/README.md +376 -0
  19. data/examples/server/adapters/payment_adapter.rb +139 -0
  20. data/examples/server/application.rb +17 -0
  21. data/examples/server/config/facera.rb +33 -0
  22. data/examples/server/config.ru +10 -0
  23. data/examples/server/cores/payment_core.rb +82 -0
  24. data/examples/server/facets/external_facet.rb +38 -0
  25. data/examples/server/facets/internal_facet.rb +33 -0
  26. data/examples/server/facets/operator_facet.rb +48 -0
  27. data/facera.gemspec +30 -0
  28. data/img/facera.png +0 -0
  29. data/lib/facera/adapter.rb +83 -0
  30. data/lib/facera/attribute.rb +95 -0
  31. data/lib/facera/auto_mount.rb +124 -0
  32. data/lib/facera/capability.rb +117 -0
  33. data/lib/facera/capability_access.rb +59 -0
  34. data/lib/facera/configuration.rb +83 -0
  35. data/lib/facera/context.rb +29 -0
  36. data/lib/facera/core.rb +65 -0
  37. data/lib/facera/dsl.rb +41 -0
  38. data/lib/facera/entity.rb +50 -0
  39. data/lib/facera/error_formatter.rb +100 -0
  40. data/lib/facera/errors.rb +40 -0
  41. data/lib/facera/executor.rb +265 -0
  42. data/lib/facera/facet.rb +103 -0
  43. data/lib/facera/field_visibility.rb +69 -0
  44. data/lib/facera/generators/core_generator.rb +23 -0
  45. data/lib/facera/generators/facet_generator.rb +25 -0
  46. data/lib/facera/generators/install_generator.rb +64 -0
  47. data/lib/facera/generators/templates/core.rb.tt +49 -0
  48. data/lib/facera/generators/templates/facet.rb.tt +23 -0
  49. data/lib/facera/grape/api_generator.rb +59 -0
  50. data/lib/facera/grape/endpoint_generator.rb +316 -0
  51. data/lib/facera/grape/entity_generator.rb +89 -0
  52. data/lib/facera/grape.rb +14 -0
  53. data/lib/facera/introspection.rb +111 -0
  54. data/lib/facera/introspection_api.rb +66 -0
  55. data/lib/facera/invariant.rb +26 -0
  56. data/lib/facera/loader.rb +153 -0
  57. data/lib/facera/openapi_generator.rb +338 -0
  58. data/lib/facera/railtie.rb +51 -0
  59. data/lib/facera/registry.rb +34 -0
  60. data/lib/facera/tasks/routes.rake +66 -0
  61. data/lib/facera/version.rb +3 -0
  62. data/lib/facera.rb +35 -0
  63. 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
@@ -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'