forest_admin_rpc_agent 1.16.10 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6408ab0ded814ec3a63a5053aa6ca40eb69e9cfa9ce86791e1aabda1f510326d
4
- data.tar.gz: 96290792e468c9bbf1d47492297981b354ff3dc0a7dc7a177092b8b4cd8c9ce4
3
+ metadata.gz: 9aacf1c4f41b79f86b3b7f3a00caa96f546cc67d38031f7f550e3cb5ae4ec232
4
+ data.tar.gz: bf9a970a7322632045f32487767cd0e50c8b1087c2dc702ddd01cad900f5b7bc
5
5
  SHA512:
6
- metadata.gz: a25d113f6e9c719f9ba52873ac718c437856be8fdf1d35358667033ee7e80103a82353886c0c5efbf458abba2ece3ce913e2e930c350c84e0e7f74055751f8ed
7
- data.tar.gz: 25ab7a181d0f2a3b3b585d5c7ae0f078d7c02c25f1cbe64c4d6e9e0914b169c7e633f7576d299a958f67b6dcfac47e7d2f4826ea531be2b66fd757e8fb77ee9d
6
+ metadata.gz: c8c65145002a3c4adea8332210a86e257de9a16bcc7bfecfc8ef7bab6d94cc1dafc9adddc7ea378854d1d55a5db1f2716a0110253e02e87aadfdc42b52231f3c
7
+ data.tar.gz: b148bfcf994516aee7a7d72bfdaac13cdab37a222fb6181798a162589e390819563eedfd8eca4ca1e01832882c1043058d805e0478a1e0872f27fbcfbe767f97
@@ -1,19 +1,260 @@
1
+ require 'digest'
2
+ require 'fileutils'
3
+
1
4
  module ForestAdminRpcAgent
2
- class Agent < ForestAdminAgent::Builder::AgentFactory
3
- attr_reader :rpc_collections
5
+ class Agent < ForestAdminAgent::Builder::AgentFactory # rubocop:disable Metrics/ClassLength
6
+ include ForestAdminAgent::Http::Exceptions
7
+
8
+ attr_reader :rpc_collections, :cached_schema, :cached_schema_hash
4
9
 
5
10
  def setup(options)
6
11
  super
7
12
  @rpc_collections = []
13
+ @cached_schema = nil
14
+ @cached_schema_hash = nil
8
15
  end
9
16
 
10
- def send_schema(_force: nil)
11
- ForestAdminRpcAgent::Facades::Container.logger.log('Info', 'Started as RPC agent, schema not sent.')
17
+ def send_schema(force: false)
18
+ if should_skip_schema_update? && !force
19
+ log_schema_skip
20
+ load_and_cache_schema
21
+ return
22
+ end
23
+
24
+ schema_path = ForestAdminRpcAgent::Facades::Container.cache(:schema_path)
25
+
26
+ if ForestAdminRpcAgent::Facades::Container.cache(:is_production)
27
+ unless schema_path && File.exist?(schema_path)
28
+ raise InternalServerError.new(
29
+ 'Schema file not found in production',
30
+ details: { schema_path: schema_path }
31
+ )
32
+ end
33
+
34
+ load_and_cache_schema_from_file(schema_path)
35
+
36
+ ForestAdminRpcAgent::Facades::Container.logger.log(
37
+ 'Info',
38
+ 'RPC agent running in production mode, using existing schema file.'
39
+ )
40
+ else
41
+ generate_and_cache_schema(schema_path)
42
+
43
+ ForestAdminRpcAgent::Facades::Container.logger.log(
44
+ 'Info',
45
+ "RPC agent schema generated and saved to #{schema_path}"
46
+ )
47
+ end
48
+
49
+ ForestAdminRpcAgent::Facades::Container.logger.log(
50
+ 'Info',
51
+ 'RPC agent does not send schema to Forest Admin servers.'
52
+ )
12
53
  end
13
54
 
14
55
  def mark_collections_as_rpc(*names)
15
56
  @rpc_collections.push(*names)
16
57
  self
17
58
  end
