forest_admin_rails 1.0.0.pre.beta.24 → 1.44.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b9b5e4f2f320d600fafe2f81248d1cb9fe102af50b52d6b738eeeb4386a650ef
4
- data.tar.gz: 8165afae5fb531f0f6f3dd24bd12d9de75f15dc027e16241e7311364df6d612e
3
+ metadata.gz: d50d0efa3697bb50f9245de346218cba23f20d4656d1b911e1288d8458f36fe7
4
+ data.tar.gz: 37cc1bb7b839d70a1a657b4a789c09c91c25f7e4d88301bfc93d00f1d63e4f63
5
5
  SHA512:
6
- metadata.gz: 63b9074b36320f95439b315d2f71b3abb806e75aaa6e8289576a60d1c031cdcaaa7765e289e60fa926e6d3b20772bc51efc2eb6d9e8277109a266ea75ba0cc27
7
- data.tar.gz: fa8017a8c2daa7ca4bd4c9d32675e04e273589962316bec2a4bcf819c4a5f1452d467ad278571a5fd84abbff533f66fc1480a8a876d6f3a651ba924cedb7d13f
6
+ metadata.gz: e9e9afe36b09214d3c7e7a2554ddc45bd57c5e2362ecfcdb91038b1cdc87387f2f06833ec2919faecfce28f24da563bbe521d7fa8497a982067ac9dda530bafd
7
+ data.tar.gz: 8e5bb1355764c22cb9d15873dfc2ece91a4efaf9d2b6271729863dea4b194706dbc44ba5dadfb228d79aecd51cfdc0b971d105068e09a5cbffb98c89a1b3c0cb
@@ -1,14 +1,29 @@
1
+ require 'jsonapi-serializers'
2
+ require 'openid_connect'
3
+
1
4
  module ForestAdminRails
2
5
  class ForestController < ActionController::Base
6
+ include ForestAdminAgent::Http
7
+
3
8
  skip_forgery_protection
4
9
 
5
10
  def index
6
- if ForestAdminAgent::Http::Router.routes.key? params['route_alias']
7
- route = ForestAdminAgent::Http::Router.routes[params['route_alias']]
11
+ if ForestAdminAgent::Http::Router.cached_routes.key? params['route_alias']
12
+ route = ForestAdminAgent::Http::Router.cached_routes[params['route_alias']]
8
13
 
9
14
  begin
10
- forest_response route[:closure].call({ params: params.to_unsafe_h, headers: request.headers.to_h })
11
- rescue StandardError => e
15
+ forest_response route[:closure].call(
16
+ {
17
+ params: params.to_unsafe_h,
18
+ headers: request.headers.to_h,
19
+ # Raw request context for verbatim-forwarding routes (workflow executor proxy);
20
+ # other routes ignore these keys.
21
+ method: request.request_method,
22
+ query_string: request.query_string,
23
+ body: request.raw_post
24
+ }
25
+ )
26
+ rescue StandardError, JSONAPI::Serializer::Error => e
12
27
  exception_handler e
13
28
  end
14
29
  else
@@ -17,35 +32,79 @@ module ForestAdminRails
17
32
  end
18
33
 
19
34
  def forest_response(data = {})
20
- render json: data[:content], status: data[:status] || 200
35
+ if data[:content].is_a?(Hash)
36
+ # Handle streaming responses (NEW)
37
+ return handle_streaming_response(data) if data.dig(:content, :type) == 'Stream'
38
+
39
+ if data.dig(:content, :type) == 'File'
40
+ response.headers['Access-Control-Expose-Headers'] = 'Content-Disposition'
41
+ return send_data data[:content][:stream], filename: data[:content][:name], type: data[:content][:mime_type],
42
+ disposition: 'attachment'
43
+ end
44
+
45
+ if data.dig(:content, :headers)
46
+ response.headers.merge!(data[:content][:headers] || {})
47
+ data[:content].delete(:headers)
48
+ end
49
+ end
50
+
51
+ # Proxy routes expose response headers at the root (their `content` is the rendered body,
52
+ # so headers can't nest under it like Stream/File do).
53
+ response.headers.merge!(data[:headers]) if data[:headers].is_a?(Hash)
54
+
55
+ respond_to do |format|
56
+ format.json { render json: data[:content], status: data[:status] || data[:content][:status] || 200 }
57
+ format.csv { render plain: data[:content][:export], status: 200, filename: data[:filename] }
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ # Handle streaming response (enumerator-based)
64
+ def handle_streaming_response(data)
65
+ enumerator = data[:content][:enumerator]
66
+ headers = data[:content][:headers] || {}
67
+
68
+ # Merge headers
69
+ response.headers.merge!(headers)
70
+
71
+ # Set response status
72
+ response.status = data[:status] || 200
73
+
74
+ # Stream the enumerator
75
+ # Rails will automatically use chunked transfer encoding
76
+ self.response_body = enumerator
21
77
  end
