forest_admin_rpc_agent 1.0.1

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 (30) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +674 -0
  3. data/bin/console +11 -0
  4. data/bin/forest_admin_rpc_agent +9 -0
  5. data/bin/setup +8 -0
  6. data/forest_admin_rpc_agent.gemspec +36 -0
  7. data/lib/forest_admin_rpc_agent/agent.rb +19 -0
  8. data/lib/forest_admin_rpc_agent/engine.rb +17 -0
  9. data/lib/forest_admin_rpc_agent/extensions/config_loader.rb +12 -0
  10. data/lib/forest_admin_rpc_agent/extensions/sinatra_extension.rb +32 -0
  11. data/lib/forest_admin_rpc_agent/facades/container.rb +29 -0
  12. data/lib/forest_admin_rpc_agent/middleware/authentication.rb +67 -0
  13. data/lib/forest_admin_rpc_agent/routes/action_execute.rb +31 -0
  14. data/lib/forest_admin_rpc_agent/routes/action_form.rb +49 -0
  15. data/lib/forest_admin_rpc_agent/routes/aggregate.rb +34 -0
  16. data/lib/forest_admin_rpc_agent/routes/base_route.rb +52 -0
  17. data/lib/forest_admin_rpc_agent/routes/chart.rb +30 -0
  18. data/lib/forest_admin_rpc_agent/routes/create.rb +23 -0
  19. data/lib/forest_admin_rpc_agent/routes/datasource_chart.rb +23 -0
  20. data/lib/forest_admin_rpc_agent/routes/delete.rb +28 -0
  21. data/lib/forest_admin_rpc_agent/routes/health_route.rb +13 -0
  22. data/lib/forest_admin_rpc_agent/routes/list.rb +29 -0
  23. data/lib/forest_admin_rpc_agent/routes/schema.rb +24 -0
  24. data/lib/forest_admin_rpc_agent/routes/sse.rb +80 -0
  25. data/lib/forest_admin_rpc_agent/routes/update.rb +26 -0
  26. data/lib/forest_admin_rpc_agent/sse_streamer.rb +14 -0
  27. data/lib/forest_admin_rpc_agent/thor/install.rb +136 -0
  28. data/lib/forest_admin_rpc_agent/version.rb +3 -0
  29. data/lib/forest_admin_rpc_agent.rb +33 -0
  30. metadata +122 -0
