You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hop.apache.org by ha...@apache.org on 2026/07/27 15:19:57 UTC
(hop) branch main updated: Rest client improvements, fixes #7621 (#7644)
This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new c2c654ad80 Rest client improvements, fixes #7621 (#7644)
c2c654ad80 is described below
commit c2c654ad807b1868dd9cc0774a5ab78516db8b62
Author: Hans Van Akelyen <ha...@gmail.com>
AuthorDate: Mon Jul 27 17:19:51 2026 +0200
Rest client improvements, fixes #7621 (#7644)
---
.../ROOT/pages/pipeline/transforms/rest.adoc | 19 +-
.../http/0020-rest-connection-query-parameters.hpl | 294 +++++++++++++++++++++
.../http/0021-rest-connection-headers.hpl | 294 +++++++++++++++++++++
...2-rest-connection-empty-post-content-length.hpl | 282 ++++++++++++++++++++
.../main-0020-rest-connection-query-parameters.hwf | 97 +++++++
.../http/main-0021-rest-connection-headers.hwf | 97 +++++++
...2-rest-connection-empty-post-content-length.hwf | 97 +++++++
.../apache/hop/metadata/rest/RestConnection.java | 22 ++
.../rest/RestConnectionContentLengthTest.java | 98 +++++++
.../metadata/rest/RestConnectionTimeoutTest.java | 118 +++++++++
.../apache/hop/pipeline/transforms/rest/Rest.java | 151 +++++++++--
.../hop/pipeline/transforms/rest/RestDialog.java | 20 +-
.../rest/messages/messages_en_US.properties | 20 +-
.../pipeline/transforms/rest/RestCallRestTest.java | 129 +++++++++
14 files changed, 1700 insertions(+), 38 deletions(-)
diff --git a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/rest.adoc b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/rest.adoc
index 4621c55c4e..cde7ba3dd9 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/rest.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/rest.adoc
@@ -115,15 +115,16 @@ TIP: To figure out what Headers are required, you can use Postman and remove as
[options="header"]
|===
|Option|Description
-|Field|The field from incoming Hop stream that contains the header information. Add the header value field name to the Field column.
-|Name|The name of the outgoing Hop field from this transform. Add the header key field name to the Name column.
+|Header value field|The field from the incoming Hop stream that contains the header value
+|Header name|The HTTP header name sent to the REST API
|===
-=== Parameters Tab
+=== Query Parameters Tab
-The Parameters tab enables you to add URL query parameters to POST, GET, PUT, DELETE, and PATCH requests.
+The Query Parameters tab enables you to add URL query parameters to POST, GET, PUT, DELETE, and PATCH requests.
+Query parameters are added to the end of the URL, e.g. `https://host/path?name1=value1&name2=value2`.
Each row maps a field from the incoming Hop stream to a query parameter name.
-Click **Get fields** to populate the **Parameter field** list, then enter the parameter name expected by the API in **Parameter name**.
+Click **Get fields** to populate the **Query parameter value field** list, then enter the parameter name expected by the API in **Parameter name**.
For example, if an incoming field named `search_value` contains `apache hop`, mapping it to the parameter name `q` adds `?q=apache+hop` to the request URL.
Hop URL-encodes the parameter name and value.
@@ -131,21 +132,21 @@ Hop URL-encodes the parameter name and value.
[options="header"]
|===
|Option|Description
-|Parameter field|The field from the incoming Hop stream that contains the parameter value
+|Parameter value field|The field from the incoming Hop stream that contains the parameter value
|Parameter name|The query parameter name expected by the REST API
|===
=== Matrix Parameters tab
Use the Matrix Parameters tab to define matrix parameter values for POST, PUT, DELETE, and PATCH requests.
-Unlike query parameters, which follow a `?`, matrix parameters are appended to the URL path with a semicolon.
+Unlike query parameters, which follow a `?`, matrix parameters are appended to the URL path with a semicolon, e.g. `https://host/path;name1=value1;name2=value2`.
For example, a matrix parameter named `language` with the value `en` produces a path such as `/resource;language=en`.
[options="header"]
|===
|Option|Description
-|Parameter field|The field from the incoming Hop stream that contains the matrix parameter value
-|Parameter name|The matrix parameter name expected by the REST API
+|Matrix parameter value field|The field from the incoming Hop stream that contains the matrix parameter value
+|Matrix parameter name|The matrix parameter name expected by the REST API
|===
=== Pagination tab
diff --git a/integration-tests/http/0020-rest-connection-query-parameters.hpl b/integration-tests/http/0020-rest-connection-query-parameters.hpl
new file mode 100644
index 0000000000..25e0002467
--- /dev/null
+++ b/integration-tests/http/0020-rest-connection-query-parameters.hpl
@@ -0,0 +1,294 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <name>0020-rest-connection-query-parameters</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Verifies that query parameters are sent when the REST client uses a named connection (issue #7621).</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <pipeline_status>0</pipeline_status>
+ <parameters>
+ <parameter>
+ <name>HOSTNAME</name>
+ <default_value>localhost</default_value>
+ <description/>
+ </parameter>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+ <transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+ <transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/07/27 10:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/07/27 10:00:00.000</modified_date>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>check result</from>
+ <to>Abort</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>JSON input</from>
+ <to>check result</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Generate rows</from>
+ <to>REST client</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>REST client</from>
+ <to>JSON input</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>Abort</name>
+ <type>Abort</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <abort_option>ABORT_WITH_ERROR</abort_option>
+ <always_log_rows>Y</always_log_rows>
+ <row_threshold>0</row_threshold>
+ <attributes/>
+ <GUI>
+ <xloc>752</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Generate rows</name>
+ <type>RowGenerator</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <length>-1</length>
+ <name>parameter</name>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <type>String</type>
+ <nullif>parametervalue</nullif>
+ </field>
+ </fields>
+ <interval_in_ms>5000</interval_in_ms>
+ <last_time_field>FiveSecondsAgo</last_time_field>
+ <never_ending>N</never_ending>
+ <limit>1</limit>
+ <row_time_field>now</row_time_field>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>JSON input</name>
+ <type>JsonInput</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <include>N</include>
+ <include_field/>
+ <rownum>N</rownum>
+ <addresultfile>N</addresultfile>
+ <readurl>N</readurl>
+ <removeSourceField>N</removeSourceField>
+ <IsIgnoreEmptyFile>N</IsIgnoreEmptyFile>
+ <doNotFailIfNoFile>Y</doNotFailIfNoFile>
+ <ignoreMissingPath>Y</ignoreMissingPath>
+ <defaultPathLeafToNull>Y</defaultPathLeafToNull>
+ <rownum_field/>
+ <file>
+ <name/>
+ <filemask/>
+ <exclude_filemask/>
+ <file_required>N</file_required>
+ <include_subfolders>N</include_subfolders>
+ </file>
+ <fields>
+ <field>
+ <name>parameter_result</name>
+ <path>$.args</path>
+ <type>String</type>
+ <format/>
+ <currency/>
+ <decimal/>
+ <group/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <trim_type>none</trim_type>
+ <repeat>N</repeat>
+ </field>
+ </fields>
+ <limit>0</limit>
+ <IsInFields>Y</IsInFields>
+ <IsAFile>N</IsAFile>
+ <valueField>result</valueField>
+ <shortFileFieldName/>
+ <pathFieldName/>
+ <hiddenFieldName/>
+ <lastModificationTimeFieldName/>
+ <uriNameFieldName/>
+ <rootUriNameFieldName/>
+ <extensionFieldName/>
+ <sizeFieldName/>
+ <attributes/>
+ <GUI>
+ <xloc>424</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>check result</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to/>
+ <send_false_to/>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <conditions>
+ <condition>
+ <negated>Y</negated>
+ <leftvalue>result_status</leftvalue>
+ <function>=</function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>Integer</type>
+ <text>200</text>
+ <length>-1</length>
+ <precision>0</precision>
+ <isnull>N</isnull>
+ <mask>#</mask>
+ </value>
+ </condition>
+ <condition>
+ <negated>Y</negated>
+ <operator>OR</operator>
+ <leftvalue>parameter_result</leftvalue>
+ <function>=</function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text>{"parameter":"parametervalue"}</text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ <mask/>
+ </value>
+ </condition>
+ </conditions>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>588</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>REST client</name>
+ <type>Rest</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <applicationType>TEXT PLAIN</applicationType>
+ <bodyField/>
+ <connectionTimeout/>
+ <connection_name>castlemock</connection_name>
+ <dynamicMethod>N</dynamicMethod>
+ <headers>
+</headers>
+ <httpLogin/>
+ <httpPassword>Encrypted </httpPassword>
+ <ignoreSsl>N</ignoreSsl>
+ <matrixParameters>
+</matrixParameters>
+ <method>POST</method>
+ <methodFieldName/>
+ <parameters>
+ <parameter>
+ <field>parameter</field>
+ <name>parameter</name>
+ </parameter>
+ </parameters>
+ <preemptive>N</preemptive>
+ <proxyHost/>
+ <proxyPort/>
+ <readTimeout/>
+ <result>
+ <code>result_status</code>
+ <name>result</name>
+ <response_header/>
+ <response_time/>
+ </result>
+ <trustStoreFile/>
+ <trustStorePassword>Encrypted </trustStorePassword>
+ <url>/post</url>
+ <urlField/>
+ <urlInField>N</urlInField>
+ <attributes/>
+ <GUI>
+ <xloc>260</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git a/integration-tests/http/0021-rest-connection-headers.hpl b/integration-tests/http/0021-rest-connection-headers.hpl
new file mode 100644
index 0000000000..b916f49233
--- /dev/null
+++ b/integration-tests/http/0021-rest-connection-headers.hpl
@@ -0,0 +1,294 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <name>0021-rest-connection-headers</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Verifies that HTTP headers are sent when the REST client uses a named connection (issue #7621).</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <pipeline_status>0</pipeline_status>
+ <parameters>
+ <parameter>
+ <name>HOSTNAME</name>
+ <default_value>localhost</default_value>
+ <description/>
+ </parameter>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+ <transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+ <transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/07/27 10:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/07/27 10:00:00.000</modified_date>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>check result</from>
+ <to>Abort</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>JSON input</from>
+ <to>check result</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Generate rows</from>
+ <to>REST client</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>REST client</from>
+ <to>JSON input</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>Abort</name>
+ <type>Abort</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <abort_option>ABORT_WITH_ERROR</abort_option>
+ <always_log_rows>Y</always_log_rows>
+ <row_threshold>0</row_threshold>
+ <attributes/>
+ <GUI>
+ <xloc>752</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Generate rows</name>
+ <type>RowGenerator</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <length>-1</length>
+ <name>header</name>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <type>String</type>
+ <nullif>headervalue</nullif>
+ </field>
+ </fields>
+ <interval_in_ms>5000</interval_in_ms>
+ <last_time_field>FiveSecondsAgo</last_time_field>
+ <never_ending>N</never_ending>
+ <limit>1</limit>
+ <row_time_field>now</row_time_field>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>JSON input</name>
+ <type>JsonInput</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <include>N</include>
+ <include_field/>
+ <rownum>N</rownum>
+ <addresultfile>N</addresultfile>
+ <readurl>N</readurl>
+ <removeSourceField>N</removeSourceField>
+ <IsIgnoreEmptyFile>N</IsIgnoreEmptyFile>
+ <doNotFailIfNoFile>Y</doNotFailIfNoFile>
+ <ignoreMissingPath>Y</ignoreMissingPath>
+ <defaultPathLeafToNull>Y</defaultPathLeafToNull>
+ <rownum_field/>
+ <file>
+ <name/>
+ <filemask/>
+ <exclude_filemask/>
+ <file_required>N</file_required>
+ <include_subfolders>N</include_subfolders>
+ </file>
+ <fields>
+ <field>
+ <name>header_parameter</name>
+ <path>$.headers.Header-Parameter</path>
+ <type>String</type>
+ <format/>
+ <currency/>
+ <decimal/>
+ <group/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <trim_type>none</trim_type>
+ <repeat>N</repeat>
+ </field>
+ </fields>
+ <limit>0</limit>
+ <IsInFields>Y</IsInFields>
+ <IsAFile>N</IsAFile>
+ <valueField>result</valueField>
+ <shortFileFieldName/>
+ <pathFieldName/>
+ <hiddenFieldName/>
+ <lastModificationTimeFieldName/>
+ <uriNameFieldName/>
+ <rootUriNameFieldName/>
+ <extensionFieldName/>
+ <sizeFieldName/>
+ <attributes/>
+ <GUI>
+ <xloc>424</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>check result</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to/>
+ <send_false_to/>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <conditions>
+ <condition>
+ <negated>Y</negated>
+ <leftvalue>result_status</leftvalue>
+ <function>=</function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>Integer</type>
+ <text>200</text>
+ <length>-1</length>
+ <precision>0</precision>
+ <isnull>N</isnull>
+ <mask>#</mask>
+ </value>
+ </condition>
+ <condition>
+ <negated>Y</negated>
+ <operator>OR</operator>
+ <leftvalue>header_parameter</leftvalue>
+ <function>=</function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text>headervalue</text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ <mask/>
+ </value>
+ </condition>
+ </conditions>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>588</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>REST client</name>
+ <type>Rest</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <applicationType>TEXT PLAIN</applicationType>
+ <bodyField/>
+ <connectionTimeout/>
+ <connection_name>castlemock</connection_name>
+ <dynamicMethod>N</dynamicMethod>
+ <headers>
+ <header>
+ <field>header</field>
+ <name>header_parameter</name>
+ </header>
+ </headers>
+ <httpLogin/>
+ <httpPassword>Encrypted </httpPassword>
+ <ignoreSsl>N</ignoreSsl>
+ <matrixParameters>
+</matrixParameters>
+ <method>GET</method>
+ <methodFieldName/>
+ <parameters>
+</parameters>
+ <preemptive>N</preemptive>
+ <proxyHost/>
+ <proxyPort/>
+ <readTimeout/>
+ <result>
+ <code>result_status</code>
+ <name>result</name>
+ <response_header/>
+ <response_time/>
+ </result>
+ <trustStoreFile/>
+ <trustStorePassword>Encrypted </trustStorePassword>
+ <url>/get</url>
+ <urlField/>
+ <urlInField>N</urlInField>
+ <attributes/>
+ <GUI>
+ <xloc>260</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git a/integration-tests/http/0022-rest-connection-empty-post-content-length.hpl b/integration-tests/http/0022-rest-connection-empty-post-content-length.hpl
new file mode 100644
index 0000000000..ebe5f30c1f
--- /dev/null
+++ b/integration-tests/http/0022-rest-connection-empty-post-content-length.hpl
@@ -0,0 +1,282 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <name>0022-rest-connection-empty-post-content-length</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Verifies that a body-less POST through a named connection still sends a Content-Length header (issue #7621).</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <pipeline_status>0</pipeline_status>
+ <parameters>
+ <parameter>
+ <name>HOSTNAME</name>
+ <default_value>localhost</default_value>
+ <description/>
+ </parameter>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+ <transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+ <transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/07/27 10:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/07/27 10:00:00.000</modified_date>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>check result</from>
+ <to>Abort</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>JSON input</from>
+ <to>check result</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Generate rows</from>
+ <to>REST client</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>REST client</from>
+ <to>JSON input</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>Abort</name>
+ <type>Abort</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <abort_option>ABORT_WITH_ERROR</abort_option>
+ <always_log_rows>Y</always_log_rows>
+ <row_threshold>0</row_threshold>
+ <attributes/>
+ <GUI>
+ <xloc>752</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Generate rows</name>
+ <type>RowGenerator</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+</fields>
+ <interval_in_ms>5000</interval_in_ms>
+ <last_time_field>FiveSecondsAgo</last_time_field>
+ <never_ending>N</never_ending>
+ <limit>1</limit>
+ <row_time_field>now</row_time_field>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>JSON input</name>
+ <type>JsonInput</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <include>N</include>
+ <include_field/>
+ <rownum>N</rownum>
+ <addresultfile>N</addresultfile>
+ <readurl>N</readurl>
+ <removeSourceField>N</removeSourceField>
+ <IsIgnoreEmptyFile>N</IsIgnoreEmptyFile>
+ <doNotFailIfNoFile>Y</doNotFailIfNoFile>
+ <ignoreMissingPath>Y</ignoreMissingPath>
+ <defaultPathLeafToNull>Y</defaultPathLeafToNull>
+ <rownum_field/>
+ <file>
+ <name/>
+ <filemask/>
+ <exclude_filemask/>
+ <file_required>N</file_required>
+ <include_subfolders>N</include_subfolders>
+ </file>
+ <fields>
+ <field>
+ <name>content_length</name>
+ <path>$.headers.Content-Length</path>
+ <type>String</type>
+ <format/>
+ <currency/>
+ <decimal/>
+ <group/>
+ <length>-1</length>
+ <precision>-1</precision>
+ <trim_type>none</trim_type>
+ <repeat>N</repeat>
+ </field>
+ </fields>
+ <limit>0</limit>
+ <IsInFields>Y</IsInFields>
+ <IsAFile>N</IsAFile>
+ <valueField>result</valueField>
+ <shortFileFieldName/>
+ <pathFieldName/>
+ <hiddenFieldName/>
+ <lastModificationTimeFieldName/>
+ <uriNameFieldName/>
+ <rootUriNameFieldName/>
+ <extensionFieldName/>
+ <sizeFieldName/>
+ <attributes/>
+ <GUI>
+ <xloc>424</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>check result</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to/>
+ <send_false_to/>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <conditions>
+ <condition>
+ <negated>Y</negated>
+ <leftvalue>result_status</leftvalue>
+ <function>=</function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>Integer</type>
+ <text>200</text>
+ <length>-1</length>
+ <precision>0</precision>
+ <isnull>N</isnull>
+ <mask>#</mask>
+ </value>
+ </condition>
+ <condition>
+ <negated>Y</negated>
+ <operator>OR</operator>
+ <leftvalue>content_length</leftvalue>
+ <function>=</function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text>0</text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ <mask/>
+ </value>
+ </condition>
+ </conditions>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>588</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>REST client</name>
+ <type>Rest</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <applicationType>TEXT PLAIN</applicationType>
+ <bodyField/>
+ <connectionTimeout/>
+ <connection_name>castlemock</connection_name>
+ <dynamicMethod>N</dynamicMethod>
+ <headers>
+</headers>
+ <httpLogin/>
+ <httpPassword>Encrypted </httpPassword>
+ <ignoreSsl>N</ignoreSsl>
+ <matrixParameters>
+</matrixParameters>
+ <method>POST</method>
+ <methodFieldName/>
+ <parameters>
+</parameters>
+ <preemptive>N</preemptive>
+ <proxyHost/>
+ <proxyPort/>
+ <readTimeout/>
+ <result>
+ <code>result_status</code>
+ <name>result</name>
+ <response_header/>
+ <response_time/>
+ </result>
+ <trustStoreFile/>
+ <trustStorePassword>Encrypted </trustStorePassword>
+ <url>/post</url>
+ <urlField/>
+ <urlInField>N</urlInField>
+ <attributes/>
+ <GUI>
+ <xloc>260</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git a/integration-tests/http/main-0020-rest-connection-query-parameters.hwf b/integration-tests/http/main-0020-rest-connection-query-parameters.hwf
new file mode 100644
index 0000000000..3c6dacaad0
--- /dev/null
+++ b/integration-tests/http/main-0020-rest-connection-query-parameters.hwf
@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+ <name>main-0020-rest-connection-query-parameters</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description/>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/07/27 10:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/07/27 10:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>50</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>0020-rest-connection-query-parameters.hpl</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <clear_files>N</clear_files>
+ <clear_rows>N</clear_rows>
+ <create_parent_folder>N</create_parent_folder>
+ <exec_per_row>N</exec_per_row>
+ <filename>${PROJECT_HOME}/0020-rest-connection-query-parameters.hpl</filename>
+ <logext/>
+ <logfile/>
+ <loglevel>Basic</loglevel>
+ <parameters>
+ <parameter>
+ <name>HOSTNAME</name>
+ <stream_name/>
+ <value>${HOSTNAME}</value>
+ </parameter>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <params_from_previous>N</params_from_previous>
+ <run_configuration>local</run_configuration>
+ <set_append_logfile>N</set_append_logfile>
+ <set_logfile>N</set_logfile>
+ <wait_until_finished>Y</wait_until_finished>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>64</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>0020-rest-connection-query-parameters.hpl</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git a/integration-tests/http/main-0021-rest-connection-headers.hwf b/integration-tests/http/main-0021-rest-connection-headers.hwf
new file mode 100644
index 0000000000..53d00506e3
--- /dev/null
+++ b/integration-tests/http/main-0021-rest-connection-headers.hwf
@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+ <name>main-0021-rest-connection-headers</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description/>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/07/27 10:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/07/27 10:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>50</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>0021-rest-connection-headers.hpl</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <clear_files>N</clear_files>
+ <clear_rows>N</clear_rows>
+ <create_parent_folder>N</create_parent_folder>
+ <exec_per_row>N</exec_per_row>
+ <filename>${PROJECT_HOME}/0021-rest-connection-headers.hpl</filename>
+ <logext/>
+ <logfile/>
+ <loglevel>Basic</loglevel>
+ <parameters>
+ <parameter>
+ <name>HOSTNAME</name>
+ <stream_name/>
+ <value>${HOSTNAME}</value>
+ </parameter>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <params_from_previous>N</params_from_previous>
+ <run_configuration>local</run_configuration>
+ <set_append_logfile>N</set_append_logfile>
+ <set_logfile>N</set_logfile>
+ <wait_until_finished>Y</wait_until_finished>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>64</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>0021-rest-connection-headers.hpl</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git a/integration-tests/http/main-0022-rest-connection-empty-post-content-length.hwf b/integration-tests/http/main-0022-rest-connection-empty-post-content-length.hwf
new file mode 100644
index 0000000000..5dca9d5da8
--- /dev/null
+++ b/integration-tests/http/main-0022-rest-connection-empty-post-content-length.hwf
@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+ <name>main-0022-rest-connection-empty-post-content-length</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description/>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/07/27 10:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/07/27 10:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>50</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>0022-rest-connection-empty-post-content-length.hpl</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <clear_files>N</clear_files>
+ <clear_rows>N</clear_rows>
+ <create_parent_folder>N</create_parent_folder>
+ <exec_per_row>N</exec_per_row>
+ <filename>${PROJECT_HOME}/0022-rest-connection-empty-post-content-length.hpl</filename>
+ <logext/>
+ <logfile/>
+ <loglevel>Basic</loglevel>
+ <parameters>
+ <parameter>
+ <name>HOSTNAME</name>
+ <stream_name/>
+ <value>${HOSTNAME}</value>
+ </parameter>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <params_from_previous>N</params_from_previous>
+ <run_configuration>local</run_configuration>
+ <set_append_logfile>N</set_append_logfile>
+ <set_logfile>N</set_logfile>
+ <wait_until_finished>Y</wait_until_finished>
+ <parallel>N</parallel>
+ <xloc>256</xloc>
+ <yloc>64</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>0022-rest-connection-empty-post-content-length.hpl</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git a/plugins/misc/rest/src/main/java/org/apache/hop/metadata/rest/RestConnection.java b/plugins/misc/rest/src/main/java/org/apache/hop/metadata/rest/RestConnection.java
index 4674dc5a5d..069de61ca1 100644
--- a/plugins/misc/rest/src/main/java/org/apache/hop/metadata/rest/RestConnection.java
+++ b/plugins/misc/rest/src/main/java/org/apache/hop/metadata/rest/RestConnection.java
@@ -192,8 +192,30 @@ public class RestConnection extends HopMetadataBase implements IHopMetadata {
public Invocation.Builder getInvocationBuilder(String url, String proxyHost, Integer proxyPort)
throws HopException {
+ return getInvocationBuilder(url, proxyHost, proxyPort, 0, 0);
+ }
+
+ /**
+ * Builds a Jersey invocation builder for the given URL. Connect/read timeouts (milliseconds, 0 =
+ * infinite) and proxy settings are applied so that requests made through a REST connection honour
+ * the same timeout, proxy and HTTP-compliance behaviour as the standalone REST transform path
+ * (issue #7621).
+ */
+ public Invocation.Builder getInvocationBuilder(
+ String url, String proxyHost, Integer proxyPort, int connectTimeout, int readTimeout)
+ throws HopException {
builder = ClientBuilder.newBuilder();
builder.property(HttpUrlConnectorProvider.SET_METHOD_WORKAROUND, true);
+ builder.property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true);
+ // Only apply timeouts when configured (>= 0). Empty transform timeout fields resolve to -1,
+ // which Jersey rejects with "timeouts can't be negative"; leaving the property unset keeps
+ // Jersey's default (0 = infinite), matching the previous connection-path behaviour.
+ if (connectTimeout >= 0) {
+ builder.property(ClientProperties.CONNECT_TIMEOUT, connectTimeout);
+ }
+ if (readTimeout >= 0) {
+ builder.property(ClientProperties.READ_TIMEOUT, readTimeout);
+ }
setProxyHost(proxyHost, proxyPort);
// Configure SSL if needed (client cert, trust store, or ignore SSL)
diff --git a/plugins/misc/rest/src/test/java/org/apache/hop/metadata/rest/RestConnectionContentLengthTest.java b/plugins/misc/rest/src/test/java/org/apache/hop/metadata/rest/RestConnectionContentLengthTest.java
new file mode 100644
index 0000000000..a6b99564b9
--- /dev/null
+++ b/plugins/misc/rest/src/test/java/org/apache/hop/metadata/rest/RestConnectionContentLengthTest.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.metadata.rest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import com.sun.net.httpserver.HttpServer;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.client.Invocation;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Regression test for issue #7621: a POST issued through a REST connection must send a {@code
+ * Content-Length} header, just like the standalone URL path does.
+ *
+ * <p>The connection path uses Jersey's default (JDK {@code HttpURLConnection}) connector. That
+ * connector omits {@code Content-Length} for a {@code null} request entity, but sends {@code
+ * Content-Length: 0} for an empty (non-null) entity. {@code Rest.executeRequest} therefore
+ * normalizes a null body to an empty string; this test locks in the connector behaviour that fix
+ * relies on: an empty-body POST through a connection carries {@code Content-Length} and is not sent
+ * with chunked transfer encoding.
+ */
+class RestConnectionContentLengthTest {
+
+ private HttpServer server;
+ private String url;
+
+ @BeforeEach
+ void startServer() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
+ // Echo back the request's Content-Length and whether it arrived chunked, so the test can prove
+ // the connection path sends Content-Length rather than using chunked transfer encoding.
+ server.createContext(
+ "/objects",
+ exchange -> {
+ // Drain the request body before responding.
+ exchange.getRequestBody().readAllBytes();
+ String contentLength = exchange.getRequestHeaders().getFirst("Content-Length");
+ List<String> transferEncoding = exchange.getRequestHeaders().get("Transfer-Encoding");
+ boolean chunked =
+ transferEncoding != null
+ && transferEncoding.stream().anyMatch(v -> v.equalsIgnoreCase("chunked"));
+ byte[] body =
+ ("content-length=" + contentLength + ";chunked=" + chunked)
+ .getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, body.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(body);
+ }
+ });
+ server.start();
+ url = "http://localhost:" + server.getAddress().getPort() + "/objects";
+ }
+
+ @AfterEach
+ void stopServer() {
+ if (server != null) {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void emptyBodyPostThroughConnectionSendsContentLength() throws Exception {
+ RestConnection connection = new RestConnection(new Variables());
+
+ Invocation.Builder invocationBuilder = connection.getInvocationBuilder(url);
+ try (Response response =
+ invocationBuilder.post(Entity.entity("", MediaType.APPLICATION_JSON_TYPE))) {
+
+ assertEquals(200, response.getStatus());
+ assertEquals("content-length=0;chunked=false", response.readEntity(String.class));
+ }
+ }
+}
diff --git a/plugins/misc/rest/src/test/java/org/apache/hop/metadata/rest/RestConnectionTimeoutTest.java b/plugins/misc/rest/src/test/java/org/apache/hop/metadata/rest/RestConnectionTimeoutTest.java
new file mode 100644
index 0000000000..6a3f1e3e3e
--- /dev/null
+++ b/plugins/misc/rest/src/test/java/org/apache/hop/metadata/rest/RestConnectionTimeoutTest.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.metadata.rest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.sun.net.httpserver.HttpServer;
+import jakarta.ws.rs.ProcessingException;
+import jakarta.ws.rs.client.Invocation;
+import jakarta.ws.rs.core.Response;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Regression test for issue #7621: the connect/read timeouts configured on the REST transform must
+ * be honoured when the request goes through a REST connection. Before the fix {@link
+ * RestConnection#getInvocationBuilder} never set {@code CONNECT_TIMEOUT}/{@code READ_TIMEOUT}, so a
+ * slow or hung server would block the pipeline indefinitely on the connection path.
+ */
+class RestConnectionTimeoutTest {
+
+ private HttpServer server;
+ private String url;
+ private String fastUrl;
+
+ @BeforeEach
+ void startServer() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
+ // Withhold the response for far longer than the client read timeout below.
+ server.createContext(
+ "/slow",
+ exchange -> {
+ try {
+ Thread.sleep(1500);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ byte[] body = "ok".getBytes(StandardCharsets.UTF_8);
+ try {
+ exchange.sendResponseHeaders(200, body.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(body);
+ }
+ } catch (Exception ignore) {
+ // The client has already timed out and disconnected.
+ } finally {
+ exchange.close();
+ }
+ });
+ // Immediate response, used to prove a request still succeeds with unset (negative) timeouts.
+ server.createContext(
+ "/fast",
+ exchange -> {
+ byte[] body = "ok".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, body.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(body);
+ }
+ });
+ server.start();
+ url = "http://localhost:" + server.getAddress().getPort() + "/slow";
+ fastUrl = "http://localhost:" + server.getAddress().getPort() + "/fast";
+ }
+
+ @AfterEach
+ void stopServer() {
+ if (server != null) {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void readTimeoutThroughConnectionIsHonoured() throws Exception {
+ RestConnection connection = new RestConnection(new Variables());
+
+ // connectTimeout 2000 ms, readTimeout 300 ms — the server withholds the response for 1500 ms,
+ // so the read must time out (a ProcessingException wrapping a SocketTimeoutException).
+ Invocation.Builder invocationBuilder =
+ connection.getInvocationBuilder(url, null, 8080, 2000, 300);
+
+ assertThrows(ProcessingException.class, () -> invocationBuilder.get(String.class));
+ }
+
+ @Test
+ void negativeTimeoutsAreIgnored() throws Exception {
+ RestConnection connection = new RestConnection(new Variables());
+
+ // Empty transform timeout fields resolve to -1; the connection must not pass a negative timeout
+ // to Jersey (which throws "timeouts can't be negative"). The request should simply succeed.
+ Invocation.Builder invocationBuilder =
+ connection.getInvocationBuilder(fastUrl, null, 8080, -1, -1);
+
+ try (Response response = invocationBuilder.get()) {
+ assertEquals(200, response.getStatus());
+ }
+ }
+}
diff --git a/plugins/transforms/rest/src/main/java/org/apache/hop/pipeline/transforms/rest/Rest.java b/plugins/transforms/rest/src/main/java/org/apache/hop/pipeline/transforms/rest/Rest.java
index 6a761bd818..15af5c381d 100644
--- a/plugins/transforms/rest/src/main/java/org/apache/hop/pipeline/transforms/rest/Rest.java
+++ b/plugins/transforms/rest/src/main/java/org/apache/hop/pipeline/transforms/rest/Rest.java
@@ -220,6 +220,60 @@ public class Rest extends BaseTransform<RestMeta, RestData> {
return ub.build().toString();
}
+ /**
+ * Appends the configured matrix and query parameters (from the current row) to the given URL. The
+ * standalone request path applies these directly to the Jersey {@link WebTarget}; the REST
+ * connection path builds its target inside {@link
+ * org.apache.hop.metadata.rest.RestConnection#getInvocationBuilder}, so we bake the parameters
+ * into the URL here to keep both paths equivalent (issue #7621).
+ */
+ protected String appendMatrixAndQueryParams(String url, Object[] rowData) throws HopException {
+ if (!data.useMatrixParams && !data.useParams) {
+ return url;
+ }
+ UriBuilder builder = UriBuilder.fromUri(URI.create(url));
+ if (data.useMatrixParams) {
+ for (int i = 0; i < data.nrMatrixParams; i++) {
+ String value = data.inputRowMeta.getString(rowData, data.indexOfMatrixParamFields[i]);
+ if (isDebug()) {
+ logDebug(
+ BaseMessages.getString(
+ PKG, "Rest.Log.matrixParameterValue", data.matrixParamNames[i], value));
+ }
+ builder =
+ builder.matrixParam(
+ data.matrixParamNames[i],
+ UriComponent.encode(value, UriComponent.Type.QUERY_PARAM));
+ }
+ }
+ if (data.useParams) {
+ for (int i = 0; i < data.nrParams; i++) {
+ String value = data.inputRowMeta.getString(rowData, data.indexOfParamFields[i]);
+ if (isDebug()) {
+ logDebug(
+ BaseMessages.getString(
+ PKG, "Rest.Log.queryParameterValue", data.paramNames[i], value));
+ }
+ builder = builder.queryParam(data.paramNames[i], value);
+ }
+ }
+ return builder.build().toString();
+ }
+
+ /**
+ * Masks the value of security-sensitive headers so credentials are not written to the log when
+ * header values are logged at debug level.
+ */
+ private static String maskHeaderValue(String name, String value) {
+ if (name != null
+ && ("Authorization".equalsIgnoreCase(name)
+ || "Proxy-Authorization".equalsIgnoreCase(name)
+ || "Cookie".equalsIgnoreCase(name))) {
+ return "********";
+ }
+ return value;
+ }
+
/**
* Executes a single REST exchange and bundles the deserialized pieces required for paging and row
* assembly.
@@ -249,13 +303,28 @@ public class Rest extends BaseTransform<RestMeta, RestData> {
final long httpVolIn0 = dataVolumeIn != null ? dataVolumeIn : 0L;
final long httpVolOut0 = dataVolumeOut != null ? dataVolumeOut : 0L;
try {
- if (isDetailed()) {
- logDetailed(BaseMessages.getString(PKG, "Rest.Log.ConnectingToURL", effectiveBase));
- }
+ String requestUri = effectiveBase;
if (!StringUtils.isEmpty(meta.getConnectionName())) {
+ // The connection builds its own Jersey target internally, so bake the matrix/query
+ // parameters into the URL and pass the timeouts through, to keep this path equivalent to
+ // the standalone path below (issue #7621).
+ requestUri = appendMatrixAndQueryParams(effectiveBase, rowData);
+ if (isDetailed()) {
+ logDetailed(
+ BaseMessages.getString(
+ PKG, "Rest.Log.UsingConnection", meta.getConnectionName(), NVL(baseUrl, "")));
+ }
invocationBuilder =
- connection.getInvocationBuilder(effectiveBase, data.realProxyHost, data.realProxyPort);
+ connection.getInvocationBuilder(
+ requestUri,
+ data.realProxyHost,
+ data.realProxyPort,
+ data.realConnectionTimeout,
+ data.realReadTimeout);
} else {
+ if (isDetailed()) {
+ logDetailed(BaseMessages.getString(PKG, "Rest.Log.NoConnection"));
+ }
ClientBuilder clientBuilder = createClientBuilder();
clientBuilder
.withConfig(data.config)
@@ -300,14 +369,21 @@ public class Rest extends BaseTransform<RestMeta, RestData> {
webResource = webResource.queryParam(data.paramNames[i], value);
}
}
- if (isDebug()) {
- logDebug(BaseMessages.getString(PKG, "Rest.Log.ConnectingToURL", webResource.getUri()));
- }
invocationBuilder = webResource.request();
+ requestUri = webResource.getUri().toString();
}
if (invocationBuilder == null) {
throw new HopException("Invocation builder not initialized");
}
+ if (isDetailed()) {
+ logDetailed(
+ BaseMessages.getString(PKG, "Rest.Log.SendingRequest", data.method, requestUri));
+ }
+ if (isDebug()) {
+ logDebug(
+ BaseMessages.getString(
+ PKG, "Rest.Log.Timeouts", data.realConnectionTimeout, data.realReadTimeout));
+ }
MultivaluedMap<String, Object> headerMap = new MultivaluedHashMap<>();
@@ -317,6 +393,22 @@ public class Rest extends BaseTransform<RestMeta, RestData> {
for (int i = 0; i < data.nrheader; i++) {
String value = data.inputRowMeta.getString(rowData, data.indexOfHeaderFields[i]);
+ // Content-Length is a restricted header computed by the HTTP client when the request
+ // entity is buffered. Setting it manually throws "Content-Length already defined" on
+ // some connectors, so we ignore any user-supplied value and let the client manage it
+ // (issue #7621).
+ if ("Content-Length".equalsIgnoreCase(data.headerNames[i])) {
+ if (isDebug()) {
+ logDebug(
+ BaseMessages.getString(
+ PKG,
+ "Rest.Log.HeaderValue",
+ data.headerNames[i],
+ maskHeaderValue(data.headerNames[i], value)));
+ }
+ continue;
+ }
+
headerMap.putSingle(data.headerNames[i], value);
if ("Content-Type".equals(data.headerNames[i])) {
contentType = value;
@@ -326,7 +418,11 @@ public class Rest extends BaseTransform<RestMeta, RestData> {
}
if (isDebug()) {
logDebug(
- BaseMessages.getString(PKG, "Rest.Log.HeaderValue", data.headerNames[i], value));
+ BaseMessages.getString(
+ PKG,
+ "Rest.Log.HeaderValue",
+ data.headerNames[i],
+ maskHeaderValue(data.headerNames[i], value)));
}
}
}
@@ -376,6 +472,14 @@ public class Rest extends BaseTransform<RestMeta, RestData> {
final String finalEntityString = entityString;
final String finalContentType = contentType;
+ if (isDebug()) {
+ logDebug(
+ BaseMessages.getString(
+ PKG,
+ "Rest.Log.RequestContentType",
+ contentType != null ? contentType : String.valueOf(data.mediaType)));
+ }
+
startTime = System.currentTimeMillis();
response =
executeWithRetry(
@@ -394,6 +498,15 @@ public class Rest extends BaseTransform<RestMeta, RestData> {
long responseTime = System.currentTimeMillis() - startTime;
int status = response.getStatus();
+ if (isDetailed()) {
+ logDetailed(BaseMessages.getString(PKG, "Rest.Log.ResponseCode", status));
+ logDetailed(BaseMessages.getString(PKG, "Rest.Log.ResponseTime", responseTime, requestUri));
+ if (status >= 400) {
+ logDetailed(
+ BaseMessages.getString(
+ PKG, "Rest.Log.ResponseError", data.method, requestUri, status));
+ }
+ }
if (response.hasEntity()) {
response.bufferEntity();
@@ -442,6 +555,9 @@ public class Rest extends BaseTransform<RestMeta, RestData> {
}
}
trackResponseBytes(response, body);
+ if (isRowLevel()) {
+ logRowlevel(BaseMessages.getString(PKG, "Rest.Log.ResponseBody", body));
+ }
MultivaluedMap<String, Object> headers = searchForHeaders(response);
JSONObject json = new JSONObject();
@@ -1212,6 +1328,11 @@ public class Rest extends BaseTransform<RestMeta, RestData> {
private Response executeRequest(
Invocation.Builder invocationBuilder, String entityString, String contentType)
throws HopException {
+ // A body-less POST/PUT/PATCH/DELETE must still carry a Content-Length header (issue #7621).
+ // The JDK HttpURLConnection connector used by the REST-connection path omits Content-Length for
+ // a null entity, whereas the standalone (Apache) connector sends Content-Length: 0. Normalizing
+ // a null body to an empty string makes both request paths send Content-Length consistently.
+ String body = entityString == null ? "" : entityString;
try {
switch (data.method) {
case RestMeta.HTTP_METHOD_GET -> {
@@ -1222,9 +1343,9 @@ public class Rest extends BaseTransform<RestMeta, RestData> {
entityString,
contentType != null ? resolveCharset(contentType) : resolveCharset(data.mediaType));
if (null != contentType) {
- return invocationBuilder.post(Entity.entity(entityString, contentType));
+ return invocationBuilder.post(Entity.entity(body, contentType));
} else {
- return invocationBuilder.post(Entity.entity(entityString, data.mediaType));
+ return invocationBuilder.post(Entity.entity(body, data.mediaType));
}
}
case RestMeta.HTTP_METHOD_PUT -> {
@@ -1232,9 +1353,9 @@ public class Rest extends BaseTransform<RestMeta, RestData> {
entityString,
contentType != null ? resolveCharset(contentType) : resolveCharset(data.mediaType));
if (null != contentType) {
- return invocationBuilder.put(Entity.entity(entityString, contentType));
+ return invocationBuilder.put(Entity.entity(body, contentType));
} else {
- return invocationBuilder.put(Entity.entity(entityString, data.mediaType));
+ return invocationBuilder.put(Entity.entity(body, data.mediaType));
}
}
case RestMeta.HTTP_METHOD_DELETE -> {
@@ -1242,7 +1363,7 @@ public class Rest extends BaseTransform<RestMeta, RestData> {
entityString,
contentType != null ? resolveCharset(contentType) : resolveCharset(data.mediaType));
Invocation invocation =
- invocationBuilder.build("DELETE", Entity.entity(entityString, data.mediaType));
+ invocationBuilder.build("DELETE", Entity.entity(body, data.mediaType));
return invocation.invoke();
}
case RestMeta.HTTP_METHOD_HEAD -> {
@@ -1257,10 +1378,10 @@ public class Rest extends BaseTransform<RestMeta, RestData> {
contentType != null ? resolveCharset(contentType) : resolveCharset(data.mediaType));
if (null != contentType) {
return invocationBuilder.method(
- RestMeta.HTTP_METHOD_PATCH, Entity.entity(entityString, contentType));
+ RestMeta.HTTP_METHOD_PATCH, Entity.entity(body, contentType));
} else {
return invocationBuilder.method(
- RestMeta.HTTP_METHOD_PATCH, Entity.entity(entityString, data.mediaType));
+ RestMeta.HTTP_METHOD_PATCH, Entity.entity(body, data.mediaType));
}
}
default ->
diff --git a/plugins/transforms/rest/src/main/java/org/apache/hop/pipeline/transforms/rest/RestDialog.java b/plugins/transforms/rest/src/main/java/org/apache/hop/pipeline/transforms/rest/RestDialog.java
index 83364eb56e..593c383922 100644
--- a/plugins/transforms/rest/src/main/java/org/apache/hop/pipeline/transforms/rest/RestDialog.java
+++ b/plugins/transforms/rest/src/main/java/org/apache/hop/pipeline/transforms/rest/RestDialog.java
@@ -677,11 +677,12 @@ public class RestDialog extends BaseTransformDialog {
int margin,
CTabItem wMatrixParametersTab,
Composite wMatrixParametersComp) {
- wlMatrixParameters = new Label(wMatrixParametersComp, SWT.NONE);
- wlMatrixParameters.setText(BaseMessages.getString(PKG, "RestDialog.Parameters.Label"));
+ wlMatrixParameters = new Label(wMatrixParametersComp, SWT.LEFT | SWT.WRAP);
+ wlMatrixParameters.setText(BaseMessages.getString(PKG, "RestDialog.MatrixParameters.Info"));
PropsUi.setLook(wlMatrixParameters);
FormData fdlMatrixParameters = new FormData();
fdlMatrixParameters.left = new FormAttachment(0, 0);
+ fdlMatrixParameters.right = new FormAttachment(100, -margin);
fdlMatrixParameters.top = new FormAttachment(wSpacer, margin);
wlMatrixParameters.setLayoutData(fdlMatrixParameters);
@@ -697,12 +698,12 @@ public class RestDialog extends BaseTransformDialog {
matrixParameterColumns =
new ColumnInfo[] {
new ColumnInfo(
- BaseMessages.getString(PKG, "RestDialog.ColumnInfo.ParameterField"),
+ BaseMessages.getString(PKG, "RestDialog.ColumnInfo.MatrixParameterValueField"),
ColumnInfo.COLUMN_TYPE_CCOMBO,
new String[] {""},
false),
new ColumnInfo(
- BaseMessages.getString(PKG, "RestDialog.ColumnInfo.ParameterName"),
+ BaseMessages.getString(PKG, "RestDialog.ColumnInfo.MatrixParameterName"),
ColumnInfo.COLUMN_TYPE_TEXT,
false),
};
@@ -742,11 +743,12 @@ public class RestDialog extends BaseTransformDialog {
private void setupParameterTabContent(
ModifyListener lsMod, int margin, CTabItem wParametersTab, Composite wParametersComp) {
- wlParameters = new Label(wParametersComp, SWT.NONE);
- wlParameters.setText(BaseMessages.getString(PKG, "RestDialog.Parameters.Label"));
+ wlParameters = new Label(wParametersComp, SWT.LEFT | SWT.WRAP);
+ wlParameters.setText(BaseMessages.getString(PKG, "RestDialog.Parameters.Info"));
PropsUi.setLook(wlParameters);
FormData fdlParameters = new FormData();
fdlParameters.left = new FormAttachment(0, 0);
+ fdlParameters.right = new FormAttachment(100, -margin);
fdlParameters.top = new FormAttachment(wSpacer, margin);
wlParameters.setLayoutData(fdlParameters);
@@ -762,7 +764,7 @@ public class RestDialog extends BaseTransformDialog {
queryParameterColumns =
new ColumnInfo[] {
new ColumnInfo(
- BaseMessages.getString(PKG, "RestDialog.ColumnInfo.ParameterField"),
+ BaseMessages.getString(PKG, "RestDialog.ColumnInfo.ParameterValueField"),
ColumnInfo.COLUMN_TYPE_CCOMBO,
new String[] {""},
false),
@@ -817,12 +819,12 @@ public class RestDialog extends BaseTransformDialog {
colinf =
new ColumnInfo[] {
new ColumnInfo(
- BaseMessages.getString(PKG, "RestDialog.ColumnInfo.Field"),
+ BaseMessages.getString(PKG, "RestDialog.ColumnInfo.HeaderValueField"),
ColumnInfo.COLUMN_TYPE_CCOMBO,
new String[] {""},
false),
new ColumnInfo(
- BaseMessages.getString(PKG, "RestDialog.ColumnInfo.Name"),
+ BaseMessages.getString(PKG, "RestDialog.ColumnInfo.HeaderName"),
ColumnInfo.COLUMN_TYPE_TEXT,
false)
};
diff --git a/plugins/transforms/rest/src/main/resources/org/apache/hop/pipeline/transforms/rest/messages/messages_en_US.properties b/plugins/transforms/rest/src/main/resources/org/apache/hop/pipeline/transforms/rest/messages/messages_en_US.properties
index 40edaee08f..25153e17db 100644
--- a/plugins/transforms/rest/src/main/resources/org/apache/hop/pipeline/transforms/rest/messages/messages_en_US.properties
+++ b/plugins/transforms/rest/src/main/resources/org/apache/hop/pipeline/transforms/rest/messages/messages_en_US.properties
@@ -43,6 +43,13 @@ Rest.Log.NoField=URL field name is missing\!
Rest.Log.queryParameterValue=Query parameter [{0}]\=''{1}''
Rest.Log.ResponseCode=The response code is {0}
Rest.Log.ResponseTime=Response time (milliseconds)\: [{0}] for [{1}]
+Rest.Log.ResponseBody=Response body [{0}]
+Rest.Log.ResponseError=REST [{0}] request to [{1}] returned HTTP status [{2}]
+Rest.Log.SendingRequest=Sending [{0}] request to [{1}]
+Rest.Log.Timeouts=Applied connect timeout [{0}] ms and read timeout [{1}] ms (-1 \= not set, engine default is used)
+Rest.Log.UsingConnection=Using REST connection [{0}] with base URL [{1}]
+Rest.Log.NoConnection=No REST connection configured, connecting to the URL directly
+Rest.Log.RequestContentType=Request Content-Type [{0}]
Rest.Log.PagingEmptyJsonArrayFirstPage=REST paging: HTTP success with an empty JSON array ([]); no rows to emit.
Rest.Log.PagingEmptyCollectionFirstPage=REST paging: HTTP success with an empty collection at split path "{0}"; no rows to emit.
Rest.Log.LinkPaginationStoppedRepeatedUrl=REST Link header pagination stopped: the next request URL was already fetched ({0}), avoiding an infinite loop when rel=\"next\" repeats or cycles.
@@ -54,10 +61,12 @@ Rest.Name=REST client
RestDialog.ApplicationType.Label=Application type
RestDialog.Auth.Title=Authentication
RestDialog.Body.Label=Body field
-RestDialog.ColumnInfo.Field=Field
-RestDialog.ColumnInfo.Name=Name
-RestDialog.ColumnInfo.ParameterField=Parameter field
+RestDialog.ColumnInfo.HeaderName=Header name
+RestDialog.ColumnInfo.HeaderValueField=Header value field
+RestDialog.ColumnInfo.MatrixParameterName=Matrix parameter name
+RestDialog.ColumnInfo.MatrixParameterValueField=Matrix parameter value field
RestDialog.ColumnInfo.ParameterName=Parameter name
+RestDialog.ColumnInfo.ParameterValueField=Parameter value field
RestDialog.ConnectionTimeout.Label=Connection timeout (milliseconds)
RestDialog.ConnectionTimeout.Tooltip=The timeout until a connection is established (milliseconds)
RestDialog.FailedToGetFields.DialogMessage=Error getting fields from incoming stream
@@ -77,13 +86,14 @@ RestDialog.HttpPassword.Tooltip=HTTP password used to authenticate the call
RestDialog.IgnoreSsl.Label=Ignore SSL certificate check
RestDialog.Log.FoundArguments=Found {0} arguments
RestDialog.Log.GettingKeyInfo=getting key info...
+RestDialog.MatrixParameters.Info=Matrix parameters are added to the URL path, e.g. https://host/path;name1\=value1;name2\=value2
RestDialog.MatrixParameters.Title=Matrix Parameters
RestDialog.Method.Label=HTTP method
RestDialog.MethodField.Label=Method field name
RestDialog.MethodInField.Label=Get Method from field
RestDialog.OutputFieldsGroup.Label=Output fields
-RestDialog.Parameters.Label=Parameters
-RestDialog.Parameters.Title=Parameters
+RestDialog.Parameters.Info=Query parameters are added to the end of the URL, e.g. https://host/path?name1\=value1&name2\=value2
+RestDialog.Parameters.Title=Query Parameters
RestDialog.Preemptive.Label=Use preemptive authentication
RestDialog.ProxyGroup.Label=Proxy to use
RestDialog.ProxyHost.Label=Proxy Host
diff --git a/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestCallRestTest.java b/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestCallRestTest.java
index 7552e6d608..1cf15c9b19 100644
--- a/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestCallRestTest.java
+++ b/plugins/transforms/rest/src/test/java/org/apache/hop/pipeline/transforms/rest/RestCallRestTest.java
@@ -67,6 +67,7 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
+import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
@@ -259,6 +260,134 @@ class RestCallRestTest {
}
}
+ @Test
+ void testCallRestPostWithoutBodySendsEmptyEntity() throws HopException {
+ // Issue #7621: a body-less POST must still produce a Content-Length header. The transform
+ // normalizes a null body to an empty string so the JDK connector used by the REST-connection
+ // path sends Content-Length: 0 instead of omitting it. Here we assert the transform posts a
+ // non-null, empty entity rather than a null one.
+ Response response = mock(Response.class);
+ when(response.getStatus()).thenReturn(200);
+ when(response.readEntity(String.class)).thenReturn("");
+ when(response.getHeaders()).thenReturn(new MultivaluedHashMap<>());
+
+ Invocation.Builder builder = mock(Invocation.Builder.class);
+ when(builder.post(any(Entity.class))).thenReturn(response);
+ when(builder.header(anyString(), any())).thenReturn(builder);
+ when(builder.accept((MediaType[]) any())).thenReturn(builder);
+
+ WebTarget webTarget = mock(WebTarget.class);
+ when(webTarget.request()).thenReturn(builder);
+ when(webTarget.getUri()).thenReturn(URI.create("http://example.com/api"));
+
+ Client client = mock(Client.class);
+ when(client.target(anyString())).thenReturn(webTarget);
+
+ ClientBuilder clientBuilder = mock(ClientBuilder.class);
+ when(clientBuilder.withConfig(any(ClientConfig.class))).thenReturn(clientBuilder);
+ when(clientBuilder.property(anyString(), any())).thenReturn(clientBuilder);
+ when(clientBuilder.hostnameVerifier(any())).thenReturn(clientBuilder);
+ when(clientBuilder.sslContext(any())).thenReturn(clientBuilder);
+ when(clientBuilder.build()).thenReturn(client);
+
+ try (MockedStatic<ClientBuilder> mockedStatic = Mockito.mockStatic(ClientBuilder.class)) {
+ mockedStatic.when(ClientBuilder::newBuilder).thenReturn(clientBuilder);
+
+ TransformMeta transformMeta = new TransformMeta();
+ transformMeta.setName("TestRest");
+ PipelineMeta pipelineMeta = new PipelineMeta();
+ pipelineMeta.setName("TestRest");
+ pipelineMeta.addTransform(transformMeta);
+
+ RestMeta meta = new RestMeta();
+ meta.setMethod(RestMeta.HTTP_METHOD_POST);
+ meta.setUrl("http://example.com/api");
+ meta.setResultField(new ResultField());
+ meta.getResultField().setFieldName("result");
+
+ RestData data = new RestData();
+ data.config = new ClientConfig();
+ data.mediaType = MediaType.APPLICATION_JSON_TYPE;
+ data.method = RestMeta.HTTP_METHOD_POST;
+ data.realUrl = "http://example.com/api";
+ data.resultFieldName = "result";
+ data.useBody = false; // no body configured -> entityString is null
+
+ IRowMeta inputRowMeta = new RowMeta();
+ inputRowMeta.addValueMeta(new ValueMetaString("field1"));
+ data.inputRowMeta = inputRowMeta;
+
+ Rest rest =
+ spy(new Rest(transformMeta, meta, data, 0, pipelineMeta, spy(new LocalPipelineEngine())));
+ when(rest.createClientBuilder()).thenReturn(clientBuilder);
+ rest.setMetadataProvider(mock(IHopMetadataProvider.class));
+
+ Object[] outputRow = rest.callRest(new Object[] {"value1"});
+
+ assertNotNull(outputRow);
+ ArgumentCaptor<Entity> entityCaptor = ArgumentCaptor.forClass(Entity.class);
+ verify(builder, times(1)).post(entityCaptor.capture());
+ assertEquals("", entityCaptor.getValue().getEntity());
+ }
+ }
+
+ @Test
+ void testAppendMatrixAndQueryParamsBakesParamsIntoUrl() throws HopException {
+ // Issue #7621: the REST-connection path builds its target inside RestConnection, so the
+ // transform bakes the configured matrix/query parameters into the URL to stay equivalent to the
+ // standalone path. This verifies both parameter kinds end up on the resulting URL.
+ RestData data = new RestData();
+ data.useParams = true;
+ data.nrParams = 2;
+ data.paramNames = new String[] {"q", "lang"};
+ data.indexOfParamFields = new int[] {0, 1};
+ data.useMatrixParams = true;
+ data.nrMatrixParams = 1;
+ data.matrixParamNames = new String[] {"author"};
+ data.indexOfMatrixParamFields = new int[] {2};
+
+ IRowMeta inputRowMeta = new RowMeta();
+ inputRowMeta.addValueMeta(new ValueMetaString("qField"));
+ inputRowMeta.addValueMeta(new ValueMetaString("langField"));
+ inputRowMeta.addValueMeta(new ValueMetaString("authorField"));
+ data.inputRowMeta = inputRowMeta;
+
+ TransformMeta transformMeta = new TransformMeta();
+ transformMeta.setName("TestRest");
+ PipelineMeta pipelineMeta = new PipelineMeta();
+ pipelineMeta.setName("TestRest");
+ pipelineMeta.addTransform(transformMeta);
+
+ Rest rest =
+ new Rest(transformMeta, new RestMeta(), data, 0, pipelineMeta, new LocalPipelineEngine());
+
+ Object[] row = new Object[] {"search", "en", "shakespeare"};
+ String url = rest.appendMatrixAndQueryParams("http://example.com/api", row);
+
+ assertTrue(url.contains("q=search"), url);
+ assertTrue(url.contains("lang=en"), url);
+ assertTrue(url.contains("author=shakespeare"), url);
+ }
+
+ @Test
+ void testAppendMatrixAndQueryParamsNoParamsReturnsUrlUnchanged() throws HopException {
+ RestData data = new RestData();
+ data.useParams = false;
+ data.useMatrixParams = false;
+
+ TransformMeta transformMeta = new TransformMeta();
+ transformMeta.setName("TestRest");
+ PipelineMeta pipelineMeta = new PipelineMeta();
+ pipelineMeta.setName("TestRest");
+ pipelineMeta.addTransform(transformMeta);
+
+ Rest rest =
+ new Rest(transformMeta, new RestMeta(), data, 0, pipelineMeta, new LocalPipelineEngine());
+
+ String url = "http://example.com/api?existing=1";
+ assertEquals(url, rest.appendMatrixAndQueryParams(url, new Object[] {}));
+ }
+
@Test
void testCallRestWithPutMethod() throws HopException {
// Setup mocks