22
78
 
23
79
  def exception_handler(exception)
24
- if exception.is_a? ForestAdminAgent::Http::Exceptions::AuthenticationOpenIdClient
25
- data = {
26
- error: exception.error,
27
- error_description: exception.error_description,
28
- state: exception.state
29
- }
30
- else
31
- data = {
32
- errors: [
33
- {
34
- name: exception.name,
35
- detail: exception.message,
36
- status: exception.try(:status)
37
- }
38
- ]
39
- }
80
+ http_exception = ErrorTranslator.translate(exception)
40
81
 
41
- data[:errors][0][:data] = exception.try(:data)
82
+ response.headers.merge!(http_exception.custom_headers || {})
42
83
 
43
- # TODO: IMPLEMENT LOGGING
44
- # if Facades::Container.cache(:is_production)
45
- # end
46
- end
84
+ data = case exception
85
+ when ForestAdminAgent::Http::Exceptions::AuthenticationOpenIdClient,
86
+ OpenIDConnect::Exception
87
+ {
88
+ error: exception.message,
89
+ error_description: exception.response,
90
+ state: http_exception.status
91
+ }
92
+ else
93
+ {
94
+ errors: [
95
+ {
96
+ name: http_exception.name,
97
+ detail: http_exception.message,
98
+ status: http_exception.status,
99
+ data: http_exception.data
100
+ }.compact
101
+ ]
102
+ }
103
+ end
104
+
105
+ ForestAdminAgent::Facades::Container.logger.log('Error', exception.full_message) if http_exception.status >= 500
47
106
 
48
- render json: data, status: exception.try(:status)
107
+ render json: data, status: http_exception.status
49
108
  end
50
109
  end
51
110
  end
@@ -0,0 +1,12 @@
1
+ class ForestAdminErrorSubscriber
2
+ SEVERITY_TO_LEVEL = {
3
+ error: 'Error',
4
+ warning: 'Warn',
5
+ info: 'Info'
6
+ }.freeze
7
+
8
+ def report(error, handled:, severity:, context:, source: nil)
9
+ level = SEVERITY_TO_LEVEL.fetch(severity, 'Error')
10
+ ForestAdminAgent::Facades::Container.logger.log(level, "[ForestAdmin] #{error.full_message}")
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ # Route pre-caching has been moved to engine.rb after agent.build() completes
2
+ #
3
+ # This initializer is kept for informational purposes only
4
+ if defined?(ForestAdminAgent::Http::Router)
5
+ cache_disabled = ForestAdminAgent::Http::Router.cache_disabled?
6
+
7
+ if cache_disabled
8
+ Rails.logger.warn('[ForestAdmin] Route caching is DISABLED - this will impact performance')
9
+ Rails.logger.warn('[ForestAdmin] To enable caching, remove disable_route_cache: true from configuration')
10
+ end
11
+ end
data/config/routes.rb CHANGED
@@ -1,5 +1,25 @@
1
1
  ForestAdminRails::Engine.routes.draw do
2
- ForestAdminAgent::Http::Router.routes.each do |name, agent_route|
3
- match agent_route[:uri], to: 'forest#index', via: agent_route[:method], as: name, route_alias: name
2
+ next if defined?(Rake) && Rake.respond_to?(:application) && Rake.application&.top_level_tasks&.any?
3
+
4
+ # Don't load routes if the agent hasn't been set up (e.g., during tests)
5
+ next unless ForestAdminAgent::Builder::AgentFactory.instance.container
6
+
7
+ scope '/forest' do
8
+ # Use cached_routes to avoid recomputing routes during Rails route initialization
9
+ ForestAdminAgent::Http::Router.cached_routes.each do |name, agent_route|
10
+ match agent_route[:uri],
11
+ defaults: { format: agent_route[:format] },
12
+ to: 'forest#index',
13
+ via: agent_route[:method],
14
+ as: name,
15
+ route_alias: name,
16
+ constraints: { id: /[a-zA-Z0-9.-]*+/ }
17
+ end
18
+ rescue StandardError => e
19
+ if defined?(Rails) && Rails.logger
20
+ Rails.logger.error("[ForestAdmin] CRITICAL: Failed to initialize routes: #{e.class} - #{e.message}")
21
+ Rails.logger.error(e.backtrace.join("\n"))
22
+ end
23
+ raise e
4
24
  end
5
25
  end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ensure dependencies are fully loaded before referencing their constants
