diff --git a/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/Utils/rpc_client.rb b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/Utils/rpc_client.rb index be755d5a0..49bdef36c 100644 --- a/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/Utils/rpc_client.rb +++ b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/Utils/rpc_client.rb @@ -31,17 +31,20 @@ def initialize(api_url, auth_secret) end # rubocop:disable Metrics/ParameterLists - def call_rpc(endpoint, caller: nil, method: :get, payload: nil, symbolize_keys: false, if_none_match: nil) + def call_rpc(endpoint, caller: nil, method: :get, payload: nil, symbolize_keys: false, + if_none_match: nil, with_response: false) response = make_request(endpoint, caller: caller, method: method, payload: payload, symbolize_keys: symbolize_keys, if_none_match: if_none_match) - handle_response(response) - end + return NotModified if response.status == HTTP_NOT_MODIFIED + raise_appropriate_error(response) unless response.success? + + with_response ? response : response.body + end # rubocop:enable Metrics/ParameterLists def fetch_schema(endpoint, if_none_match: nil) - response = make_request(endpoint, method: :get, symbolize_keys: true, if_none_match: if_none_match) - handle_response(response) + call_rpc(endpoint, method: :get, symbolize_keys: true, if_none_match: if_none_match) end private @@ -111,13 +114,6 @@ def generate_signature(timestamp) OpenSSL::HMAC.hexdigest('SHA256', @auth_secret, timestamp) end - def handle_response(response) - return response.body if response.success? - return NotModified if response.status == HTTP_NOT_MODIFIED - - raise_appropriate_error(response) - end - def raise_appropriate_error(response) error_body = parse_error_body(response) status = response.status diff --git a/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/collection.rb b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/collection.rb index 46a17c196..b662e597f 100644 --- a/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/collection.rb +++ b/packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/collection.rb @@ -1,4 +1,7 @@ require 'base64' +require 'cgi' +require 'json' +require 'stringio' module ForestAdminDatasourceRpc class Collection < ForestAdminDatasourceToolkit::Collection @@ -127,7 +130,12 @@ def execute(caller, name, data, filter = nil) "Forwarding '#{@name}' action #{name} call to the Rpc agent on #{url}." ) - @client.call_rpc(url, caller: caller, method: :post, payload: params, symbolize_keys: true) + response = @client.call_rpc(url, caller: caller, method: :post, payload: params, + symbolize_keys: true, with_response: true) + + return build_file_result(response) if response.headers['x-forest-action-type'] == 'File' + + response.body end def get_form(caller, name, data = nil, filter = nil, metas = nil) @@ -176,5 +184,20 @@ def encode_form_data(data) end end end + + def build_file_result(response) + response_headers_header = response.headers['x-forest-action-response-headers'] + file_name_header = response.headers['x-forest-action-file-name'] + + result = { + type: 'File', + mime_type: response.headers['content-type'], + name: CGI.unescape(file_name_header.to_s), + stream: response.body.to_s + } + result[:response_headers] = JSON.parse(response_headers_header) if response_headers_header + + result + end end end diff --git a/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/collection_spec.rb b/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/collection_spec.rb index 84cf3257e..13ba280f9 100644 --- a/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/collection_spec.rb +++ b/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/collection_spec.rb @@ -13,7 +13,15 @@ module ForestAdminDatasourceRpc allow(Utils::RpcClient).to receive(:new).and_return(rpc_client) end + let(:raw_response) do + instance_double(Faraday::Response, body: {}, headers: {}, status: 200, success?: true) + end let(:rpc_client) { instance_double(Utils::RpcClient, call_rpc: {}) } + + before do + allow(rpc_client).to receive(:call_rpc).with(any_args, hash_including(with_response: true)).and_return(raw_response) + end + let(:datasource) { Datasource.new({ uri: 'http://localhost' }, introspection) } let(:collection) { datasource.get_collection('Product') } let(:caller) { build_caller } @@ -45,8 +53,8 @@ module ForestAdminDatasourceRpc } }, actions: { - 'static_action': { scope: 'global', static_form: true }, - 'dynamic_action': { scope: 'global', static_form: false } + static_action: { scope: 'global', static_form: true }, + dynamic_action: { scope: 'global', static_form: false } } } ] @@ -275,10 +283,11 @@ module ForestAdminDatasourceRpc expect(rpc_client).to have_received(:call_rpc) do |_url, options| expect(options[:symbolize_keys]).to be(true) + expect(options[:with_response]).to be(true) end end - it 'returns the action result as-is so :type and other keys reach ActionResult.parse' do + it 'returns the parsed body so :type and other keys reach ActionResult.parse' do success_result = { type: 'Success', message: 'ok', @@ -286,13 +295,65 @@ module ForestAdminDatasourceRpc html: nil, response_headers: {} } - allow(rpc_client).to receive(:call_rpc).and_return(success_result) + allow(raw_response).to receive(:body).and_return(success_result) result = collection.execute(caller, 'my_action', {}) expect(result).to eq(success_result) expect(result[:type]).to eq('Success') end + + context 'when the server replies with X-Forest-Action-Type=File' do + let(:file_body) { 'binary-payload' } + let(:raw_response) do + instance_double( + Faraday::Response, + body: file_body, + headers: { + 'content-type' => 'application/pdf', + 'x-forest-action-type' => 'File', + 'x-forest-action-file-name' => CGI.escape('report final.pdf'), + 'x-forest-action-response-headers' => { 'set-cookie' => 'token=xyz' }.to_json + }, + status: 200, + success?: true + ) + end + + it 'rebuilds a File action result from the response headers and body' do + result = collection.execute(caller, 'download', {}) + + expect(result[:type]).to eq('File') + expect(result[:mime_type]).to eq('application/pdf') + expect(result[:name]).to eq('report final.pdf') + expect(result[:response_headers]).to eq({ 'set-cookie' => 'token=xyz' }) + expect(result[:stream]).to eq(file_body) + end + + context 'when response_headers header is absent' do + let(:raw_response) do + instance_double( + Faraday::Response, + body: 'hi', + headers: { + 'content-type' => 'text/plain', + 'x-forest-action-type' => 'File', + 'x-forest-action-file-name' => 'note.txt' + }, + status: 200, + success?: true + ) + end + + it 'omits response_headers from the rebuilt result' do + result = collection.execute(caller, 'download', {}) + + expect(result[:type]).to eq('File') + expect(result[:name]).to eq('note.txt') + expect(result).not_to have_key(:response_headers) + end + end + end end context 'when call get_form' do diff --git a/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/utils/rpc_client_spec.rb b/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/utils/rpc_client_spec.rb index 5ff24a78d..59bae1edf 100644 --- a/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/utils/rpc_client_spec.rb +++ b/packages/forest_admin_datasource_rpc/spec/lib/forest_admin_datasource_rpc/utils/rpc_client_spec.rb @@ -97,6 +97,24 @@ module Utils expect(result).to eq({}) end + + context 'when called with with_response: true' do + it 'returns the Faraday::Response object instead of the body' do + result = rpc_client.call_rpc('/rpc/test', method: :post, with_response: true) + + expect(result).to be(response) + expect(result.body).to eq({}) + expect(result.headers).to eq(response_headers) + end + end + + context 'when called without with_response (default)' do + it 'returns the response body for backward compatibility' do + result = rpc_client.call_rpc('/rpc/test', method: :post) + + expect(result).to eq({}) + end + end end describe '#fetch_schema' do diff --git a/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb b/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb index 246cdeb49..5a672ddff 100644 --- a/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb +++ b/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/agent.rb @@ -84,36 +84,9 @@ def build_rpc_schema_from_datasource(datasource) relations = {} if @rpc_collections.include?(collection.name) - # RPC collection → extract relations to non-RPC collections - collection.schema[:fields].each do |field_name, field| - next if field.type == 'Column' - next if relation_targets_rpc_collection?(field) - - relations[field_name] = field - end + extract_rpc_collection_relations(collection, relations) else - fields = {} - - collection.schema[:fields].each do |field_name, field| - if field.type != 'Column' && relation_targets_rpc_collection?(field) - relations[field_name] = field - else - if field.type == 'Column' - field.filter_operators = ForestAdminAgent::Utils::Schema::FrontendFilterable.sort_operators( - field.filter_operators - ) - end - - fields[field_name] = field - end - end - - # Normal collection → include in schema - collections << collection.schema.merge( - name: collection.name, - fields: fields, - actions: serialize_actions(collection.schema[:actions]) - ) + collections << build_normal_collection_payload(collection, relations) end rpc_relations[collection.name] = relations unless relations.empty? @@ -135,6 +108,40 @@ def build_rpc_schema_from_datasource(datasource) schema end + # RPC collection → extract relations targeting non-RPC collections. + def extract_rpc_collection_relations(collection, relations) + collection.schema[:fields].each do |field_name, field| + next if field.type == 'Column' + next if relation_targets_rpc_collection?(field) + + relations[field_name] = field + end + end + + # Normal (non-RPC) collection → split fields between local schema and cross-RPC relations. + def build_normal_collection_payload(collection, relations) + fields = {} + + collection.schema[:fields].each do |field_name, field| + if field.type != 'Column' && relation_targets_rpc_collection?(field) + relations[field_name] = field + else + if field.type == 'Column' + field.filter_operators = ForestAdminAgent::Utils::Schema::FrontendFilterable.sort_operators( + field.filter_operators + ) + end + fields[field_name] = field + end + end + + collection.schema.merge( + name: collection.name, + fields: fields, + actions: serialize_actions(collection.schema[:actions]) + ) + end + # Only expose the fields needed by an RPC consumer: scope, is_generate_file, static_form, # description, submit_button_label. Drops `form` (computed via /action-form) and `execute` # (server-side callback) — neither belongs on the wire. diff --git a/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/routes/action_execute.rb b/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/routes/action_execute.rb index 18b2f5c8c..3c55b6133 100644 --- a/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/routes/action_execute.rb +++ b/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/routes/action_execute.rb @@ -1,3 +1,5 @@ +require 'cgi' +require 'json' require 'jsonapi-serializers' module ForestAdminRpcAgent @@ -20,7 +22,30 @@ def handle_request(args) data = args[:params]['data'] action = args[:params]['action'] - collection.execute(args[:caller], action, data, filter) + result = collection.execute(args[:caller], action, data, filter) + + return build_file_response(result) if file_result?(result) + + result + end + + private + + def file_result?(result) + result.is_a?(Hash) && result[:type] == 'File' + end + + def build_file_response(result) + encoded_name = CGI.escape(result[:name].to_s) + headers = { + 'Content-Type' => result[:mime_type], + 'Content-Disposition' => %(attachment; filename="#{encoded_name}"), + 'X-Forest-Action-Type' => 'File', + 'X-Forest-Action-File-Name' => encoded_name + } + headers['X-Forest-Action-Response-Headers'] = result[:response_headers].to_json if result[:response_headers] + + { status: 200, headers: headers, content: result[:stream] } end end end