59
+
60
+ # Returns the cached schema for the /rpc-schema route
61
+ # Falls back to building schema from datasource if not cached
62
+ def rpc_schema
63
+ return @cached_schema if @cached_schema
64
+
65
+ build_and_cache_schema_from_datasource
66
+ @cached_schema
67
+ end
68
+
69
+ # Check if provided hash matches the cached schema hash
70
+ def schema_hash_matches?(provided_hash)
71
+ return false unless @cached_schema_hash && provided_hash
72
+
73
+ @cached_schema_hash == provided_hash
74
+ end
75
+
76
+ private
77
+
78
+ def should_skip_schema_update?
79
+ ForestAdminRpcAgent::Facades::Container.cache(:skip_schema_update) == true
80
+ end
81
+
82
+ def log_schema_skip
83
+ logger = ForestAdminRpcAgent::Facades::Container.logger
84
+ logger.log('Warn', '[ForestAdmin] Schema update skipped (skip_schema_update flag is true)')
85
+ environment = ForestAdminRpcAgent::Facades::Container.cache(:is_production) ? 'production' : 'development'
86
+ logger.log('Info', "[ForestAdmin] RPC agent running in #{environment} mode")
87
+ end
88
+
89
+ def load_and_cache_schema
90
+ schema_path = ForestAdminRpcAgent::Facades::Container.cache(:schema_path)
91
+
92
+ if ForestAdminRpcAgent::Facades::Container.cache(:is_production) && schema_path && File.exist?(schema_path)
93
+ load_and_cache_schema_from_file(schema_path)
94
+ else
95
+ # In development with skip_schema_update, still build from datasource
96
+ build_and_cache_schema_from_datasource
97
+ end
98
+ end
99
+
100
+ def load_and_cache_schema_from_file(_schema_path)
101
+ # File exists but RPC schema needs internal format - build from datasource
102
+ # The file is kept for reference/frontend but RPC always uses internal format
103
+ datasource = @container.resolve(:datasource)
104
+ @cached_schema = build_rpc_schema_from_datasource(datasource)
105
+ compute_and_cache_hash
106
+ end
107
+
108
+ def generate_and_cache_schema(schema_path)
109
+ datasource = @container.resolve(:datasource)
110
+
111
+ # Generate frontend schema for file (used by Forest Admin)
112
+ generated = ForestAdminAgent::Utils::Schema::SchemaEmitter.generate(datasource)
113
+ meta = ForestAdminAgent::Utils::Schema::SchemaEmitter.meta
114
+
115
+ schema = {
116
+ meta: meta,
117
+ collections: generated
118
+ }
119
+
120
+ FileUtils.mkdir_p(File.dirname(schema_path))
121
+ File.write(schema_path, format_schema_json(schema))
122
+
123
+ # Build RPC schema in internal format (used by master agent)
124
+ @cached_schema = build_rpc_schema_from_datasource(datasource)
125
+ compute_and_cache_hash
126
+ end
127
+
128
+ def build_and_cache_schema_from_datasource
129
+ datasource = @container.resolve(:datasource)
130
+
131
+ @cached_schema = build_rpc_schema_from_datasource(datasource)
132
+ compute_and_cache_hash
133
+ end
134
+
135
+ def build_rpc_schema_from_datasource(datasource)
136
+ schema = customizer.schema
137
+
138
+ # Serialize collections with internal schema format (fields as hash with :type keys)
139
+ collections = datasource.collections.map { |_name, collection| serialize_collection_for_rpc(collection) }
140
+ schema[:collections] = collections.sort_by { |c| c[:name] }
141
+
142
+ connections = datasource.live_query_connections.keys.map { |connection_name| { name: connection_name } }
143
+ schema[:native_query_connections] = connections
144
+
145
+ schema
146
+ end
147
+
148
+ def serialize_collection_for_rpc(collection)
149
+ {
150
+ name: collection.name,
151
+ countable: collection.schema[:countable],
152
+ searchable: collection.schema[:searchable],
153
+ segments: collection.schema[:segments] || [],
154
+ charts: collection.schema[:charts] || [],
155
+ actions: serialize_actions_for_rpc(collection.schema[:actions] || {}),
156
+ fields: serialize_fields_for_rpc(collection.schema[:fields] || {})
157
+ }
158
+ end
159
+
160
+ def serialize_fields_for_rpc(fields)
161
+ fields.transform_values do |field_schema|
162
+ serialize_field_schema(field_schema)
163
+ end
164
+ end
165
+
166
+ def serialize_field_schema(field_schema)
167
+ case field_schema
168
+ when ForestAdminDatasourceToolkit::Schema::ColumnSchema
169
+ {
170
+ type: 'Column',
171
+ column_type: field_schema.column_type,
172
+ filter_operators: field_schema.filter_operators,
173
+ is_primary_key: field_schema.is_primary_key,
174
+ is_read_only: field_schema.is_read_only,
175
+ is_sortable: field_schema.is_sortable,
176
+ default_value: field_schema.default_value,
177
+ enum_values: field_schema.enum_values,
178
+ validation: field_schema.validation
179
+ }
180
+ when ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema
181
+ {
182
+ type: 'ManyToOne',
183
+ foreign_collection: field_schema.foreign_collection,
184
+ foreign_key: field_schema.foreign_key,
185
+ foreign_key_target: field_schema.foreign_key_target
186
+ }
187
+ when ForestAdminDatasourceToolkit::Schema::Relations::OneToOneSchema
188
+ {
189
+ type: 'OneToOne',
190
+ foreign_collection: field_schema.foreign_collection,
191
+ origin_key: field_schema.origin_key,
192
+ origin_key_target: field_schema.origin_key_target
193
+ }
194
+ when ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema
195
+ {
196
+ type: 'OneToMany',
197
+ foreign_collection: field_schema.foreign_collection,
198
+ origin_key: field_schema.origin_key,
199
+ origin_key_target: field_schema.origin_key_target
200
+ }
201
+ when ForestAdminDatasourceToolkit::Schema::Relations::ManyToManySchema
202
+ {
203
+ type: 'ManyToMany',
204
+ foreign_collection: field_schema.foreign_collection,
205
+ foreign_key: field_schema.foreign_key,
206
+ foreign_key_target: field_schema.foreign_key_target,
207
+ origin_key: field_schema.origin_key,
208
+ origin_key_target: field_schema.origin_key_target,
209
+ through_collection: field_schema.through_collection
210
+ }
211
+ when ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicManyToOneSchema
212
+ {
213
+ type: 'PolymorphicManyToOne',
214
+ foreign_collections: field_schema.foreign_collections,
215
+ foreign_key: field_schema.foreign_key,
216
+ foreign_key_type_field: field_schema.foreign_key_type_field,
217
+ foreign_key_targets: field_schema.foreign_key_targets
218
+ }
219
+ when ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicOneToOneSchema
220
+ {
221
+ type: 'PolymorphicOneToOne',
222
+ foreign_collection: field_schema.foreign_collection,
223
+ origin_key: field_schema.origin_key,
224
+ origin_key_target: field_schema.origin_key_target,
225
+ origin_type_field: field_schema.origin_type_field,
226
+ origin_type_value: field_schema.origin_type_value
227
+ }
228
+ when ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicOneToManySchema
229
+ {
230
+ type: 'PolymorphicOneToMany',
231
+ foreign_collection: field_schema.foreign_collection,
232
+ origin_key: field_schema.origin_key,
233
+ origin_key_target: field_schema.origin_key_target,
234
+ origin_type_field: field_schema.origin_type_field,
235
+ origin_type_value: field_schema.origin_type_value
236
+ }
237
+ else
238
+ # Fallback: try to convert to hash if possible
239
+ field_schema.respond_to?(:to_h) ? field_schema.to_h : field_schema
240
+ end
241
+ end
242
+
243
+ def serialize_actions_for_rpc(actions)
244
+ actions.transform_values do |action|
245
+ action.respond_to?(:to_h) ? action.to_h : action
246
+ end
247
+ end
248
+
249
+ def compute_and_cache_hash
250
+ return unless @cached_schema
251
+
252
+ @cached_schema_hash = Digest::SHA1.hexdigest(@cached_schema.to_json)
253
+
254
+ ForestAdminRpcAgent::Facades::Container.logger.log(
255
+ 'Debug',
256
+ "RPC agent schema hash computed: #{@cached_schema_hash}"
257
+ )
258
+ end
18
259
  end