4
+ require 'forest_admin_datasource_toolkit'
5
+ require 'forest_admin_datasource_customizer'
6
+ require 'forest_admin_agent'
7
+
8
+ # ForestAdmin::Types provides a convenient way to access commonly-used classes
9
+ # from ForestAdmin packages without requiring multiple includes.
10
+ #
11
+ # Usage:
12
+ # include ForestAdmin::Types
13
+ #
14
+ # # Now you can use short class names:
15
+ # ConditionTreeLeaf.new('status', Operators::EQUAL, 'active')
16
+ # Filter.new(condition_tree: tree)
17
+ # Aggregation.new(operation: 'Count', field: 'id')
18
+ #
19
+ # # In action blocks, result_builder is an ActionResultBuilder:
20
+ # result_builder.success(message: 'Done!')
21
+ # result_builder.file(content: data, name: 'export.csv')
22
+ #
23
+ # # In chart blocks, result_builder is a ChartResultBuilder:
24
+ # result_builder.value(42)
25
+ # result_builder.distribution({ 'Active' => 10, 'Inactive' => 5 })
26
+ #
27
+ # # Factory classes for building complex objects:
28
+ # tree = ConditionTreeFactory.from_plain_object({ field: 'status', operator: 'equal', value: 'active' })
29
+ # filter = ConditionTreeFactory.match_ids(collection, [1, 2, 3])
30
+ # combined = ConditionTreeFactory.intersect([tree1, tree2])
31
+ # projection = ProjectionFactory.all(collection)
32
+ # sort = SortFactory.by_primary_keys(collection)
33
+ #
34
+ # # Exception handling in hooks and actions:
35
+ # raise ValidationError.new('Invalid input') if invalid?
36
+ # raise ForbiddenError.new('Access denied') unless authorized?
37
+ # raise UnprocessableError.new('Cannot process request')
38
+ #
39
+ # # Context classes for different handlers:
40
+ # # - ChartContext: context.get_record(['field1', 'field2'])
41
+ # # - HookContext: context.raise_validation_error('Error message')
42
+ #
43
+ module ForestAdmin
44
+ module Types
45
+ # ============================================
46
+ # Query Components
47
+ # ============================================
48
+
49
+ # Filter - used to filter records in queries
50
+ Filter = ForestAdminDatasourceToolkit::Components::Query::Filter
51
+
52
+ # Aggregation - used for aggregate operations (Count, Sum, Avg, etc.)
53
+ Aggregation = ForestAdminDatasourceToolkit::Components::Query::Aggregation
54
+
55
+ # Projection - specifies which fields to retrieve
56
+ Projection = ForestAdminDatasourceToolkit::Components::Query::Projection
57
+
58
+ # Page - pagination parameters
59
+ Page = ForestAdminDatasourceToolkit::Components::Query::Page
60
+
61
+ # Sort - sorting parameters
62
+ Sort = ForestAdminDatasourceToolkit::Components::Query::Sort
63
+
64
+ # ============================================
65
+ # Condition Tree
66
+ # ============================================
67
+
68
+ # ConditionTreeLeaf - a single condition (field, operator, value)
69
+ ConditionTreeLeaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf
70
+
71
+ # ConditionTreeBranch - combines multiple conditions with And/Or
72
+ ConditionTreeBranch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch
73
+
74
+ # Operators - all available operators (EQUAL, GREATER_THAN, CONTAINS, etc.)
75
+ Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
76
+
77
+ # ============================================
78
+ # Action Types
79
+ # ============================================
80
+
81
+ # FieldType - field types for action forms (STRING, NUMBER, BOOLEAN, etc.)
82
+ FieldType = ForestAdminDatasourceCustomizer::Decorators::Action::Types::FieldType
83
+
84
+ # ActionScope - action scopes (SINGLE, BULK, GLOBAL)
85
+ ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope
86
+
87
+ # BaseAction - base class for defining actions
88
+ BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction
89
+
90
+ # ============================================
91
+ # Action Contexts
92
+ # ============================================
93
+
94
+ # ActionContext - execution context for Bulk/Global actions
95
+ ActionContext = ForestAdminDatasourceCustomizer::Decorators::Action::Context::ActionContext
96
+
97
+ # ActionContextSingle - execution context for Single actions
98
+ ActionContextSingle = ForestAdminDatasourceCustomizer::Decorators::Action::Context::ActionContextSingle
99
+
100
+ # ActionResultBuilder - builder for action results (success, error, file, webhook)
101
+ ActionResultBuilder = ForestAdminDatasourceCustomizer::Decorators::Action::ResultBuilder
102
+
103
+ # ============================================
104
+ # Charts
105
+ # ============================================
106
+
107
+ # ChartResultBuilder - builder for chart results (value, distribution, time_based, etc.)
108
+ ChartResultBuilder = ForestAdminDatasourceCustomizer::Decorators::Chart::ResultBuilder
109
+
110
+ # ============================================
111
+ # Computed Fields
112
+ # ============================================
113
+
114
+ # ComputedDefinition - defines computed/virtual fields
115
+ ComputedDefinition = ForestAdminDatasourceCustomizer::Decorators::Computed::ComputedDefinition
116
+
117
+ # ============================================
118
+ # User/Caller Information
119
+ # ============================================
120
+
121
+ # Caller - represents the user making the request (email, timezone, team, etc.)
122
+ Caller = ForestAdminDatasourceToolkit::Components::Caller
123
+
124
+ # ============================================
125
+ # Plugins
126
+ # ============================================
127
+
128
+ # Plugin - base class for creating custom plugins
129
+ Plugin = ForestAdminDatasourceCustomizer::Plugins::Plugin
130
+
131
+ # ActiveStoragePlugin - plugin to expose Active Storage attachments as File fields
132
+ # Lazy-loaded because Zeitwerk may not have autoloaded it yet when types.rb is required
133
+ def self.const_missing(name)
134
+ if name == :ActiveStoragePlugin
135
+ require 'forest_admin_rails/plugins/active_storage'
136
+ const_set(:ActiveStoragePlugin, ForestAdminRails::Plugins::ActiveStorage)
137
+ else
138
+ super
139
+ end
140
+ end
141
+
142
+ # ============================================
143
+ # Additional Context Classes
144
+ # ============================================
145
+
146
+ # ChartContext - execution context for chart handlers
147
+ # Methods: get_record(fields), record_id, composite_record_id
148
+ ChartContext = ForestAdminDatasourceCustomizer::Decorators::Chart::ChartContext
149
+
150
+ # HookContext - execution context for hook handlers
151
+ # Methods: raise_validation_error, raise_forbidden_error, raise_error
152
+ HookContext = ForestAdminDatasourceCustomizer::Decorators::Hook::Context::HookContext
153
+
154
+ # ============================================
155
+ # Exception Classes
156
+ # ============================================
157
+
158
+ # BusinessError - base class for all business errors
159
+ BusinessError = ForestAdminAgent::Http::Exceptions::BusinessError
160
+
161
+ # Specific error types for better error handling
162
+ ValidationError = ForestAdminAgent::Http::Exceptions::ValidationError
163
+ ForbiddenError = ForestAdminAgent::Http::Exceptions::ForbiddenError
164
+ UnauthorizedError = ForestAdminAgent::Http::Exceptions::UnauthorizedError
165
+ NotFoundError = ForestAdminAgent::Http::Exceptions::NotFoundError
166
+ UnprocessableError = ForestAdminAgent::Http::Exceptions::UnprocessableError
167
+ BadRequestError = ForestAdminAgent::Http::Exceptions::BadRequestError
168
+
169
+ # ============================================
170
+ # Factory Classes
171
+ # ============================================
172
+
173
+ # ConditionTreeFactory - factory methods for creating condition trees
174
+ # Methods: from_plain_object, match_ids, match_records, intersect, union, match_none
175
+ ConditionTreeFactory = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::ConditionTreeFactory
176
+
177
+ # FilterFactory - factory methods for creating filters
178
+ # Methods: from_plain_object, make_foreign_filter, make_through_filter
179
+ FilterFactory = ForestAdminDatasourceToolkit::Components::Query::FilterFactory
180
+
181
+ # ProjectionFactory - factory methods for creating projections
182
+ # Methods: all(collection)
183
+ ProjectionFactory = ForestAdminDatasourceToolkit::Components::Query::ProjectionFactory
184
+
185
+ # SortFactory - factory methods for creating sort clauses
186
+ # Methods: by_primary_keys(collection)
187
+ SortFactory = ForestAdminDatasourceToolkit::Components::Query::SortUtils::SortFactory
188
+
189
+ # ============================================
190
+ # Module inclusion support
191
+ # ============================================
192
+
193
+ def self.included(base)
194
+ # When included, define constants on the including class/module
195
+ # This allows using `include ForestAdmin::Types` and then referencing
196
+ # the types directly without the ForestAdmin::Types prefix
197
+ constants.each do |const_name|
198
+ base.const_set(const_name, const_get(const_name)) unless base.const_defined?(const_name)
199
+ end
200
+ end
201
+ end
202
+ end
@@ -22,35 +22,109 @@ module ForestAdminRails
22
22
  class Engine < ::Rails::Engine