data/bin/console ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "forest_admin_rpc_agent"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ require "irb"
11
+ IRB.start(__FILE__)
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+ require 'thor'
5
+
6
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
7
+ require 'forest_admin_rpc_agent/thor/install'
8
+
9
+ ForestAdminRpcAgent::Thor::Install.start(ARGV)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,36 @@
1
+ lib = File.expand_path('lib', __dir__)
2
+ $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib)
3
+
4
+ require_relative "lib/forest_admin_rpc_agent/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "forest_admin_rpc_agent"
8
+ spec.version = ForestAdminRpcAgent::VERSION
9
+ spec.authors = ["Matthieu", "Nicolas"]
10
+ spec.email = ["matthv@gmail.com", "nicolasalexandre9@gmail.com"]
11
+ spec.homepage = "https://www.forestadmin.com"
12
+ spec.summary = "Ruby agent for Forest Admin."
13
+ spec.description = "Forest is a modern admin interface that works on all major web frameworks. This gem makes Forest
14
+ admin work on any Ruby application."
15
+ spec.license = "GPL-3.0"
16
+ spec.required_ruby_version = ">= 3.0.0"
17
+
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = "https://github.com/ForestAdmin/agent-ruby"
20
+ spec.metadata["changelog_uri"] = "https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md"
21
+ spec.metadata["rubygems_mfa_required"] = "false"
22
+ spec.files = Dir[
23
+ "lib/**/*.rb",
24
+ "bin/*",
25
+ "*.gemspec",
26
+ "README.md",
27
+ "LICENSE"
28
+ ]
29
+ spec.bindir = "bin"
30
+ spec.executables = ["forest_admin_rpc_agent"]
31
+ spec.require_paths = ["lib"]
32
+
33
+ spec.add_dependency "dry-configurable", "~> 1.1"
34
+ spec.add_dependency "zeitwerk", "~> 2.3"
35
+ spec.add_dependency "thor", "~> 1.3"
36
+ end
@@ -0,0 +1,19 @@
1
+ module ForestAdminRpcAgent
2
+ class Agent < ForestAdminAgent::Builder::AgentFactory
3
+ attr_reader :rpc_collections
4
+
5
+ def setup(options)
6
+ super
7
+ @rpc_collections = []
8
+ end
9
+
10
+ def send_schema(_force: nil)
11
+ ForestAdminRpcAgent::Facades::Container.logger.log('Info', 'Started as RPC agent, schema not sent.')
12
+ end
13
+
14
+ def mark_collections_as_rpc(*names)
15
+ @rpc_collections.push(*names)
16
+ self
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ module ForestAdminRpcAgent
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace ForestAdminRpcAgent
4
+
5
+ config.after_initialize do
6
+ Rails.error.handle(ForestAdminDatasourceToolkit::Exceptions::ForestException) do
7
+ agent = ForestAdminRpcAgent::Agent.instance
8
+ agent.setup(ForestAdminRpcAgent.config)
9
+
10
+ # force eager loading models
11
+ Rails.application.eager_load!
12
+
13
+ ForestAdminRpcAgent::Extensions::ConfigLoader.load_configuration
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,12 @@
1
+ module ForestAdminRpcAgent
2
+ module Extensions
3
+ module ConfigLoader
4
+ def self.load_configuration
5
+ config_file = File.join(Dir.pwd, 'app', 'lib', 'forest_admin_rpc_agent', 'create_rpc_agent.rb')
6
+ return unless File.exist?(config_file)
7
+
8
+ ForestAdminRpcAgent::CreateRpcAgent.setup!
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,32 @@
1
+ require 'sinatra/base'
2
+
3
+ module ForestAdminRpcAgent
4
+ module Extensions
5
+ module SinatraExtension
6
+ def self.registered(app)
7
+ app.before do
8
+ agent = ForestAdminRpcAgent::Agent.instance
9
+ agent.setup(ForestAdminRpcAgent.config)
10
+ ForestAdminRpcAgent::Extensions::ConfigLoader.load_configuration
11
+ end
12
+
13
+ app.use ForestAdminRpcAgent::Middleware::Authentication
14
+
15
+ route_classes = ForestAdminRpcAgent::Routes.constants.reject { |route| route.name == 'BaseRoute' }
16
+ route_classes.each do |route|
17
+ route_class = ForestAdminRpcAgent::Routes.const_get(route)
18
+
19
+ if route_class.respond_to?(:registered)
20
+ puts "Registering #{route_class}"
21
+ route_class.registered(app)
22
+ else
23
+ ForestAdminAgent::Facades::Container.logger.log('warn',
24
+ "Skipping #{route_class} (does not respond to :registered)")
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ Sinatra::Base.register ForestAdminRpcAgent::Extensions::SinatraExtension
@@ -0,0 +1,29 @@
1
+ module ForestAdminRpcAgent
2
+ module Facades
3
+ class Container
4
+ def self.instance
5
+ ForestAdminRpcAgent::Agent.instance.container
6
+ end
7
+
8
+ def self.datasource
9
+ instance.resolve(:datasource) do
10
+ ForestAdminDatasourceToolkit::Datasource.new
11
+ end
12
+ end
13
+
14
+ def self.logger
15
+ instance.resolve(:logger)
16
+ end
17
+
18
+ def self.config_from_cache
19
+ instance.resolve(:cache).get('config')
20
+ end
21
+
22
+ def self.cache(key)
23
+ raise "Key #{key} not found in container" unless config_from_cache.key?(key)
24
+
25
+ config_from_cache[key]
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,67 @@
1
+ module ForestAdminRpcAgent
2
+ module Middleware
3
+ class Authentication
4
+ ALLOWED_TIME_DIFF = 300
5
+ SIGNATURE_REUSE_WINDOW = 5
6
+ @@used_signatures = {}
7
+
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ request = Rack::Request.new(env)
14
+ signature = request.get_header('HTTP_X_SIGNATURE')
15
+ timestamp = request.get_header('HTTP_X_TIMESTAMP')
16
+
17
+ unless valid_signature?(signature, timestamp)
18
+ return [401, { 'Content-Type' => 'application/json' }, [{ error: 'Unauthorized' }.to_json]]
19
+ end
20
+
21
+ @app.call(env)
22
+ end
23
+
24
+ private
25
+
26
+ def valid_signature?(signature, timestamp)
27
+ return false if signature.nil? || timestamp.nil?
28
+ return false unless valid_timestamp?(timestamp)
29
+
30
+ expected_signature = OpenSSL::HMAC.hexdigest('SHA256', auth_secret, timestamp)
31
+
32
+ return false unless Rack::Utils.secure_compare(signature, expected_signature)
33
+
34
+ # check if this signature has already been used (replay attack)
35
+ if @@used_signatures.key?(signature)
36
+ last_used = @@used_signatures[signature]
37
+ return false if Time.now.utc.to_i - last_used > SIGNATURE_REUSE_WINDOW
38
+ end
39
+ @@used_signatures[signature] = Time.now.utc.to_i
40
+
41
+ cleanup_old_signatures
42
+
43
+ true
44
+ end
45
+
46
+ def valid_timestamp?(timestamp)
47
+ time = begin
48
+ Time.iso8601(timestamp)
49
+ rescue StandardError
50
+ nil
51
+ end
52
+ return false if time.nil?
53
+
54
+ (Time.now.utc.to_i - time.to_i).abs <= ALLOWED_TIME_DIFF
55
+ end
56
+
57
+ def cleanup_old_signatures
58
+ now = Time.now.utc.to_i
59
+ @@used_signatures.delete_if { |_signature, last_used| now - last_used > ALLOWED_TIME_DIFF }
60
+ end
61
+
62
+ def auth_secret
63
+ ForestAdminRpcAgent.config.auth_secret
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,31 @@
1
+ require 'jsonapi-serializers'
2
+
3
+ module ForestAdminRpcAgent
4
+ module Routes
5
+ class ActionExecute < BaseRoute
6
+ include ForestAdminDatasourceToolkit::Components::Query
7
+ include ForestAdminAgent::Utils
8
+ include ForestAdminAgent::Routes::QueryHandler
9
+
10
+ def initialize
11
+ super('rpc/:collection_name/action-execute', 'post', 'rpc_action_execute')
12
+ end
13
+
14
+ def handle_request(args)
15
+ return '{}' unless args[:params]['collection_name']
16
+
17
+ datasource = ForestAdminRpcAgent::Facades::Container.datasource
18
+ collection = datasource.get_collection(args[:params]['collection_name'])
19
+
20
+ caller = ForestAdminDatasourceToolkit::Components::Caller.new(
21
+ **args[:params]['caller'].to_h.transform_keys(&:to_sym)
22
+ )
23
+ filter = FilterFactory.from_plain_object(args[:params]['filter'])
24
+ data = args[:params]['data']
25
+ action = args[:params]['action']
26
+
27
+ collection.execute(caller, action, data, filter).to_json
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,49 @@
1
+ require 'jsonapi-serializers'
2
+
3
+ module ForestAdminRpcAgent
4
+ module Routes
5
+ class ActionForm < BaseRoute
6
+ include ForestAdminDatasourceToolkit::Components::Query
7
+ include ForestAdminAgent::Utils
8
+ include ForestAdminAgent::Routes::QueryHandler
9
+
10
+ def initialize
11
+ super('rpc/:collection_name/action-form', 'post', 'rpc_action_form')
12
+ end
13
+
14
+ def handle_request(args)
15
+ return '{}' unless args[:params]['collection_name']
16
+
17
+ datasource = ForestAdminRpcAgent::Facades::Container.datasource
18
+ collection = datasource.get_collection(args[:params]['collection_name'])
19
+
20
+ caller = if args[:params].key?('caller')
21
+ ForestAdminDatasourceToolkit::Components::Caller.new(
22
+ **args[:params]['caller'].to_h.transform_keys(&:to_sym)
23
+ )
24
+ end
25
+ filter = FilterFactory.from_plain_object(args[:params]['filter'])
26
+ metas = args[:params]['metas'] || {}
27
+ data = args[:params]['data']
28
+ action = args[:params]['action']
29
+
30
+ form = collection.get_form(caller, action, data, filter, metas)
31
+ form = encode_file_element(form)
32
+ form.to_json
33
+ end
34
+
35
+ def encode_file_element(elements)
36
+ nested_elements = %w[Page Row]
37
+ elements.map do |element|
38
+ encode_file_element(element) if element.type == 'Layout' && nested_elements.include?(element.component)
39
+
40
+ if element.type == 'File' && element.value && element.value.is_a?(File)
41
+ element.value = ForestAdminAgent::Utils::Schema::ForestValueConverter.make_data_uri(element.value)
42
+ end
43
+
44
+ element
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,34 @@
1
+ require 'jsonapi-serializers'
2
+
3
+ module ForestAdminRpcAgent
4
+ module Routes
5
+ class Aggregate < BaseRoute
6
+ include ForestAdminDatasourceToolkit::Components::Query
7
+ include ForestAdminAgent::Utils
8
+ include ForestAdminAgent::Routes::QueryHandler
9
+
10
+ def initialize
11
+ super('rpc/:collection_name/aggregate', 'post', 'rpc_aggregate')
12
+ end
13
+
14
+ def handle_request(args)
15
+ return '{}' unless args[:params]['collection_name']
16
+
17
+ caller = ForestAdminDatasourceToolkit::Components::Caller.new(
18
+ **args[:params]['caller'].to_h.transform_keys(&:to_sym)
19
+ )
20
+ datasource = ForestAdminRpcAgent::Facades::Container.datasource
21
+ collection = datasource.get_collection(args[:params]['collection_name'])
22
+
23
+ aggregation = Aggregation.new(
24
+ operation: args[:params]['aggregation']['operation'],
25
+ field: args[:params]['aggregation']['field'],
26
+ groups: args[:params]['aggregation']['groups']
27
+ )
28
+ filter = FilterFactory.from_plain_object(args[:params]['filter'])
29
+
30
+ collection.aggregate(caller, filter, aggregation, args[:params]['limit']).to_json
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,52 @@
1
+ module ForestAdminRpcAgent
2
+ module Routes
3
+ class BaseRoute
4
+ def initialize(url, method, name)
5
+ @url = url
6
+ @method = method
7
+ @name = name
8
+ end
9
+
10
+ def registered(app)
11
+ if defined?(Sinatra) && (app == Sinatra::Base || app.ancestors.include?(Sinatra::Base))
12
+ register_sinatra(app)
13
+ elsif defined?(Rails) && app.is_a?(ActionDispatch::Routing::Mapper)
14
+ register_rails(app)
15
+ else
16
+ raise NotImplementedError,
17
+ "Unsupported application type: #{app.class}. #{self} works with Sinatra::Base or ActionDispatch::Routing::Mapper."
18
+ end
19
+ end
20
+
21
+ def register_sinatra(app)
22
+ app.send(@method.to_sym, @url) do
23
+ handle_request(params)
24
+ end
25
+ end
26
+
27
+ def register_rails(router)
28
+ handler = proc do |hash|
29
+ request = ActionDispatch::Request.new(hash)
30
+
31
+ auth_middleware = ForestAdminRpcAgent::Middleware::Authentication.new(->(_env) { [200, {}, ['OK']] })
32
+ status, headers, response = auth_middleware.call(request.env)
33
+
34
+ if status == 200
35
+ params = request.query_parameters.merge(request.request_parameters)
36
+
37
+ [200, { 'Content-Type' => 'application/json' }, [handle_request({ params: params })]]
38
+ else
39
+ [status, headers, response]
40
+ end
41
+ end
42
+
43
+ router.match @url,
44
+ defaults: { format: 'json' },
45
+ to: handler,
46
+ via: @method,
47
+ as: @name,
48
+ route_alias: @name
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,30 @@
1
+ require 'jsonapi-serializers'
2
+
3
+ module ForestAdminRpcAgent
4
+ module Routes
5
+ class Chart < BaseRoute
6
+ include ForestAdminDatasourceToolkit::Components::Query
7
+
8
+ def initialize
9
+ super('rpc/:collection_name/chart', 'post', 'rpc_chart_collection')
10
+ end
11
+
12
+ def handle_request(args)
13
+ return '{}' unless args[:params]['collection_name']
14
+
15
+ chart_name = args[:params]['name']
16
+ caller = ForestAdminDatasourceToolkit::Components::Caller.new(
17
+ **args[:params]['caller'].to_h.transform_keys(&:to_sym)
18
+ )
19
+ datasource = ForestAdminRpcAgent::Facades::Container.datasource
20
+ collection = datasource.get_collection(args[:params]['collection_name'])
21
+
22
+ collection.render_chart(
23
+ caller,
24
+ chart_name,
25
+ ForestAdminAgent::Utils::Id.unpack_id(collection, args[:params]['record_id'])
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,23 @@
1
+ require 'jsonapi-serializers'
2
+
3
+ module ForestAdminRpcAgent
4
+ module Routes
5
+ class Create < BaseRoute
6
+ def initialize
7
+ super('rpc/:collection_name/create', 'post', 'rpc_create')
8
+ end
9
+
10
+ def handle_request(args)
11
+ return '{}' unless args[:params]['collection_name']
12
+
13
+ caller = ForestAdminDatasourceToolkit::Components::Caller.new(
14
+ **args[:params]['caller'].to_h.transform_keys(&:to_sym)
15
+ )
16
+ datasource = ForestAdminRpcAgent::Facades::Container.datasource
17
+ collection = datasource.get_collection(args[:params]['collection_name'])
18
+
19
+ collection.create(caller, args[:params]['data']).to_json
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ require 'jsonapi-serializers'
2
+
3
+ module ForestAdminRpcAgent
4
+ module Routes
5
+ class DatasourceChart < BaseRoute
6
+ def initialize
7
+ super('rpc/datasource-chart', 'post', 'rpc_chart_datasource')
8
+ end
9
+
10
+ def handle_request(args)
11
+ return '{}' unless args[:params]['chart']
12
+
13
+ chart_name = args[:params]['chart']
14
+ caller = ForestAdminDatasourceToolkit::Components::Caller.new(
15
+ **args[:params]['caller'].to_h.transform_keys(&:to_sym)
16
+ )
17
+ datasource = ForestAdminRpcAgent::Facades::Container.datasource
18
+
19
+ datasource.render_chart(caller, chart_name).to_json
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,28 @@
1
+ require 'jsonapi-serializers'
2
+
3
+ module ForestAdminRpcAgent
4
+ module Routes
5
+ class Delete < BaseRoute
6
+ include ForestAdminDatasourceToolkit::Components::Query
7
+ include ForestAdminAgent::Utils
8
+ include ForestAdminAgent::Routes::QueryHandler
9
+
10
+ def initialize
11
+ super('rpc/:collection_name/delete', 'post', 'rpc_delete')
12
+ end
13
+
14
+ def handle_request(args)
15
+ return '{}' unless args[:params]['collection_name']
16
+
17
+ caller = ForestAdminDatasourceToolkit::Components::Caller.new(
18
+ **args[:params]['caller'].to_h.transform_keys(&:to_sym)
19
+ )
20
+ datasource = ForestAdminRpcAgent::Facades::Container.datasource
21
+ collection = datasource.get_collection(args[:params]['collection_name'])
22
+ filter = FilterFactory.from_plain_object(args[:params]['filter'])
23
+
24
+ collection.delete(caller, filter).to_json
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,13 @@
1
+ module ForestAdminRpcAgent
2
+ module Routes
3
+ class HealthRoute < BaseRoute
4
+ def initialize
5
+ super('health', 'get', 'rpc_forest')
6
+ end
7
+
8
+ def handle_request(_params)
9
+ { status: 'ok', message: 'Forest Admin RPC Agent is running' }.to_json
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,29 @@
1
+ require 'jsonapi-serializers'
2
+
3
+ module ForestAdminRpcAgent
4
+ module Routes
5
+ class List < BaseRoute
6
+ include ForestAdminDatasourceToolkit::Components::Query
7
+ include ForestAdminAgent::Utils
8
+ include ForestAdminAgent::Routes::QueryHandler
9
+
10
+ def initialize
11
+ super('rpc/:collection_name/list', 'post', 'rpc_list')
12
+ end
13
+
14
+ def handle_request(args)
15
+ return '{}' unless args[:params]['collection_name']
16
+
17
+ caller = ForestAdminDatasourceToolkit::Components::Caller.new(
18
+ **args[:params]['caller'].to_h.transform_keys(&:to_sym)
19
+ )
20
+ datasource = ForestAdminRpcAgent::Facades::Container.datasource
21
+ collection = datasource.get_collection(args[:params]['collection_name'])
22
+ projection = Projection.new(args[:params]['projection'])
23
+ filter = FilterFactory.from_plain_object(args[:params]['filter'])
24
+
25
+ collection.list(caller, filter, projection).to_json
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,24 @@
1
+ module ForestAdminRpcAgent
2
+ module Routes
3
+ class Schema < BaseRoute
4
+ include ForestAdminDatasourceToolkit::Components::Query::ConditionTree
5
+ include ForestAdminAgent::Utils
6
+ include ForestAdminAgent::Routes::QueryHandler
7
+
8
+ def initialize
9
+ super('rpc-schema', 'get', 'rpc_schema')
10
+ end
11
+
12
+ def handle_request(_params)
13
+ agent = ForestAdminRpcAgent::Agent.instance
14
+ schema = agent.customizer.schema
15
+ schema[:collections] = agent.customizer.datasource(ForestAdminRpcAgent::Facades::Container.logger)
16
+ .collections
17
+ .map { |_name, collection| collection.schema.merge({ name: collection.name }) }
18
+ .sort_by { |collection| collection[:name] }
19
+
20
+ schema.to_json
21
+ end
22
+ end
23
+ end
24
+ end