From ab5c58379a798ca33ef869e31da46f43d269073c Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Wed, 20 May 2026 17:21:09 +0200 Subject: [PATCH] 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 83b3d05af..d5c8678c0 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 } @@ -239,10 +246,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', @@ -250,13 +258,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 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