23
23
  isolate_namespace ForestAdminRails
24
24
 
25
+ extend ActiveSupport::Concern
26
+
27
+ initializer 'forest_admin_rails.error_subscribe' do
28
+ Rails.error.subscribe(ForestAdminErrorSubscriber.new)
29
+ end
30
+
25
31
  config.after_initialize do
26
- agent_factory = ForestAdminAgent::Builder::AgentFactory.instance
27
- agent_factory.setup(ForestAdminRails.config)
28
- load_configuration
29
- load_cors
32
+ Rails.error.handle(ForestAdminDatasourceToolkit::Exceptions::ForestException, severity: :error) do
33
+ agent_factory = ForestAdminAgent::Builder::AgentFactory.instance
34
+ agent_factory.setup(ForestAdminRails.config)
35
+ load_configuration
36
+ load_cors
37
+ load_correlation_id
38
+ end
39
+ end
40
+
41
+ # Echo the agent-generated correlation id on every response (mirrors the Node agent's
42
+ # router.use(correlationIdMiddleware)); CORS exposure of the header is handled in load_cors.
43
+ #
44
+ # Ahead of ShowExceptions rather than at the end of the stack: a Rack middleware can only add a header
45
+ # to a response it sees returned, so sitting under the exception handlers meant error responses they
46
+ # build on the way out never carried the id.
47
+ def load_correlation_id
48
+ middleware = ForestAdminAgent::Http::CorrelationIdMiddleware
49
+
50
+ begin
51
+ # The application's stack, not the engine's: ShowExceptions lives there, and an
52
+ # insert_before recorded on the engine's own (empty) proxy raises when it is applied.
53
+ Rails.application.config.middleware.insert_before ActionDispatch::ShowExceptions, middleware
54
+ rescue StandardError
55
+ # No ShowExceptions in this stack (or already gone): the id still reaches every response the app
56
+ # returns normally.
57
+ config.middleware.use middleware
58
+ end
30
59
  end
