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,69 @@
1
+ module Facera
2
+ class FieldVisibility
3
+ attr_reader :entity_name, :visible_fields, :hidden_fields, :field_aliases, :computed_fields
4
+
5
+ def initialize(entity_name)
6
+ @entity_name = entity_name.to_sym
7
+ @visible_fields = nil # nil means all fields visible
8
+ @hidden_fields = []
9
+ @field_aliases = {}
10
+ @computed_fields = {}
11
+ end
12
+
13
+ def fields(*field_names)
14
+ if field_names.first == :all
15
+ @visible_fields = :all
16
+ else
17
+ @visible_fields = field_names.map(&:to_sym)
18
+ end
19
+ end
20
+
21
+ def hide(*field_names)
22
+ @hidden_fields = field_names.map(&:to_sym)
23
+ end
24
+
25
+ def alias_field(source, as:)
26
+ @field_aliases[source.to_sym] = as.to_sym
27
+ end
28
+
29
+ def computed(field_name, &block)
30
+ raise Error, "Computed field '#{field_name}' must have a block" unless block_given?
31
+ @computed_fields[field_name.to_sym] = block
32
+ end
33
+
34
+ def visible?(field_name)
35
+ field_sym = field_name.to_sym
36
+
37
+ # If explicitly hidden, not visible
38
+ return false if @hidden_fields.include?(field_sym)
39
+
40
+ # If visible_fields is nil or :all, visible (unless hidden)
41
+ return true if @visible_fields.nil? || @visible_fields == :all
42
+
43
+ # Otherwise check if in visible list
44
+ @visible_fields.include?(field_sym)
45
+ end
46
+
47
+ def aliased_name(field_name)
48
+ @field_aliases[field_name.to_sym] || field_name
49
+ end
50
+
51
+ def visible_field_names(entity)
52
+ base_fields = if @visible_fields == :all || @visible_fields.nil?
53
+ entity.attributes.keys
54
+ else
55
+ @visible_fields
56
+ end
57
+
58
+ # Filter out hidden fields
59
+ base_fields.reject { |f| @hidden_fields.include?(f) }
60
+ end
61
+
62
+ def all_visible_fields(entity)
63
+ base_fields = visible_field_names(entity)
64
+ computed_field_names = @computed_fields.keys
65
+
66
+ base_fields + computed_field_names
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,23 @@
1
+ if defined?(Rails)
2
+ require 'rails/generators/base'
3
+
4
+ module Facera
5
+ module Generators
6
+ class CoreGenerator < Rails::Generators::NamedBase
7
+ source_root File.expand_path('../templates', __FILE__)
8
+
9
+ desc "Generate a new Facera core"
10
+
11
+ def create_core_file
12
+ template 'core.rb.tt', File.join('app/cores', "#{file_name}_core.rb")
13
+ end
14
+
15
+ private
16
+
17
+ def entity_name
18
+ file_name.singularize
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,25 @@
1
+ if defined?(Rails)
2
+ require 'rails/generators/base'
3
+
4
+ module Facera
5
+ module Generators
6
+ class FacetGenerator < Rails::Generators::NamedBase
7
+ source_root File.expand_path('../templates', __FILE__)
8
+
9
+ desc "Generate a new Facera facet"
10
+
11
+ class_option :core, type: :string, required: true, desc: "The core this facet belongs to"
12
+
13
+ def create_facet_file
14
+ template 'facet.rb.tt', File.join('app/facets', "#{file_name}_facet.rb")
15
+ end
16
+
17
+ private
18
+
19
+ def core_name
20
+ options[:core]
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,64 @@
1
+ if defined?(Rails)
2
+ require 'rails/generators/base'
3
+
4
+ module Facera
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ source_root File.expand_path('../templates', __FILE__)
8
+
9
+ desc "Install Facera in your Rails application"
10
+
11
+ def create_directories
12
+ empty_directory 'app/cores'
13
+ empty_directory 'app/facets'
14
+ end
15
+
16
+ def create_initializer
17
+ create_file 'config/initializers/facera.rb', <<~RUBY
18
+ # Facera configuration
19
+ Facera.configure do |config|
20
+ # Base path for all APIs
21
+ # config.base_path = '/api'
22
+
23
+ # API version
24
+ # config.version = 'v1'
25
+
26
+ # Enable dashboard
27
+ # config.dashboard = true
28
+
29
+ # Custom facet paths
30
+ # config.facet_path :external, '/v1'
31
+ # config.facet_path :internal, '/internal/v1'
32
+
33
+ # Disable specific facets
34
+ # config.disable_facet :agent
35
+
36
+ # Authentication handlers
37
+ # config.authenticate :external do |request|
38
+ # token = request.headers['Authorization']
39
+ # User.find_by_token(token)
40
+ # end
41
+ end
42
+
43
+ # Auto-mount all defined facets
44
+ # This will discover and mount facets from app/facets/
45
+ # Facera.auto_mount!
46
+ RUBY
47
+ end
48
+
49
+ def show_readme
50
+ say "\n"
51
+ say "=" * 70
52
+ say "Facera installed successfully!"
53
+ say "=" * 70
54
+ say "\nNext steps:"
55
+ say " 1. Generate a core: rails g facera:core payment"
56
+ say " 2. Generate a facet: rails g facera:facet external --core=payment"
57
+ say " 3. Start your server and visit /api/v1/health"
58
+ say "\nDocumentation: https://github.com/jcagarcia/facera"
59
+ say "=" * 70 + "\n"
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,49 @@
1
+ # <%= class_name %> Core
2
+ Facera.define_core(:<%= file_name %>) do
3
+ # Define your entity
4
+ entity :<%= entity_name %> do
5
+ attribute :id, :uuid, immutable: true
6
+ attribute :created_at, :timestamp, immutable: true
7
+ attribute :updated_at, :timestamp
8
+
9
+ # Add your attributes here
10
+ # attribute :name, :string, required: true
11
+ # attribute :status, :enum, values: [:active, :inactive]
12
+ end
13
+
14
+ # Define your invariants (business rules)
15
+ # invariant :example_rule, description: "Example invariant" do
16
+ # # Your validation logic
17
+ # true
18
+ # end
19
+
20
+ # Define your capabilities
21
+ capability :create_<%= entity_name %>, type: :create do
22
+ entity :<%= entity_name %>
23
+ requires :id
24
+ # Add required params
25
+ # optional :name
26
+ end
27
+
28
+ capability :get_<%= entity_name %>, type: :get do
29
+ entity :<%= entity_name %>
30
+ requires :id
31
+ end
32
+
33
+ capability :list_<%= file_name %>, type: :list do
34
+ entity :<%= entity_name %>
35
+ optional :limit, :offset
36
+ # filterable :status
37
+ end
38
+
39
+ capability :update_<%= entity_name %>, type: :update do
40
+ entity :<%= entity_name %>
41
+ requires :id
42
+ # optional :name
43
+ end
44
+
45
+ capability :delete_<%= entity_name %>, type: :delete do
46
+ entity :<%= entity_name %>
47
+ requires :id
48
+ end
49
+ end
@@ -0,0 +1,23 @@
1
+ # <%= class_name %> Facet
2
+ Facera.define_facet(:<%= file_name %>, core: :<%= core_name %>) do
3
+ description "<%= class_name %> API"
4
+
5
+ # Control field visibility
6
+ # expose :<%= core_name.singularize %> do
7
+ # fields :id, :name, :created_at
8
+ # # hide :sensitive_field
9
+ # # alias_field :created_at, as: :createdAt
10
+ # end
11
+
12
+ # Control capability access
13
+ # allow_capabilities :create_<%= core_name.singularize %>, :get_<%= core_name.singularize %>, :list_<%= core_name %>
14
+ # deny_capabilities :delete_<%= core_name.singularize %>
15
+
16
+ # Add scoping
17
+ # scope :list_<%= core_name %> do
18
+ # { user_id: current_user.id }
19
+ # end
20
+
21
+ # Set error verbosity
22
+ error_verbosity :minimal
23
+ end
@@ -0,0 +1,59 @@
1
+ require 'grape'
2
+
3
+ module Facera
4
+ module Grape
5
+ class APIGenerator
6
+ def self.for_facet(facet_name)
7
+ facet = Registry.find_facet(facet_name)
8
+ core = facet.core
9
+
10
+ api_class = Class.new(::Grape::API) do
11
+ version 'v1', using: :header, vendor: 'facera'
12
+ format :json
13
+ content_type :json, 'application/json'
14
+
15
+ # Store facet reference
16
+ define_singleton_method(:facet) { facet }
17
+
18
+ # Add helpers
19
+ helpers do
20
+ def current_user
21
+ # Placeholder - in real implementation this would authenticate
22
+ @current_user ||= { id: 'user-123' }
23
+ end
24
+
25
+ def current_facet
26
+ self.class.facet
27
+ end
28
+ end
29
+
30
+ # Generate endpoints for each allowed capability
31
+ facet.allowed_capabilities.each do |capability_name|
32
+ capability = core.find_capability(capability_name)
33
+ next unless capability.entity_name
34
+
35
+ entity_name = capability.entity_name
36
+ resource_name = entity_name.to_s.pluralize
37
+
38
+ # Create resource block
39
+ resource resource_name do
40
+ EndpointGenerator.generate_for(self, capability, facet)
41
+ end
42
+ end
43
+
44
+ # Add a health check endpoint
45
+ get :health do
46
+ {
47
+ status: 'ok',
48
+ facet: facet.name,
49
+ core: core.name,
50
+ timestamp: Time.now.iso8601
51
+ }
52
+ end
53
+ end
54
+
55
+ api_class
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,316 @@
1
+ module Facera
2
+ module Grape
3
+ class EndpointGenerator
4
+ def self.generate_for(api_class, capability, facet)
5
+ case capability.type
6
+ when :create
7
+ generate_create(api_class, capability, facet)
8
+ when :get
9
+ generate_get(api_class, capability, facet)
10
+ when :update
11
+ generate_update(api_class, capability, facet)
12
+ when :delete
13
+ generate_delete(api_class, capability, facet)
14
+ when :list
15
+ generate_list(api_class, capability, facet)
16
+ when :action
17
+ generate_action(api_class, capability, facet)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def self.generate_create(api_class, capability, facet)
24
+ entity_name = capability.entity_name
25
+ resource_name = entity_name.to_s.pluralize
26
+ entity = facet.core.find_entity(entity_name)
27
+ entity_class = EntityGenerator.for(entity, facet)
28
+
29
+ # Pre-calculate param types
30
+ param_types = {}
31
+ capability.required_params.each do |param|
32
+ param_types[param] = param_type_for(param, entity)
33
+ end
34
+ capability.optional_params.each do |param|
35
+ param_types[param] = param_type_for(param, entity)
36
+ end
37
+
38
+ api_class.class_eval do
39
+ desc "Create a #{entity_name}" do
40
+ success entity_class
41
+ failure [[400, 'Validation Error'], [401, 'Unauthorized']]
42
+ end
43
+
44
+ params do
45
+ capability.required_params.each do |param|
46
+ requires param, type: param_types[param]
47
+ end
48
+
49
+ capability.optional_params.each do |param|
50
+ optional param, type: param_types[param]
51
+ end
52
+ end
53
+
54
+ post do
55
+ result = Executor.run(
56
+ facet: facet,
57
+ capability: capability,
58
+ params: declared(params),
59
+ context: { current_user: current_user }
60
+ )
61
+
62
+ present result, with: entity_class
63
+ rescue Facera::ValidationError => e
64
+ error!({ error: 'validation', errors: e.errors }, 400)
65
+ rescue Facera::UnauthorizedError => e
66
+ error!({ error: 'unauthorized', message: e.message }, 401)
67
+ end
68
+ end
69
+ end
70
+
71
+ def self.generate_get(api_class, capability, facet)
72
+ entity_name = capability.entity_name
73
+ resource_name = entity_name.to_s.pluralize
74
+ entity = facet.core.find_entity(entity_name)
75
+ entity_class = EntityGenerator.for(entity, facet)
76
+
77
+ api_class.class_eval do
78
+ desc "Get a #{entity_name}" do
79
+ success entity_class
80
+ failure [[404, 'Not Found'], [401, 'Unauthorized']]
81
+ end
82
+
83
+ params do
84
+ requires :id, type: String, desc: "#{entity_name.to_s.capitalize} ID"
85
+ end
86
+
87
+ get ':id' do
88
+ result = Executor.run(
89
+ facet: facet,
90
+ capability: capability,
91
+ params: { id: params[:id] },
92
+ context: { current_user: current_user }
93
+ )
94
+
95
+ present result, with: entity_class
96
+ rescue Facera::NotFoundError => e
97
+ error!({ error: 'not_found', message: e.message }, 404)
98
+ rescue Facera::UnauthorizedError => e
99
+ error!({ error: 'unauthorized', message: e.message }, 401)
100
+ end
101
+ end
102
+ end
103
+
104
+ def self.generate_list(api_class, capability, facet)
105
+ entity_name = capability.entity_name
106
+ resource_name = entity_name.to_s.pluralize
107
+ entity = facet.core.find_entity(entity_name)
108
+ collection_class = EntityGenerator.for_collection(entity, facet)
109
+
110
+ api_class.class_eval do
111
+ desc "List #{resource_name}" do
112
+ success collection_class
113
+ failure [[401, 'Unauthorized']]
114
+ end
115
+
116
+ params do
117
+ optional :limit, type: Integer, default: 20, desc: 'Number of items to return'
118
+ optional :offset, type: Integer, default: 0, desc: 'Number of items to skip'
119
+
120
+ capability.filterable_params.each do |param|
121
+ optional param, type: String, desc: "Filter by #{param}"
122
+ end
123
+
124
+ capability.optional_params.each do |param|
125
+ next if [:limit, :offset].include?(param)
126
+ optional param, type: String
127
+ end
128
+ end
129
+
130
+ get do
131
+ result = Executor.run(
132
+ facet: facet,
133
+ capability: capability,
134
+ params: declared(params),
135
+ context: { current_user: current_user }
136
+ )
137
+
138
+ present result, with: collection_class
139
+ rescue Facera::UnauthorizedError => e
140
+ error!({ error: 'unauthorized', message: e.message }, 401)
141
+ end
142
+ end
143
+ end
144
+
145
+ def self.generate_update(api_class, capability, facet)
146
+ entity_name = capability.entity_name
147
+ entity = facet.core.find_entity(entity_name)
148
+ entity_class = EntityGenerator.for(entity, facet)
149
+
150
+ # Pre-calculate param types
151
+ param_types = {}
152
+ capability.optional_params.each do |param|
153
+ param_types[param] = param_type_for(param, entity)
154
+ end
155
+
156
+ api_class.class_eval do
157
+ desc "Update a #{entity_name}" do
158
+ success entity_class
159
+ failure [[400, 'Validation Error'], [404, 'Not Found'], [401, 'Unauthorized']]
160
+ end
161
+
162
+ params do
163
+ requires :id, type: String
164
+
165
+ capability.optional_params.each do |param|
166
+ optional param, type: param_types[param]
167
+ end
168
+ end
169
+
170
+ patch ':id' do
171
+ result = Executor.run(
172
+ facet: facet,
173
+ capability: capability,
174
+ params: declared(params),
175
+ context: { current_user: current_user }
176
+ )
177
+
178
+ present result, with: entity_class
179
+ rescue Facera::ValidationError => e
180
+ error!({ error: 'validation', errors: e.errors }, 400)
181
+ rescue Facera::NotFoundError => e
182
+ error!({ error: 'not_found', message: e.message }, 404)
183
+ rescue Facera::UnauthorizedError => e
184
+ error!({ error: 'unauthorized', message: e.message }, 401)
185
+ end
186
+ end
187
+ end
188
+
189
+ def self.generate_delete(api_class, capability, facet)
190
+ entity_name = capability.entity_name
191
+
192
+ api_class.class_eval do
193
+ desc "Delete a #{entity_name}" do
194
+ success { { success: Boolean, id: String } }
195
+ failure [[404, 'Not Found'], [401, 'Unauthorized']]
196
+ end
197
+
198
+ params do
199
+ requires :id, type: String
200
+ end
201
+
202
+ delete ':id' do
203
+ result = Executor.run(
204
+ facet: facet,
205
+ capability: capability,
206
+ params: { id: params[:id] },
207
+ context: { current_user: current_user }
208
+ )
209
+
210
+ present result
211
+ rescue Facera::NotFoundError => e
212
+ error!({ error: 'not_found', message: e.message }, 404)
213
+ rescue Facera::UnauthorizedError => e
214
+ error!({ error: 'unauthorized', message: e.message }, 401)
215
+ end
216
+ end
217
+ end
218
+
219
+ def self.generate_action(api_class, capability, facet)
220
+ entity_name = capability.entity_name
221
+ action_name = capability.action_name || extract_action_name(capability.name)
222
+ entity = facet.core.find_entity(entity_name)
223
+ entity_class = EntityGenerator.for(entity, facet)
224
+
225
+ # Pre-calculate param types
226
+ param_types = {}
227
+ capability.required_params.each do |param|
228
+ next if param == :id
229
+ param_types[param] = param_type_for(param, entity)
230
+ end
231
+ capability.optional_params.each do |param|
232
+ param_types[param] = param_type_for(param, entity)
233
+ end
234
+
235
+ api_class.class_eval do
236
+ desc "#{action_name.to_s.humanize} a #{entity_name}" do
237
+ success entity_class
238
+ failure [[400, 'Validation Error'], [404, 'Not Found'], [401, 'Unauthorized'], [422, 'Precondition Failed']]
239
+ end
240
+
241
+ params do
242
+ requires :id, type: String
243
+
244
+ capability.required_params.each do |param|
245
+ next if param == :id
246
+ requires param, type: param_types[param]
247
+ end
248
+
249
+ capability.optional_params.each do |param|
250
+ optional param, type: param_types[param]
251
+ end
252
+ end
253
+
254
+ post ":id/#{action_name}" do
255
+ result = Executor.run(
256
+ facet: facet,
257
+ capability: capability,
258
+ params: declared(params),
259
+ context: { current_user: current_user }
260
+ )
261
+
262
+ present result, with: entity_class
263
+ rescue Facera::ValidationError => e
264
+ error!({ error: 'validation', errors: e.errors }, 400)
265
+ rescue Facera::PreconditionError => e
266
+ error!({ error: 'precondition', message: e.message }, 422)
267
+ rescue Facera::NotFoundError => e
268
+ error!({ error: 'not_found', message: e.message }, 404)
269
+ rescue Facera::UnauthorizedError => e
270
+ error!({ error: 'unauthorized', message: e.message }, 401)
271
+ end
272
+ end
273
+ end
274
+
275
+ def self.extract_action_name(capability_name)
276
+ # Extract action from capability name like "confirm_payment" -> "confirm"
277
+ name_parts = capability_name.to_s.split('_')
278
+ name_parts[0...-1].join('_').to_sym
279
+ end
280
+
281
+ def self.param_type_for(param_name, entity)
282
+ attr = entity.find_attribute(param_name)
283
+ return String unless attr
284
+
285
+ case attr.type
286
+ when :string, :uuid then String
287
+ when :integer then Integer
288
+ when :float, :money then Float
289
+ when :boolean then Boolean
290
+ when :hash then Hash
291
+ when :array then Array
292
+ else String
293
+ end
294
+ end
295
+ end
296
+ end
297
+ end
298
+
299
+ # Add humanize method if not available
300
+ unless String.method_defined?(:humanize)
301
+ class String
302
+ def humanize
303
+ self.gsub('_', ' ').capitalize
304
+ end
305
+ end
306
+ end
307
+
308
+ # Add pluralize method if not available
309
+ unless String.method_defined?(:pluralize)
310
+ class String
311
+ def pluralize
312
+ return self if self.end_with?('s')
313
+ "#{self}s"
314
+ end
315
+ end
316
+ end