From cbe89a01d9ea6ea88dd8011bc322432278342b5f Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Wed, 20 May 2026 17:21:09 +0200 Subject: [PATCH 1/2] fix(rpc): action with file --- .../Utils/rpc_client.rb | 20 +++--- .../forest_admin_datasource_rpc/collection.rb | 25 +++++++- .../collection_spec.rb | 64 ++++++++++++++++++- .../utils/rpc_client_spec.rb | 18 ++++++ .../routes/action_execute.rb | 27 +++++++- 5 files changed, 138 insertions(+), 16 deletions(-) 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 0d307ec67..97ac034be 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,14 @@ 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 } @@ -275,10 +282,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 +294,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/routes/action_execute.rb b/packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/routes/action_execute.rb index 30d9235e1..8657bbabd 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 @@ -18,7 +20,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 From 37ae6bdc204e3b987c494deb672279dac47c6dc1 Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Thu, 21 May 2026 16:13:41 +0200 Subject: [PATCH 2/2] ci: lint --- .../collection_spec.rb | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) 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 97ac034be..e6430eced 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 @@ -7,24 +7,22 @@ module ForestAdminDatasourceRpc include ForestAdminDatasourceToolkit::Components::Query::ConditionTree describe Collection do - before do - logger = instance_double(Logger, log: nil) - allow(ForestAdminAgent::Facades::Container).to receive_messages(logger: logger, cache: 'secret') - 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 } + before do + logger = instance_double(Logger, log: nil) + allow(ForestAdminAgent::Facades::Container).to receive_messages(logger: logger, cache: 'secret') + allow(Utils::RpcClient).to receive(:new).and_return(rpc_client) + allow(rpc_client).to receive(:call_rpc) + .with(any_args, hash_including(with_response: true)).and_return(raw_response) + end + include_context 'with introspection' context 'when initialized' do