31
60
 
32
61
  def load_configuration
33
- return unless File.exist?(Rails.root.join('lib', 'forest_admin_rails', 'create_agent.rb'))
62
+ return unless running_web_server?
63
+ return unless create_agent_file_exists?
34
64
 
35
65
  # force eager loading models
36
66
  Rails.application.eager_load!
37
67
 
38
- # setup agent
39
- require Rails.root.join('lib', 'forest_admin_rails', 'create_agent.rb')
40
- ForestAdminRails::CreateAgent.setup!
68
+ require create_agent_path.to_s
69
+
70
+ setup_agent_and_cache_routes
41
71
 
42
72
  sse = ForestAdminAgent::Services::SSECacheInvalidation
43
73
  sse.run if ForestAdminRails.config[:instant_cache_refresh]
44
74
  end
45
75
 
76
+ def create_agent_path
77
+ Rails.root.join('lib', 'forest_admin_rails', 'create_agent.rb')
78
+ end
79
+
80
+ def create_agent_file_exists?
81
+ File.exist?(create_agent_path)
82
+ end
83
+
84
+ def setup_agent_and_cache_routes
85
+ ForestAdminRails::CreateAgent.setup!
86
+ precache_routes
87
+ rescue StandardError => e
88
+ logger = ActiveSupport::Logger.new($stdout)
89
+ logger.warn 'An error has occurred during setup of the Forest Admin agent.'
90
+ raise e.message
91
+ end
92
+
93
+ def precache_routes
94
+ return if ForestAdminAgent::Http::Router.cache_disabled?
95
+
96
+ start_time = Time.now
97
+ routes = ForestAdminAgent::Http::Router.cached_routes
98
+ elapsed = ((Time.now - start_time) * 1000).round(2)
99
+
100
+ logger = ActiveSupport::Logger.new($stdout)
101
+ logger.info("[ForestAdmin] Successfully pre-cached #{routes.size} routes in #{elapsed}ms")
102
+ end
103
+
104
+ def running_web_server?
105
+ return true if defined?(::Rails::Server)
106
+
107
+ # check if a web server is running with the given command line arguments
108
+ server_commands = %w[puma unicorn thin passenger rackup]
109
+ return true if server_commands.any? { |cmd| ARGV.any? { |arg| arg.include?(cmd) } }
110
+
111
+ # check if running via common server executables
112
+ return true if $PROGRAM_NAME.match?(/puma|unicorn|thin|passenger/)
113
+
114
+ false
115
+ end
116
+
46
117
  def load_cors
118
+ return if ENV['FOREST_CORS_DEACTIVATED']
119
+
47
120
  config.middleware.insert_before 0, Rack::Cors do
48
121
  allow do
49
122
  hostnames = [/\A.*\.forestadmin\.com\z/]
50
123
  hostnames += ENV['CORS_ORIGINS'].split(',') if ENV['CORS_ORIGINS']
51
124
 
52
125
  origins hostnames
53
- resource '*', headers: :any, methods: :any, credentials: true, max_age: 86_400
126
+ resource '*', headers: :any, methods: :any, credentials: true, max_age: 86_400,
127
+ expose: [ForestAdminAgent::Http::CorrelationId::HEADER]
54
128
  end
55
129
  end
56
130
  end