19
260
  end
@@ -20,10 +20,12 @@ module ForestAdminRpcAgent
20
20
 
21
21
  def register_sinatra(app)
22
22
  app.send(@method.to_sym, @url) do
23
- result = handle_request(params)
23
+ result = handle_request({ params: params, request: request })
24
24
 
25
25
  if result.is_a?(Hash) && result.key?(:status)
26
26
  status result[:status]
27
+ # Set custom headers if provided
28
+ result[:headers]&.each { |key, value| headers[key] = value }
27
29
  result[:content] ? serialize_response(result[:content]) : ''
28
30
  else
29
31
  serialize_response(result)
@@ -38,16 +40,16 @@ module ForestAdminRpcAgent
38
40
  # Skip authentication for health check (root path)
39
41
  if @url == '/'
40
42
  params = request.query_parameters.merge(request.request_parameters)
41
- result = handle_request({ params: params, caller: nil })
42
- [200, { 'Content-Type' => 'application/json' }, [serialize_response(result)]]
43
+ result = handle_request({ params: params, caller: nil, request: request })
44
+ build_rails_response(result)
43
45
  else
44
46
  auth_middleware = ForestAdminRpcAgent::Middleware::Authentication.new(->(_env) { [200, {}, ['OK']] })
45
47
  status, headers, response = auth_middleware.call(request.env)
46
48
 
47
49
  if status == 200
48
50
  params = request.query_parameters.merge(request.request_parameters)
49
- result = handle_request({ params: params, caller: headers[:caller] })
50
- [200, { 'Content-Type' => 'application/json' }, [serialize_response(result)]]
51
+ result = handle_request({ params: params, caller: headers[:caller], request: request })
52
+ build_rails_response(result)
51
53
  else
