forest_admin_rpc_agent 1.16.10 → 2.0.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 +4 -4
- data/lib/forest_admin_rpc_agent/agent.rb +245 -4
- data/lib/forest_admin_rpc_agent/routes/base_route.rb +18 -5
- data/lib/forest_admin_rpc_agent/routes/schema.rb +52 -9
- data/lib/forest_admin_rpc_agent/version.rb +1 -1
- data/lib/forest_admin_rpc_agent.rb +2 -0
- metadata +2 -5
- data/lib/forest_admin_rpc_agent/routes/sse.rb +0 -166
- data/lib/forest_admin_rpc_agent/sse_connection_manager.rb +0 -81
- data/lib/forest_admin_rpc_agent/sse_streamer.rb +0 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '06883f603b147464ddf1ad642f47af2c3230b41cd043cf663ad9214ad22f98ec'
|
|
4
|
+
data.tar.gz: 13cdf53f4abc7147fd38d73efa4ee74f7829abbf320ce363ac315f42e8f1e322
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e680dc41c11c375aafe885afee8870b78ee67c81b9853f33c8eb214f1ddd4a60e82c4fe11bbd78f30c894c7288ad66f211c1d354cc7c780fb4c22e5abe4cb48
|
|
7
|
+
data.tar.gz: 02e09ab9ba06a77619dcd42785c3965f578e799014517a85535234c6079e673d4d31b0491fced672764b82fa7186022394b8570415d63f7797a4cc7ee2d9e760
|
|
@@ -1,19 +1,260 @@
|
|
|
1
|
+
require 'digest'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
|
|
1
4
|
module ForestAdminRpcAgent
|
|
2
|
-
class Agent < ForestAdminAgent::Builder::AgentFactory
|
|
3
|
-
|
|
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(
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
15
|
+
def handle_request(args)
|
|
13
16
|
agent = ForestAdminRpcAgent::Agent.instance
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
64
|
+
def unquote_etag(etag)
|
|
65
|
+
return nil unless etag
|
|
23
66
|
|
|
24
|
-
|
|
67
|
+
etag.gsub(/\A"?|"?\z/, '')
|
|
25
68
|
end
|
|
26
69
|
end
|
|
27
70
|
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:
|
|
4
|
+
version: 2.0.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-
|
|
12
|
+
date: 2025-12-11 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: base64
|
|
@@ -176,10 +176,7 @@ files:
|
|
|
176
176
|
- lib/forest_admin_rpc_agent/routes/list.rb
|
|
177
177
|
- lib/forest_admin_rpc_agent/routes/native_query.rb
|
|
178
178
|
- lib/forest_admin_rpc_agent/routes/schema.rb
|
|
179
|
-
- lib/forest_admin_rpc_agent/routes/sse.rb
|
|
180
179
|
- lib/forest_admin_rpc_agent/routes/update.rb
|
|
181
|
-
- lib/forest_admin_rpc_agent/sse_connection_manager.rb
|
|
182
|
-
- lib/forest_admin_rpc_agent/sse_streamer.rb
|
|
183
180
|
- lib/forest_admin_rpc_agent/thor/install.rb
|
|
184
181
|
- lib/forest_admin_rpc_agent/version.rb
|
|
185
182
|
homepage: https://www.forestadmin.com
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
require 'jsonapi-serializers'
|
|
2
|
-
|
|
3
|
-
module ForestAdminRpcAgent
|
|
4
|
-
module Routes
|
|
5
|
-
class Sse
|
|
6
|
-
DEFAULT_HEARTBEAT_INTERVAL = 10
|
|
7
|
-
|
|
8
|
-
def initialize(url = 'sse', method = 'get', name = 'rpc_sse', heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL)
|
|
9
|
-
@url = url
|
|
10
|
-
@method = method
|
|
11
|
-
@name = name
|
|
12
|
-
@heartbeat_interval = heartbeat_interval
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def registered(app)
|
|
16
|
-
if defined?(Sinatra) && (app == Sinatra::Base || app.ancestors.include?(Sinatra::Base))
|
|
17
|
-
register_sinatra(app)
|
|
18
|
-
elsif defined?(Rails) && app.is_a?(ActionDispatch::Routing::Mapper)
|
|
19
|
-
register_rails(app)
|
|
20
|
-
else
|
|
21
|
-
raise NotImplementedError,
|
|
22
|
-
"Unsupported application type: #{app.class}. #{self} works with Sinatra::Base or ActionDispatch::Routing::Mapper."
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def register_sinatra(app)
|
|
27
|
-
route_instance = self
|
|
28
|
-
app.send(@method.to_sym, "/#{@url}") do
|
|
29
|
-
auth_middleware = ForestAdminRpcAgent::Middleware::Authentication.new(->(_env) { [200, {}, ['OK']] })
|
|
30
|
-
status, headers, response = auth_middleware.call(env)
|
|
31
|
-
|
|
32
|
-
halt status, headers, response if status != 200
|
|
33
|
-
|
|
34
|
-
content_type 'text/event-stream'
|
|
35
|
-
headers 'Cache-Control' => 'no-cache',
|
|
36
|
-
'Connection' => 'keep-alive',
|
|
37
|
-
'X-Accel-Buffering' => 'no'
|
|
38
|
-
|
|
39
|
-
stream(:keep_open) do |out|
|
|
40
|
-
# Register this connection; any previous connection will be terminated
|
|
41
|
-
connection = ForestAdminRpcAgent::SseConnectionManager.register_connection
|
|
42
|
-
|
|
43
|
-
server_stopped = false
|
|
44
|
-
received_signal = nil
|
|
45
|
-
stop_proc = proc do |sig|
|
|
46
|
-
connection.terminate
|
|
47
|
-
server_stopped = true
|
|
48
|
-
received_signal = sig
|
|
49
|
-
end
|
|
50
|
-
original_int_handler = trap('INT', stop_proc)
|
|
51
|
-
original_term_handler = trap('TERM', stop_proc)
|
|
52
|
-
|
|
53
|
-
begin
|
|
54
|
-
streamer = SseStreamer.new(out)
|
|
55
|
-
|
|
56
|
-
while connection.active?
|
|
57
|
-
streamer.write('', event: 'heartbeat')
|
|
58
|
-
sleep route_instance.instance_variable_get(:@heartbeat_interval)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# Send RpcServerStop only if server is stopping (not client disconnect)
|
|
62
|
-
if server_stopped
|
|
63
|
-
begin
|
|
64
|
-
streamer.write({ event: 'RpcServerStop' }, event: 'RpcServerStop')
|
|
65
|
-
ForestAdminRpcAgent::Facades::Container.logger&.log('Debug', '[SSE] RpcServerStop event sent')
|
|
66
|
-
rescue StandardError => e
|
|
67
|
-
ForestAdminRpcAgent::Facades::Container.logger&.log('Debug', "[SSE] Error sending stop event: #{e.message}")
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
rescue IOError, Errno::EPIPE => e
|
|
71
|
-
# Client disconnected normally
|
|
72
|
-
ForestAdminRpcAgent::Facades::Container.logger&.log('Debug', "[SSE] Client disconnected: #{e.message}")
|
|
73
|
-
ensure
|
|
74
|
-
trap('INT', original_int_handler)
|
|
75
|
-
trap('TERM', original_term_handler)
|
|
76
|
-
ForestAdminRpcAgent::SseConnectionManager.unregister_connection(connection)
|
|
77
|
-
out.close if out.respond_to?(:close)
|
|
78
|
-
|
|
79
|
-
# Re-send the signal to allow proper server shutdown
|
|
80
|
-
Process.kill(received_signal, Process.pid) if received_signal
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def register_rails(router)
|
|
87
|
-
route_instance = self
|
|
88
|
-
handler = proc do |hash|
|
|
89
|
-
request = ActionDispatch::Request.new(hash)
|
|
90
|
-
auth_middleware = ForestAdminRpcAgent::Middleware::Authentication.new(->(_env) { [200, {}, ['OK']] })
|
|
91
|
-
status, headers, response = auth_middleware.call(request.env)
|
|
92
|
-
|
|
93
|
-
if status == 200
|
|
94
|
-
headers = {
|
|
95
|
-
'Content-Type' => 'text/event-stream',
|
|
96
|
-
'Cache-Control' => 'no-cache',
|
|
97
|
-
'Connection' => 'keep-alive',
|
|
98
|
-
'X-Accel-Buffering' => 'no'
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
# Register this connection; any previous connection will be terminated
|
|
102
|
-
connection = ForestAdminRpcAgent::SseConnectionManager.register_connection
|
|
103
|
-
|
|
104
|
-
server_stopped = false
|
|
105
|
-
received_signal = nil
|
|
106
|
-
stop_proc = proc do |sig|
|
|
107
|
-
connection.terminate
|
|
108
|
-
server_stopped = true
|
|
109
|
-
received_signal = sig
|
|
110
|
-
end
|
|
111
|
-
original_int_handler = trap('INT', stop_proc)
|
|
112
|
-
original_term_handler = trap('TERM', stop_proc)
|
|
113
|
-
|
|
114
|
-
body = Enumerator.new do |yielder|
|
|
115
|
-
stream = SseStreamer.new(yielder)
|
|
116
|
-
|
|
117
|
-
begin
|
|
118
|
-
ForestAdminRpcAgent::Facades::Container.logger&.log('Debug', '[SSE] Starting stream')
|
|
119
|
-
|
|
120
|
-
while connection.active?
|
|
121
|
-
stream.write('', event: 'heartbeat')
|
|
122
|
-
sleep route_instance.instance_variable_get(:@heartbeat_interval)
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
# Send RpcServerStop only if server is stopping (not client disconnect)
|
|
126
|
-
if server_stopped
|
|
127
|
-
begin
|
|
128
|
-
stream.write({ event: 'RpcServerStop' }, event: 'RpcServerStop')
|
|
129
|
-
ForestAdminRpcAgent::Facades::Container.logger&.log('Debug', '[SSE] RpcServerStop event sent')
|
|
130
|
-
rescue StandardError => e
|
|
131
|
-
ForestAdminRpcAgent::Facades::Container.logger&.log('Debug', "[SSE] Error sending stop event: #{e.message}")
|
|
132
|
-
end
|
|
133
|
-
end
|
|
134
|
-
rescue IOError, Errno::EPIPE => e
|
|
135
|
-
# Client disconnected normally
|
|
136
|
-
ForestAdminRpcAgent::Facades::Container.logger&.log('Debug', "[SSE] Client disconnected: #{e.message}")
|
|
137
|
-
rescue StandardError => e
|
|
138
|
-
ForestAdminRpcAgent::Facades::Container.logger&.log('Error', "[SSE] Unexpected error: #{e.message}")
|
|
139
|
-
ForestAdminRpcAgent::Facades::Container.logger&.log('Error', e.backtrace.join("\n"))
|
|
140
|
-
ensure
|
|
141
|
-
trap('INT', original_int_handler)
|
|
142
|
-
trap('TERM', original_term_handler)
|
|
143
|
-
ForestAdminRpcAgent::SseConnectionManager.unregister_connection(connection)
|
|
144
|
-
ForestAdminRpcAgent::Facades::Container.logger&.log('Debug', '[SSE] Stream stopped')
|
|
145
|
-
|
|
146
|
-
# Re-send the signal to allow proper server shutdown
|
|
147
|
-
Process.kill(received_signal, Process.pid) if received_signal
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
[status, headers, body]
|
|
152
|
-
else
|
|
153
|
-
[status, headers, response]
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
router.match @url,
|
|
158
|
-
defaults: { format: 'event-stream' },
|
|
159
|
-
to: handler,
|
|
160
|
-
via: @method,
|
|
161
|
-
as: @name,
|
|
162
|
-
route_alias: @name
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
end
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
module ForestAdminRpcAgent
|
|
2
|
-
# Manages SSE connections to ensure only one active connection at a time.
|
|
3
|
-
# When a new connection is established, the previous one is terminated.
|
|
4
|
-
# This prevents zombie loops when the master restarts and reconnects.
|
|
5
|
-
class SseConnectionManager
|
|
6
|
-
@mutex = Mutex.new
|
|
7
|
-
@current_connection = nil
|
|
8
|
-
|
|
9
|
-
class << self
|
|
10
|
-
# Registers a new SSE connection and terminates any existing one.
|
|
11
|
-
# Returns a connection object that can be used to check if the connection is still active.
|
|
12
|
-
def register_connection
|
|
13
|
-
connection = Connection.new
|
|
14
|
-
|
|
15
|
-
@mutex.synchronize do
|
|
16
|
-
# Terminate the previous connection if it exists
|
|
17
|
-
if @current_connection
|
|
18
|
-
ForestAdminRpcAgent::Facades::Container.logger&.log(
|
|
19
|
-
'Debug',
|
|
20
|
-
'[SSE ConnectionManager] Terminating previous connection'
|
|
21
|
-
)
|
|
22
|
-
@current_connection.terminate
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
@current_connection = connection
|
|
26
|
-
ForestAdminRpcAgent::Facades::Container.logger&.log(
|
|
27
|
-
'Debug',
|
|
28
|
-
"[SSE ConnectionManager] New connection registered (id: #{connection.id})"
|
|
29
|
-
)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
connection
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# Unregisters a connection when it's closed normally.
|
|
36
|
-
def unregister_connection(connection)
|
|
37
|
-
@mutex.synchronize do
|
|
38
|
-
if @current_connection&.id == connection.id
|
|
39
|
-
@current_connection = nil
|
|
40
|
-
ForestAdminRpcAgent::Facades::Container.logger&.log(
|
|
41
|
-
'Debug',
|
|
42
|
-
"[SSE ConnectionManager] Connection unregistered (id: #{connection.id})"
|
|
43
|
-
)
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Returns the current active connection (for testing purposes)
|
|
49
|
-
def current_connection
|
|
50
|
-
@mutex.synchronize { @current_connection }
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# Resets the manager state (for testing purposes)
|
|
54
|
-
def reset!
|
|
55
|
-
@mutex.synchronize do
|
|
56
|
-
@current_connection&.terminate
|
|
57
|
-
@current_connection = nil
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Represents an individual SSE connection
|
|
63
|
-
class Connection
|
|
64
|
-
attr_reader :id
|
|
65
|
-
|
|
66
|
-
def initialize
|
|
67
|
-
@id = SecureRandom.uuid
|
|
68
|
-
@active = true
|
|
69
|
-
@mutex = Mutex.new
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def active?
|
|
73
|
-
@mutex.synchronize { @active }
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def terminate
|
|
77
|
-
@mutex.synchronize { @active = false }
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
end
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
require 'json'
|
|
2
|
-
|
|
3
|
-
module ForestAdminRpcAgent
|
|
4
|
-
class SseStreamer
|
|
5
|
-
def initialize(yielder)
|
|
6
|
-
@yielder = yielder
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
def write(object, event: nil)
|
|
10
|
-
@yielder << "event: #{event}\n" if event
|
|
11
|
-
@yielder << "data: #{JSON.dump(object)}\n\n"
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
end
|