@@ -0,0 +1,130 @@
1
+ require 'base64'
2
+ require 'cgi'
3
+ require 'rack/mime'
4
+
5
+ module ForestAdminRails
6
+ module Plugins
7
+ class ActiveStorage < ForestAdminDatasourceCustomizer::Plugins::Plugin
8
+ ACTIVE_STORAGE_COLLECTIONS = %w[
9
+ ActiveStorage__Attachment
10
+ ActiveStorage__Blob
11
+ ActiveStorage__VariantRecord
12
+ ].freeze
13
+
14
+ def run(datasource_customizer, _collection_customizer = nil, options = {})
15
+ datasource_customizer.collections.each do |name, collection|
16
+ next if options[:only] && !options[:only].include?(name)
17
+ next if options[:except]&.include?(name)
18
+
19
+ add_attachment_fields(collection, options)
20
+ end
21
+
22
+ remove_active_storage_collections(datasource_customizer) if options.fetch(:hide_internal_collections, true)
23
+ end
24
+
25
+ private
26
+
27
+ def remove_active_storage_collections(datasource_customizer)
28
+ ACTIVE_STORAGE_COLLECTIONS.each do |collection_name|
29
+ next unless datasource_customizer.collections.key?(collection_name)
30
+
31
+ datasource_customizer.remove_collection(collection_name)
32
+ end
33
+ end
34
+
35
+ def add_attachment_fields(collection, options)
36
+ model_class = find_model_class(collection)
37
+ return unless model_class
38
+ return unless defined?(::ActiveStorage) && model_class.respond_to?(:reflect_on_all_attachments)
39
+
40
+ model_class.reflect_on_all_attachments
41
+ .select { |a| a.macro == :has_one_attached }
42
+ .each do |attachment|
43
+ name = attachment.name.to_s
44
+ hide_attachment_relations(collection, name)
45
+ add_file_field(collection, model_class, name, options.fetch(:download_images_on_list, false))
46
+ end
47
+ end
48
+
49
+ def hide_attachment_relations(collection, attachment_name)
50
+ %W[#{attachment_name}_attachment #{attachment_name}_blob].each do |relation_name|
51
+ next unless collection.schema[:fields].key?(relation_name)
52
+
53
+ collection.remove_field(relation_name)
54
+ end
55
+ end
56
+
57
+ def add_file_field(collection, model_class, attachment_name, download_images_on_list)
58
+ return if collection.schema[:fields][attachment_name]
59
+
60
+ collection.add_field(attachment_name,
61
+ ForestAdminDatasourceCustomizer::Decorators::Computed::ComputedDefinition.new(
62
+ column_type: 'File',
63
+ dependencies: ['id'],
64
+ values: lambda { |records, _ctx|
65
+ compute_values(records, model_class, attachment_name, download_images_on_list)
66
+ }
67
+ ))
68
+
69
+ collection.replace_field_writing(attachment_name) do |value, context|
70
+ handle_write(value, context, model_class, attachment_name)
71
+ end
72
+ end
73
+
74
+ def compute_values(records, model_class, attachment_name, download_images_on_list)
75
+ ids = records.map { |r| r['id'] }
76
+ models = model_class
77
+ .where(id: ids)
78
+ .includes(:"#{attachment_name}_attachment", :"#{attachment_name}_blob")
79
+ .index_by(&:id)
80
+
81
+ records.map do |record|
82
+ model = models[record['id']]
83
+ next nil unless model&.public_send(attachment_name)&.attached?
84
+
85
+ blob = model.public_send(attachment_name).blob
86
+
87
+ if records.length == 1 || (download_images_on_list && blob.content_type&.start_with?('image/'))
88
+ content = blob.download
89
+ "data:#{blob.content_type};name=#{CGI.escape(blob.filename.to_s)};base64,#{Base64.strict_encode64(content)}"
90
+ else
91
+ "data:#{blob.content_type};name=#{CGI.escape(blob.filename.to_s)};base64,"
92
+ end
93
+ end
94
+ end
95
+
96
+ def handle_write(value, context, model_class, attachment_name)
97
+ record_id = context.filter&.condition_tree&.value
98
+ return {} unless record_id
99
+
100
+ record = model_class.find(record_id)
101
+
102
+ if value.nil? || value.to_s.strip.empty?
103
+ record.public_send(attachment_name).purge if record.public_send(attachment_name).attached?
104
+ else
105
+ parsed = ForestAdminAgent::Utils::Schema::ForestValueConverter.parse_data_uri(value)
106
+ if parsed
107
+ fallback_extension = Rack::Mime::MIME_TYPES.invert[parsed['mime_type']] || '.bin'
108
+ record.public_send(attachment_name).attach(
109
+ io: StringIO.new(parsed['buffer']),
110
+ filename: parsed['name'] || "#{attachment_name}#{fallback_extension}",
111
+ content_type: parsed['mime_type']
112
+ )
113
+ end
114
+ end
115
+ {}
116
+ end
117
+
118
+ def find_model_class(collection)
119
+ current = collection.respond_to?(:collection) ? collection.collection : collection
120
+ loop do
121
+ return current.model if current.respond_to?(:model) && current.model.is_a?(Class)
122
+ break unless current.respond_to?(:child_collection)
123
+
124
+ current = current.child_collection
125
+ end
126
+ nil
127
+ end
128
+ end
129
+ end
130
+ end
@@ -1,3 +1,3 @@
1
1
  module ForestAdminRails
2
- VERSION = "1.0.0-beta.24"
2
+ VERSION = "1.44.4"
3
3
  end
@@ -4,9 +4,11 @@ require 'forest_admin_rails/engine'
4
4
  require 'zeitwerk'
5
5
  require 'rails/railtie'
6
6
  require 'forest_admin_agent'
7
+ require_relative 'forest_admin/types'
7
8
 
8
9
  loader = Zeitwerk::Loader.for_gem
9
10
  loader.ignore("#{__dir__}/generators")
11
+ loader.ignore("#{__dir__}/forest_admin")
10
12
  loader.setup
11
13
 
12
14
  module ForestAdminRails
@@ -15,16 +17,29 @@ module ForestAdminRails
15
17
  setting :debug, default: true
16
18
  setting :auth_secret
17
19
  setting :env_secret
18
- setting :forest_server_url, default: 'https://api.forestadmin.com'
20
+ setting :forest_server_url, default: ENV.fetch('FOREST_SERVER_URL', 'https://api.forestadmin.com')
19
21
  setting :is_production, default: Rails.env.production?
20
- setting :prefix, default: 'forest'
22
+ setting :prefix, default: nil
21
23
  setting :permission_expiration, default: 900
22
24
  setting :cache_dir, default: :'tmp/cache/forest_admin'
23
25
  setting :schema_path, default: File.join(Dir.pwd, '.forestadmin-schema.json')
24
26
  setting :project_dir, default: Dir.pwd
25
- setting :loggerLevel, default: 'info'
27
+ setting :logger_level, default: 'info'
26
28
  setting :logger, default: nil
29
+ setting :customize_error_message, default: nil
27
30
  setting :instant_cache_refresh, default: Rails.env.production?
31
+ setting :limit_export_size, default: nil
32
+ setting :append_schema_path, default: nil
33
+ setting :skip_schema_update, default: false
34
+ setting :skip_relation_read_permissions, default: false
35
+ setting :disable_route_cache, default: false
36
+ setting :rpc_max_polling_threads, default: nil
37
+ setting :workflow_executor_url, default: nil
38
+ # { database: <ActiveRecord config or URL>, schema:, table_name:, redact: } — setting `database`
39
+ # turns the audit trail on: every change is captured and the `/_audit-trail` routes are registered.
40
+ setting :audit_trail, default: nil
41
+ # Max records a "select all" approval may target; clamped to the Forest server's cap (500).
42
+ setting :max_records_for_approval, default: 500
28
43
 
29
44
  if defined?(Rails::Railtie)
30
45
  # logic for cors middleware,... here // or it might be into Engine
@@ -9,7 +9,7 @@ module ForestAdminRails
9
9
  @env_secret = env_secret
10
10
  template 'initializers/config.rb', 'config/initializers/forest_admin_rails.rb'
11
11
  template 'create_agent.rb', 'lib/forest_admin_rails/create_agent.rb'
12
- route "mount ForestAdminRails::Engine => '/#{ForestAdminRails.config[:prefix]}'"
12
+ route 'mount ForestAdminRails::Engine => "#{ForestAdminRails.config[:prefix]}"'
13
13
  end
14
14
  end
15
15
  end
@@ -1,4 +1,43 @@
1
- # desc "Explaining what the task does"
2
- # task :forest_admin_rails do
3
- # # Task goes here
4
- # end
1
+ namespace :forest_admin do
2
+ namespace :schema do
3
+ desc 'Generate the Forest Admin schema file without starting the server or sending it to the API'
4
+ task generate: :environment do
5
+ require 'forest_admin_agent'
6
+
7
+ debug_mode = ENV['debug'] == 'true'
8
+
9
+ puts '[ForestAdmin] Starting schema generation...'
10
+
11
+ # Force eager loading of all models
12
+ Rails.application.eager_load!
13
+
14
+ # Check if create_agent.rb exists
15
+ create_agent_path = Rails.root.join('lib', 'forest_admin_rails', 'create_agent.rb')
16
+ unless File.exist?(create_agent_path)
17
+ puts '[ForestAdmin] Error: create_agent.rb not found at lib/forest_admin_rails/create_agent.rb'
18
+ puts '[ForestAdmin] Run `rails generate forest_admin_rails <ENV_SECRET>` to create it.'
19
+ exit 1
20
+ end
21
+
22
+ # Setup the agent factory
23
+ agent_factory = ForestAdminAgent::Builder::AgentFactory.instance
24
+ agent_factory.setup(ForestAdminRails.config)
25
+
26
+ # Enable schema-only mode (generates schema without sending to API)
27
+ agent_factory.schema_only_mode = true
28
+
29
+ # Load and execute the create_agent.rb file
30
+ require create_agent_path
31
+
32
+ # Call setup! which will generate the schema
33
+ begin
34
+ ForestAdminRails::CreateAgent.setup!
35
+ puts '[ForestAdmin] Schema generation completed!'
36
+ rescue StandardError => e
37
+ puts "[ForestAdmin] Error during schema generation: #{e.message}"
38
+ puts e.backtrace.first(10).join("\n") if debug_mode
39
+ exit 1
40
+ end
41
+ end
42
+ end
43
+ end
metadata CHANGED
@@ -1,16 +1,86 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_admin_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.pre.beta.24
4
+ version: 1.44.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu
8
8
  - Nicolas
9
- autorequire:
9
+ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2023-12-18 00:00:00.000000000 Z
12
+ date: 2026-10-01 00:00:00.000000000 Z
13
13
  dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: base64
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: benchmark
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '0'
35
+ type: :runtime
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ - !ruby/object:Gem::Dependency
43
+ name: bigdecimal
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ type: :runtime
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ - !ruby/object:Gem::Dependency
57
+ name: cgi
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ - !ruby/object:Gem::Dependency
71
+ name: csv
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ type: :runtime
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
14
84
  - !ruby/object:Gem::Dependency
15
85
  name: dry-configurable
16
86
  requirement: !ruby/object:Gem::Requirement
@@ -25,6 +95,48 @@ dependencies:
25
95
  - - "~>"
26
96
  - !ruby/object:Gem::Version
27
97
  version: '1.1'
98
+ - !ruby/object:Gem::Dependency
99
+ name: logger
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ type: :runtime
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ - !ruby/object:Gem::Dependency
113
+ name: mutex_m
114
+ requirement: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ type: :runtime
120
+ prerelease: false
121
+ version_requirements: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ - !ruby/object:Gem::Dependency
127
+ name: ostruct
128
+ requirement: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ type: :runtime
134
+ prerelease: false
135
+ version_requirements: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
28
140
  - !ruby/object:Gem::Dependency
29
141
  name: rails
30
142
  requirement: !ruby/object:Gem::Requirement
@@ -64,13 +176,16 @@ extensions: []
64
176
  extra_rdoc_files: []
65
177
  files:
66
178
  - LICENSE
67
- - README.md
68
179
  - Rakefile
69
180
  - app/controllers/forest_admin_rails/forest_controller.rb
70
181
  - app/helpers/forest_admin_rails/application_helper.rb
182
+ - config/initializers/forest_admin_error_subscriber.rb
183
+ - config/initializers/forest_admin_route_cache.rb
71
184
  - config/routes.rb
185
+ - lib/forest_admin/types.rb
72
186
  - lib/forest_admin_rails.rb
73
187
  - lib/forest_admin_rails/engine.rb
188
+ - lib/forest_admin_rails/plugins/active_storage.rb
74
189
  - lib/forest_admin_rails/version.rb
75
190
  - lib/generators/forest_admin_rails/install_generator.rb
76
191
  - lib/generators/forest_admin_rails/templates/create_agent.rb
@@ -78,13 +193,13 @@ files:
78
193
  - lib/tasks/forest_admin_rails_tasks.rake
79
194
  homepage: https://www.forestadmin.com
80
195
  licenses:
81
- - MIT
196
+ - GPL-3.0
82
197
  metadata:
83
198
  homepage_uri: https://www.forestadmin.com
84
- source_code_uri: https://github.com/ForestAdmin/rails-forest_admin
85
- changelog_uri: https://github.com/ForestAdmin/rails-forest_admin/CHANGELOG.md
199
+ source_code_uri: https://github.com/ForestAdmin/agent-ruby
200
+ changelog_uri: https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md
86
201
  rubygems_mfa_required: 'false'
87
- post_install_message:
202
+ post_install_message:
88
203
  rdoc_options: []
89
204
  require_paths:
90
205
  - lib
@@ -95,12 +210,12 @@ required_ruby_version: !ruby/object:Gem::Requirement
95
210
  version: 3.0.0
96
211
  required_rubygems_version: !ruby/object:Gem::Requirement
97
212
  requirements:
98
- - - ">"
213
+ - - ">="
99
214
  - !ruby/object:Gem::Version
100
- version: 1.3.1
215
+ version: '0'
101
216
  requirements: []
102
- rubygems_version: 3.3.5
103
- signing_key:
217
+ rubygems_version: 3.4.20
218
+ signing_key:
104
219
  specification_version: 4
105
220
  summary: Official Rails Agent for Forest Admin.
106
221
  test_files: []
data/README.md DELETED
@@ -1,28 +0,0 @@
1
- # ForestAdmin
2
- Short description and motivation.
3
-
4
- ## Usage
5
- How to use my plugin.
6
-
7
- ## Installation
8
- Add this line to your application's Gemfile:
9
-
10
- ```ruby
11
- gem "forest_admin"
12
- ```
13
-
14
- And then execute:
15
- ```bash
16
- $ bundle
17
- ```
18
-
19
- Or install it yourself as:
20
- ```bash
21
- $ gem install forest_admin
22
- ```
23
-
24
- ## Contributing
25
- Contribution directions go here.
26
-
27
- ## License
28
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).