52
54
  [status, headers, response]
53
55
  end
@@ -62,6 +64,17 @@ module ForestAdminRpcAgent
62
64
  route_alias: @name
63
65
  end
64
66
 
67
+ def build_rails_response(result)
68
+ if result.is_a?(Hash) && result.key?(:status)
69
+ response_headers = { 'Content-Type' => 'application/json' }
70
+ response_headers.merge!(result[:headers]) if result[:headers]
71
+ body = result[:content] ? serialize_response(result[:content]) : ''
72
+ [result[:status], response_headers, [body]]
73
+ else
74
+ [200, { 'Content-Type' => 'application/json' }, [serialize_response(result)]]
75
+ end
76
+ end
77
+
65
78
  protected
66
79
 
67
80
  def get_collection_safe(datasource, collection_name)
@@ -5,23 +5,66 @@ module ForestAdminRpcAgent
5
5
  include ForestAdminAgent::Utils
6
6
  include ForestAdminAgent::Routes::QueryHandler
7
7
 
8
+ HTTP_OK = 200
9
+ HTTP_NOT_MODIFIED = 304
10
+
8
11
  def initialize
9
12
  super('rpc-schema', 'get', 'rpc_schema')
10
13
  end
11
14
 
12
- def handle_request(_params)
15
+ def handle_request(args)
13
16
  agent = ForestAdminRpcAgent::Agent.instance
14
- schema = agent.customizer.schema
15
- datasource = agent.customizer.datasource(ForestAdminRpcAgent::Facades::Container.logger)
17
+ client_etag = extract_if_none_match(args)
18
+
19
+ # If client has cached schema and ETag matches, return 304 Not Modified
20
+ if client_etag && agent.schema_hash_matches?(client_etag)
21
+ ForestAdminRpcAgent::Facades::Container.logger.log(
22
+ 'Debug',
23
+ 'ETag matches, returning 304 Not Modified'
24
+ )
25
+ return { status: HTTP_NOT_MODIFIED, content: nil,
26
+ headers: { 'ETag' => quote_etag(agent.cached_schema_hash) } }
27
+ end
28
+
29
+ # Get schema from cache (or build from datasource if not cached)
30
+ schema = agent.rpc_schema
31
+ etag = agent.cached_schema_hash
32
+
33
+ # Return schema with ETag header
34
+ {
35
+ status: HTTP_OK,
36
+ content: schema,
37
+ headers: { 'ETag' => quote_etag(etag) }
38
+ }
39
+ end
40
+
41
+ private
16
42
 
17
- schema[:collections] = datasource.collections
18
- .map { |_name, collection| collection.schema.merge({ name: collection.name }) }
19
- .sort_by { |collection| collection[:name] }
43
+ def extract_if_none_match(args)
44
+ request = args[:request] if args.is_a?(Hash)
45
+ return nil unless request
46
+
47
+ # Get If-None-Match header (works for both Rails and Sinatra)
48
+ etag = if request.respond_to?(:get_header)
49
+ request.get_header('HTTP_IF_NONE_MATCH')
50
+ elsif request.respond_to?(:env)
51
+ request.env['HTTP_IF_NONE_MATCH']
52
+ end
53
+
54
+ # Strip quotes from ETag value if present
55
+ unquote_etag(etag)
56
+ end
57
+
58
+ def quote_etag(etag)
59
+ return nil unless etag
60
+
61
+ %("#{etag}")
62
+ end
20
63
 
21
- connections = datasource.live_query_connections.keys.map { |connection_name| { name: connection_name } }
22
- schema[:native_query_connections] = connections
64
+ def unquote_etag(etag)
65
+ return nil unless etag
23
66
 
24
- schema
67
+ etag.gsub(/\A"?|"?\z/, '')
25
68
  end
26
69
  end
27
70
  end
@@ -1,3 +1,3 @@
1
1
  module ForestAdminRpcAgent
2
- VERSION = "1.16.10"
2
+ VERSION = "1.17.0"
3
3
  end
@@ -20,6 +20,8 @@ module ForestAdminRpcAgent
20
20
  setting :prefix, default: nil
21
21
  setting :cache_dir, default: :'tmp/cache/forest_admin'
22
22
  setting :project_dir, default: Dir.pwd
23
+ setting :schema_path, default: File.join(Dir.pwd, '.forestadmin-schema.json')
24
+ setting :skip_schema_update, default: false
23
25
  setting :logger_level, default: 'info'
24
26
  setting :logger, default: nil
25
27
  setting :customize_error_message, default: nil
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_admin_rpc_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.16.10
4
+ version: 1.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2025-12-09 00:00:00.000000000 Z
12
+ date: 2025-12-11 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: base64