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,33 @@
1
+ # Internal Facet
2
+ # Service-to-service API for internal microservices
3
+ # Full access to all fields and operations
4
+
5
+ Facera.define_facet(:internal, core: :payment) do
6
+ description "Internal service-to-service API"
7
+
8
+ # Expose all fields for internal services
9
+ expose :payment do
10
+ fields :all
11
+
12
+ # Add computed fields for internal use
13
+ computed :age_in_seconds do
14
+ created_at ? (Time.now - created_at).to_i : 0
15
+ end
16
+
17
+ computed :is_recent do
18
+ age_in_seconds < 3600 # Less than 1 hour old
19
+ end
20
+ end
21
+
22
+ # Allow all capabilities
23
+ allow_capabilities :all
24
+
25
+ # No scoping - internal services can see everything
26
+ # They're trusted and may need to operate on any payment
27
+
28
+ # Detailed errors for debugging
29
+ error_verbosity :detailed
30
+
31
+ # Audit all internal operations (in production, this would log to a service)
32
+ audit_all_operations user: :system
33
+ end
@@ -0,0 +1,48 @@
1
+ # Operator Facet
2
+ # Admin/support operator API with enhanced visibility
3
+ # Used by support tools and admin dashboards
4
+
5
+ Facera.define_facet(:operator, core: :payment) do
6
+ description "Support operator and admin tools API"
7
+
8
+ # Expose all fields plus additional operational data
9
+ expose :payment do
10
+ fields :all
11
+
12
+ # Add operator-specific computed fields
13
+ computed :customer_name do
14
+ # In a real app: Customer.find(customer_id).name
15
+ "Customer #{customer_id[0..7]}"
16
+ end
17
+
18
+ computed :merchant_name do
19
+ # In a real app: Merchant.find(merchant_id).name
20
+ "Merchant #{merchant_id[0..7]}"
21
+ end
22
+
23
+ computed :time_in_current_state do
24
+ if status == :confirmed && confirmed_at
25
+ Time.now - confirmed_at
26
+ elsif status == :cancelled && cancelled_at
27
+ Time.now - cancelled_at
28
+ elsif created_at
29
+ Time.now - created_at
30
+ else
31
+ 0
32
+ end
33
+ end
34
+ end
35
+
36
+ # Allow all capabilities (operators can do everything)
37
+ allow_capabilities :all
38
+
39
+ # No scoping - operators need to see all payments for support
40
+ # But we audit all their actions
41
+ audit_all_operations user: :current_operator
42
+
43
+ # Detailed errors for troubleshooting
44
+ error_verbosity :detailed
45
+
46
+ # Structured format for better tooling integration
47
+ format :structured
48
+ end
data/facera.gemspec ADDED
@@ -0,0 +1,30 @@
1
+ require_relative 'lib/facera/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "facera"
5
+ spec.version = Facera::VERSION
6
+ spec.authors = ["Juan Carlos Garcia"]
7
+ spec.email = ["jugade92@gmail.com"]
8
+
9
+ spec.summary = "A Ruby framework for building multi-facet APIs from a single semantic core"
10
+ spec.description = "Facera allows you to define your system once as a semantic core and expose it through multiple facets, each tailored to different consumers while remaining logically consistent."
11
+ spec.homepage = "https://github.com/jcagarcia/facera"
12
+ spec.license = "MIT"
13
+ spec.required_ruby_version = ">= 3.2.0"
14
+
15
+ spec.metadata["homepage_uri"] = spec.homepage
16
+ spec.metadata["source_code_uri"] = spec.homepage
17
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
18
+
19
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
20
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
21
+ end
22
+
23
+ spec.bindir = "exe"
24
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
25
+ spec.require_paths = ["lib"]
26
+
27
+ # Runtime dependencies
28
+ spec.add_dependency "grape", "~> 2.0"
29
+ spec.add_dependency "grape-entity", "~> 1.0"
30
+ end
data/img/facera.png ADDED
Binary file
@@ -0,0 +1,83 @@
1
+ module Facera
2
+ # Base module for adapters
3
+ # Adapters implement the actual business logic for capabilities
4
+ #
5
+ # Example:
6
+ # class PaymentAdapter
7
+ # include Facera::Adapter
8
+ #
9
+ # def create_payment(params)
10
+ # Payment.create!(params)
11
+ # end
12
+ #
13
+ # def get_payment(id:)
14
+ # Payment.find(id)
15
+ # end
16
+ # end
17
+ module Adapter
18
+ def self.included(base)
19
+ base.extend(ClassMethods)
20
+ end
21
+
22
+ module ClassMethods
23
+ # Returns the core name this adapter is for
24
+ # Inferred from class name: PaymentAdapter -> :payment
25
+ def core_name
26
+ name.gsub(/Adapter$/, '').split('::').last.underscore.to_sym
27
+ end
28
+ end
29
+
30
+ # Default implementation for capabilities
31
+ # Subclasses override specific methods
32
+
33
+ def create(params)
34
+ raise NotImplementedError, "#{self.class}#create not implemented"
35
+ end
36
+
37
+ def get(id:)
38
+ raise NotImplementedError, "#{self.class}#get not implemented"
39
+ end
40
+
41
+ def list(filters = {})
42
+ raise NotImplementedError, "#{self.class}#list not implemented"
43
+ end
44
+
45
+ def action(capability_name, params)
46
+ raise NotImplementedError, "#{self.class}#action(#{capability_name}) not implemented"
47
+ end
48
+ end
49
+
50
+ # Adapter registry
51
+ class AdapterRegistry
52
+ @adapters = {}
53
+
54
+ class << self
55
+ def register(core_name, adapter_class)
56
+ @adapters[core_name.to_sym] = adapter_class
57
+ end
58
+
59
+ def get(core_name)
60
+ @adapters[core_name.to_sym]
61
+ end
62
+
63
+ def all
64
+ @adapters
65
+ end
66
+
67
+ def clear!
68
+ @adapters = {}
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ # String underscore helper
75
+ class String
76
+ def underscore
77
+ gsub(/::/, '/')
78
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
79
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
80
+ .tr('-', '_')
81
+ .downcase
82
+ end unless method_defined?(:underscore)
83
+ end
@@ -0,0 +1,95 @@
1
+ module Facera
2
+ class Attribute
3
+ attr_reader :name, :type, :options
4
+
5
+ VALID_TYPES = [
6
+ :string,
7
+ :integer,
8
+ :float,
9
+ :boolean,
10
+ :uuid,
11
+ :money,
12
+ :timestamp,
13
+ :datetime,
14
+ :date,
15
+ :enum,
16
+ :hash,
17
+ :array
18
+ ].freeze
19
+
20
+ def initialize(name, type, **options)
21
+ @name = name.to_sym
22
+ @type = type.to_sym
23
+ @options = options
24
+
25
+ validate_type!
26
+ validate_options!
27
+ end
28
+
29
+ def required?
30
+ @options[:required] == true
31
+ end
32
+
33
+ def immutable?
34
+ @options[:immutable] == true
35
+ end
36
+
37
+ def default_value
38
+ @options[:default]
39
+ end
40
+
41
+ def enum_values
42
+ @options[:values] || []
43
+ end
44
+
45
+ def validate_value(value)
46
+ return true if value.nil? && !required?
47
+ return false if value.nil? && required?
48
+
49
+ case type
50
+ when :string
51
+ value.is_a?(String)
52
+ when :integer
53
+ value.is_a?(Integer)
54
+ when :float
55
+ value.is_a?(Float) || value.is_a?(Integer)
56
+ when :boolean
57
+ [true, false].include?(value)
58
+ when :uuid
59
+ value.is_a?(String) && uuid_format?(value)
60
+ when :money
61
+ value.is_a?(Numeric) && value >= 0
62
+ when :timestamp, :datetime
63
+ value.is_a?(Time) || value.is_a?(DateTime)
64
+ when :date
65
+ value.is_a?(Date)
66
+ when :enum
67
+ enum_values.include?(value)
68
+ when :hash
69
+ value.is_a?(Hash)
70
+ when :array
71
+ value.is_a?(Array)
72
+ else
73
+ true
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def validate_type!
80
+ unless VALID_TYPES.include?(type)
81
+ raise Error, "Invalid attribute type '#{type}'. Valid types: #{VALID_TYPES.join(', ')}"
82
+ end
83
+ end
84
+
85
+ def validate_options!
86
+ if type == :enum && enum_values.empty?
87
+ raise Error, "Enum attribute '#{name}' must specify :values option"
88
+ end
89
+ end
90
+
91
+ def uuid_format?(value)
92
+ value.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,124 @@
1
+ module Facera
2
+ class AutoMount
3
+ attr_reader :app, :config, :mounted_facets
4
+
5
+ def initialize(app = nil, config: {})
6
+ @app = app || detect_app
7
+ @config = Facera.configuration
8
+ @mounted_facets = {}
9
+ @logger = Logger.new($stdout)
10
+ end
11
+
12
+ def mount!
13
+ log_header
14
+ discover_definitions
15
+ mount_facets
16
+ mount_introspection if @config.introspection
17
+ mount_dashboard if @config.dashboard
18
+ log_summary
19
+ @mounted_facets
20
+ end
21
+
22
+ private
23
+
24
+ def log_header
25
+ @logger.info "\n" + "=" * 80
26
+ @logger.info "💎 Facera v#{Facera::VERSION} - Auto-Mounting"
27
+ @logger.info "=" * 80
28
+ end
29
+
30
+ def discover_definitions
31
+ # Use Facera's loader to auto-discover cores, adapters, and facets
32
+ loader = Loader.new(logger: @logger)
33
+ loader.load_all!
34
+
35
+ @logger.info "\n📊 Found:"
36
+ @logger.info " Cores: #{Registry.cores.count}"
37
+ @logger.info " Adapters: #{AdapterRegistry.all.count}"
38
+ @logger.info " Facets: #{Registry.facets.count}"
39
+ end
40
+
41
+ def mount_facets
42
+ @logger.info "\n🚀 Mounting facets:"
43
+
44
+ Registry.facets.each do |name, facet|
45
+ next unless @config.facet_enabled?(name)
46
+
47
+ path = "#{@config.base_path}#{@config.path_for_facet(name)}"
48
+ api = Grape::APIGenerator.for_facet(name)
49
+
50
+ mount_api(api, path)
51
+
52
+ @mounted_facets[name] = {
53
+ path: path,
54
+ api: api,
55
+ endpoints: api.routes.count
56
+ }
57
+
58
+ @logger.info " ✓ #{name.to_s.ljust(15)} → #{path.ljust(25)} (#{api.routes.count} endpoints)"
59
+ end
60
+ end
61
+
62
+ def mount_introspection
63
+ path = "#{@config.base_path}/facera"
64
+ api = IntrospectionAPI
65
+
66
+ mount_api(api, path)
67
+
68
+ @logger.info "\n📚 Introspection API:"
69
+ @logger.info " ✓ Mounted at #{path}"
70
+ @logger.info " • #{path}/introspect - Full introspection"
71
+ @logger.info " • #{path}/cores - All cores"
72
+ @logger.info " • #{path}/facets - All facets"
73
+ @logger.info " • #{path}/openapi - OpenAPI specs"
74
+ end
75
+
76
+ def mount_api(api, path)
77
+ if defined?(Rails)
78
+ Rails.application.routes.draw do
79
+ mount api => path
80
+ end
81
+ elsif @app.respond_to?(:map)
82
+ # Rack app with map support
83
+ @app.map(path) { run api }
84
+ else
85
+ # Simple rack app
86
+ @app = Rack::URLMap.new(
87
+ path => api,
88
+ '/' => @app
89
+ )
90
+ end
91
+ end
92
+
93
+ def mount_dashboard
94
+ # Dashboard will be implemented in a future phase
95
+ # For now, just log that it would be mounted
96
+ @logger.info "\n🎨 Dashboard: #{@config.base_path}/facera/ui (coming soon)"
97
+ end
98
+
99
+ def log_summary
100
+ @logger.info "\n" + "=" * 80
101
+ @logger.info "✨ Facera ready! #{@mounted_facets.count} facets mounted"
102
+ @logger.info "=" * 80 + "\n"
103
+ end
104
+
105
+ def detect_app
106
+ if defined?(Rails)
107
+ Rails.application
108
+ elsif defined?(Sinatra::Application)
109
+ Sinatra::Application
110
+ else
111
+ # Return a basic Rack builder
112
+ Rack::Builder.new
113
+ end
114
+ end
115
+ end
116
+
117
+ class << self
118
+ def auto_mount!(app = nil, config: {})
119
+ AutoMount.new(app, config: config).mount!
120
+ end
121
+ end
122
+ end
123
+
124
+ require 'logger'
@@ -0,0 +1,117 @@
1
+ module Facera
2
+ class Capability
3
+ attr_reader :name, :type, :entity_name, :required_params, :optional_params,
4
+ :preconditions, :validations, :transitions, :field_setters, :action_name,
5
+ :execute_block
6
+
7
+ VALID_TYPES = [:create, :get, :update, :delete, :list, :action].freeze
8
+
9
+ def initialize(name, type:)
10
+ @name = name.to_sym
11
+ @type = type.to_sym
12
+ @entity_name = nil
13
+ @required_params = []
14
+ @optional_params = []
15
+ @preconditions = []
16
+ @validations = []
17
+ @transitions = []
18
+ @field_setters = {}
19
+ @action_name = nil
20
+ @execute_block = nil
21
+
22
+ validate_type!
23
+ end
24
+
25
+ def entity(name)
26
+ @entity_name = name.to_sym
27
+ end
28
+
29
+ def requires(*param_names)
30
+ param_names.each do |param_name|
31
+ @required_params << param_name.to_sym
32
+ end
33
+ end
34
+
35
+ def optional(*param_names)
36
+ param_names.each do |param_name|
37
+ @optional_params << param_name.to_sym
38
+ end
39
+ end
40
+
41
+ def precondition(&block)
42
+ @preconditions << block
43
+ end
44
+
45
+ def validates(&block)
46
+ @validations << block
47
+ end
48
+
49
+ def transitions_to(state)
50
+ @transitions << state.to_sym
51
+ end
52
+
53
+ def sets(field_values)
54
+ @field_setters.merge!(field_values)
55
+ end
56
+
57
+ def action(name)
58
+ @action_name = name.to_sym
59
+ end
60
+
61
+ def returns(type)
62
+ @return_type = type.to_sym
63
+ end
64
+
65
+ def execute(&block)
66
+ @execute_block = block
67
+ end
68
+
69
+ def has_execute_block?
70
+ !@execute_block.nil?
71
+ end
72
+
73
+ def filterable(*param_names)
74
+ @filterable_params ||= []
75
+ param_names.each do |param_name|
76
+ @filterable_params << param_name.to_sym
77
+ end
78
+ end
79
+
80
+ def filterable_params
81
+ @filterable_params || []
82
+ end
83
+
84
+ def validate_params(params)
85
+ errors = []
86
+
87
+ # Check required parameters
88
+ required_params.each do |param|
89
+ if params[param].nil? && params[param.to_s].nil?
90
+ errors << "#{param} is required"
91
+ end
92
+ end
93
+
94
+ errors
95
+ end
96
+
97
+ def check_preconditions(context)
98
+ preconditions.all? do |precondition|
99
+ context.instance_eval(&precondition)
100
+ end
101
+ end
102
+
103
+ def validate_business_rules(context)
104
+ validations.all? do |validation|
105
+ context.instance_eval(&validation)
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ def validate_type!
112
+ unless VALID_TYPES.include?(type)
113
+ raise Error, "Invalid capability type '#{type}'. Valid types: #{VALID_TYPES.join(', ')}"
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,59 @@
1
+ module Facera
2
+ class CapabilityAccess
3
+ attr_reader :allowed_capabilities, :denied_capabilities, :scopes
4
+
5
+ def initialize
6
+ @allowed_capabilities = :all
7
+ @denied_capabilities = []
8
+ @scopes = {}
9
+ end
10
+
11
+ def allow(*capability_names)
12
+ if capability_names.first == :all
13
+ @allowed_capabilities = :all
14
+ else
15
+ @allowed_capabilities = [] if @allowed_capabilities == :all
16
+ @allowed_capabilities.concat(capability_names.map(&:to_sym))
17
+ end
18
+ end
19
+
20
+ def deny(*capability_names)
21
+ @denied_capabilities.concat(capability_names.map(&:to_sym))
22
+ end
23
+
24
+ def scope(capability_name, &block)
25
+ raise Error, "Scope for '#{capability_name}' must have a block" unless block_given?
26
+ @scopes[capability_name.to_sym] = block
27
+ end
28
+
29
+ def allowed?(capability_name)
30
+ cap_sym = capability_name.to_sym
31
+
32
+ # Check if explicitly denied
33
+ return false if @denied_capabilities.include?(cap_sym)
34
+
35
+ # Check if in allowed list
36
+ if @allowed_capabilities == :all
37
+ true
38
+ else
39
+ @allowed_capabilities.include?(cap_sym)
40
+ end
41
+ end
42
+
43
+ def scope_for(capability_name)
44
+ @scopes[capability_name.to_sym]
45
+ end
46
+
47
+ def has_scope?(capability_name)
48
+ @scopes.key?(capability_name.to_sym)
49
+ end
50
+
51
+ def allowed_capability_names(core)
52
+ if @allowed_capabilities == :all
53
+ core.capabilities.keys - @denied_capabilities
54
+ else
55
+ @allowed_capabilities - @denied_capabilities
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,83 @@
1
+ module Facera
2
+ class Configuration
3
+ attr_accessor :base_path, :version, :dashboard, :generate_docs, :introspection
4
+ attr_reader :facet_paths, :disabled_facets, :authentication_handlers, :middleware_handlers
5
+
6
+ def initialize
7
+ @base_path = '/api'
8
+ @version = 'v1'
9
+ @dashboard = true
10
+ @generate_docs = true
11
+ @introspection = true
12
+ @facet_paths = {}
13
+ @disabled_facets = []
14
+ @authentication_handlers = {}
15
+ @middleware_handlers = {}
16
+ end
17
+
18
+ def facet_path(facet_name, path)
19
+ @facet_paths[facet_name.to_sym] = path
20
+ end
21
+
22
+ def disable_facet(facet_name)
23
+ @disabled_facets << facet_name.to_sym
24
+ end
25
+
26
+ def authenticate(facet_name, &block)
27
+ raise Error, "Authentication block required for facet '#{facet_name}'" unless block_given?
28
+ @authentication_handlers[facet_name.to_sym] = block
29
+ end
30
+
31
+ def middleware_for(facet_name, &block)
32
+ raise Error, "Middleware block required for facet '#{facet_name}'" unless block_given?
33
+ @middleware_handlers[facet_name.to_sym] = block
34
+ end
35
+
36
+ def path_for_facet(facet_name)
37
+ @facet_paths[facet_name.to_sym] || default_path_for(facet_name)
38
+ end
39
+
40
+ def facet_enabled?(facet_name)
41
+ !@disabled_facets.include?(facet_name.to_sym)
42
+ end
43
+
44
+ def authentication_handler_for(facet_name)
45
+ @authentication_handlers[facet_name.to_sym]
46
+ end
47
+
48
+ def middleware_handler_for(facet_name)
49
+ @middleware_handlers[facet_name.to_sym]
50
+ end
51
+
52
+ private
53
+
54
+ def default_path_for(facet_name)
55
+ case facet_name.to_sym
56
+ when :external
57
+ "/#{@version}"
58
+ when :internal
59
+ "/internal/#{@version}"
60
+ when :operator
61
+ "/operator/#{@version}"
62
+ when :agent
63
+ "/agent/#{@version}"
64
+ else
65
+ "/#{facet_name}/#{@version}"
66
+ end
67
+ end
68
+ end
69
+
70
+ class << self
71
+ def configuration
72
+ @configuration ||= Configuration.new
73
+ end
74
+
75
+ def configure
76
+ yield(configuration) if block_given?
77
+ end
78
+
79
+ def reset_configuration!
80
+ @configuration = Configuration.new
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,29 @@
1
+ module Facera
2
+ # Lightweight context object for block evaluation
3
+ # Replaces OpenStruct with a simpler, faster implementation
4
+ class Context
5
+ def initialize(data = {})
6
+ @data = data.transform_keys(&:to_sym)
7
+ end
8
+
9
+ def method_missing(method, *args)
10
+ if args.empty?
11
+ # Getter
12
+ @data[method]
13
+ elsif method.to_s.end_with?('=')
14
+ # Setter
15
+ @data[method.to_s.chomp('=').to_sym] = args.first
16
+ else
17
+ super
18
+ end
19
+ end
20
+
21
+ def respond_to_missing?(method, include_private = false)
22
+ @data.key?(method) || method.to_s.end_with?('=') || super
23
+ end
24
+
25
+ def to_h
26
+ @data
27
+ end
28
+ end
29
+ end