This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26159-salesforce-map-body in repository https://gitbox.apache.org/repos/asf/camel.git
commit 7a1394dc35539d8f5844fb1ed5f15d8e6c54f81a Author: Claus Ibsen <[email protected]> AuthorDate: Mon Nov 2 18:01:04 2026 +0100 CAMEL-26159: camel-salesforce - send a Map or POJO body as JSON, fail raw on a body it cannot send The REST operations sent a Map or another non-DTO object as its toString (for example {Name=x}); they now marshal it with the object mapper, as for DTOs. Text and simple values are still sent as is. The raw operation sent an empty request when the body could not be converted to an InputStream (such as a Map); it now fails with an InvalidPayloadException. The SObject Collections operations completed the exchange with no exception and no body when the response could not be parsed; they now set a SalesforceException. The rawPayload option description now says the REST producer returns an InputStream, not a String. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../camel/catalog/components/salesforce.json | 6 +- .../camel/catalog/docs/salesforce-component.adoc | 2 +- .../camel/catalog/docs/salesforce-rest-api.adoc | 3 + .../camel/component/salesforce/salesforce.json | 6 +- .../src/main/docs/salesforce-component.adoc | 2 +- .../src/main/docs/salesforce-rest-api.adoc | 3 + .../component/salesforce/SalesforceEndpoint.java | 9 +- .../salesforce/SalesforceEndpointConfig.java | 5 +- .../CompositeSObjectCollectionsProcessor.java | 3 + .../internal/processor/JsonRestProcessor.java | 13 ++ .../internal/processor/RawProcessor.java | 4 +- .../salesforce/ProducerRequestBodyTest.java | 163 +++++++++++++++++++++ .../internal/processor/JsonRestProcessorTest.java | 49 +++++++ .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 9 ++ .../dsl/SalesforceComponentBuilderFactory.java | 6 +- .../dsl/SalesforceEndpointBuilderFactory.java | 36 +++-- 16 files changed, 290 insertions(+), 29 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/salesforce.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/salesforce.json index 14820edd6d69..250fccb2073c 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/salesforce.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/salesforce.json @@ -62,7 +62,7 @@ "pkChunkingParent": { "index": 34, "kind": "property", "displayName": "Pk Chunking Parent", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "config", "description": "Specifies the parent object when you're enabling PK chunking for queries on sharing objects. The chun [...] "pkChunkingStartRow": { "index": 35, "kind": "property", "displayName": "Pk Chunking Start Row", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "config", "description": "Specifies the 15-character or 18-character record ID to be used as the lower boundary for the fi [...] "queryLocator": { "index": 36, "kind": "property", "displayName": "Query Locator", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "config", "description": "Query Locator provided by salesforce for use when a query results in more records than can be retrieved in a s [...] - "rawPayload": { "index": 37, "kind": "property", "displayName": "Raw Payload", "group": "common", "label": "", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "config", "description": "Use raw payload String for request and response (either JSON or XML depending on format), instead o [...] + "rawPayload": { "index": 37, "kind": "property", "displayName": "Raw Payload", "group": "common", "label": "", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "config", "description": "Use raw payload for request and response, instead of DTOs, false by default. The REST, apexCall and [...] "reportId": { "index": 38, "kind": "property", "displayName": "Report Id", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "config", "description": "Salesforce1 Analytics report Id" }, "reportMetadata": { "index": 39, "kind": "property", "displayName": "Report Metadata", "group": "common", "label": "", "required": false, "type": "object", "javaType": "org.apache.camel.component.salesforce.api.dto.analytics.reports.ReportMetadata", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "config", "description": "Salesforce1 Analytics report metadata for fi [...] "resultId": { "index": 40, "kind": "property", "displayName": "Result Id", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "config", "description": "Identifies one result set of a completed Bulk API v1 query batch, as returned by the getQueryResultIds operation. Used [...] @@ -162,7 +162,7 @@ }, "bodyTypes": { "consumerOut": { "carriers": [ "map", "pojo" ], "byOption": [ { "option": "operationName", "value": "pubSubSubscribe", "carriers": [ "pojo" ] }, { "option": "rawPayload", "value": "true", "carriers": [ "text", "pojo" ] }, { "option": "pubSubDeserializeType", "value": "JSON", "carriers": [ "text", "map", "pojo" ] } ], "description": "The subscribe operation gives a push topic record as a Map, or an object of sObjectClass (or the DTO of sObjectName), a platform event (\/event\/) as a P [...] - "producerIn": { "carriers": [ "text", "bytes", "stream", "file", "pojo", "list", "map", "null" ], "description": "The REST operations send a generated DTO (an AbstractSObjectBase) as JSON, or JSON text, bytes, a stream or a file as is; a Map or another object is sent as its toString, not as JSON: marshal it first. Text in the body is the id, SOQL or SOSL when the option or header is not set. apexCall takes a Map as query parameters; bulk operations take a JobInfo, BatchInfo or Job, a [...] + "producerIn": { "carriers": [ "text", "bytes", "stream", "file", "pojo", "list", "map", "null" ], "description": "The REST operations send a generated DTO (an AbstractSObjectBase), a Map or another object as JSON, or JSON text, bytes, a stream or a file as is. Text in the body is the id, SOQL or SOSL when the option or header is not set. apexCall takes a Map as query parameters; bulk operations take a JobInfo, BatchInfo or Job, and createBatch the CSV or XML records as a stream; comp [...] "producerOut": { "carriers": [ "pojo", "list", "map", "stream", "unchanged", "null" ], "dependsOn": [ "streamQueryResult", "notFoundBehaviour" ], "byOption": [ { "option": "operationName", "value": "createSObject", "carriers": [ "pojo" ], "javaType": "org.apache.camel.component.salesforce.api.dto.CreateSObjectResult" }, { "option": "operationName", "value": "upsertSObject", "carriers": [ "pojo", "unchanged" ] }, { "option": "operationName", "value": "updateSObject", "carriers": [ "un [...] }, "properties": { @@ -199,7 +199,7 @@ "pkChunkingParent": { "index": 30, "kind": "parameter", "displayName": "Pk Chunking Parent", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "configuration", "description": "Specifies the parent object when you're enabling PK chunking for queries on sharing objects. [...] "pkChunkingStartRow": { "index": 31, "kind": "parameter", "displayName": "Pk Chunking Start Row", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "configuration", "description": "Specifies the 15-character or 18-character record ID to be used as the lower boundary fo [...] "queryLocator": { "index": 32, "kind": "parameter", "displayName": "Query Locator", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "configuration", "description": "Query Locator provided by salesforce for use when a query results in more records than can be retrieve [...] - "rawPayload": { "index": 33, "kind": "parameter", "displayName": "Raw Payload", "group": "common", "label": "", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "configuration", "description": "Use raw payload String for request and response (either JSON or XML depending on format), i [...] + "rawPayload": { "index": 33, "kind": "parameter", "displayName": "Raw Payload", "group": "common", "label": "", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "configuration", "description": "Use raw payload for request and response, instead of DTOs, false by default. The REST, apex [...] "reportId": { "index": 34, "kind": "parameter", "displayName": "Report Id", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "configuration", "description": "Salesforce1 Analytics report Id" }, "reportMetadata": { "index": 35, "kind": "parameter", "displayName": "Report Metadata", "group": "common", "label": "", "required": false, "type": "object", "javaType": "org.apache.camel.component.salesforce.api.dto.analytics.reports.ReportMetadata", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "configuration", "description": "Salesforce1 Analytics report metadat [...] "resultId": { "index": 36, "kind": "parameter", "displayName": "Result Id", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "configuration", "description": "Identifies one result set of a completed Bulk API v1 query batch, as returned by the getQueryResultIds operati [...] diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/salesforce-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/salesforce-component.adoc index a40ad7f27145..6e636d2f7688 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/salesforce-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/salesforce-component.adoc @@ -267,7 +267,7 @@ header will be automatically set based on the `format` option, but this can be o |=== | Parameter | Type | Description| Default| Required -| Body | `String` or `InputStream` | Body of the HTTP request | | +| Body | `String` or `InputStream` | Body of the HTTP request. A body that cannot be converted to an `InputStream`, such as a `Map`, fails with an `InvalidPayloadException`. | | | `rawPath` | `String` | The portion of the endpoint URL after the domain name, e.g., '/services/data/v51.0/sobjects/Account/' | | x | `rawMethod` | `String` | The HTTP method | | x | `rawQueryParameters` | `String` | Comma separated list of message headers to include as query parameters. Do not url-encode values as this will be done automatically. | | diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/salesforce-rest-api.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/salesforce-rest-api.adoc index 4f7dcb91491b..7d446454ca6d 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/salesforce-rest-api.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/salesforce-rest-api.adoc @@ -41,6 +41,9 @@ request as the input to a subsequent request. Unless otherwise specified, DTO types for the following options are from `org.apache.camel.component.salesforce.api.dto` or one if its sub-packages. +The operations that send a request body (such as `createSObject`, `updateSObject` and `upsertSObject`) send an SObject DTO, +a `Map` or another object as JSON, and JSON text, bytes, a stream or a file as is. + [[getVersions]] == Versions diff --git a/components/camel-salesforce/camel-salesforce-component/src/generated/resources/META-INF/org/apache/camel/component/salesforce/salesforce.json b/components/camel-salesforce/camel-salesforce-component/src/generated/resources/META-INF/org/apache/camel/component/salesforce/salesforce.json index 14820edd6d69..250fccb2073c 100644 --- a/components/camel-salesforce/camel-salesforce-component/src/generated/resources/META-INF/org/apache/camel/component/salesforce/salesforce.json +++ b/components/camel-salesforce/camel-salesforce-component/src/generated/resources/META-INF/org/apache/camel/component/salesforce/salesforce.json @@ -62,7 +62,7 @@ "pkChunkingParent": { "index": 34, "kind": "property", "displayName": "Pk Chunking Parent", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "config", "description": "Specifies the parent object when you're enabling PK chunking for queries on sharing objects. The chun [...] "pkChunkingStartRow": { "index": 35, "kind": "property", "displayName": "Pk Chunking Start Row", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "config", "description": "Specifies the 15-character or 18-character record ID to be used as the lower boundary for the fi [...] "queryLocator": { "index": 36, "kind": "property", "displayName": "Query Locator", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "config", "description": "Query Locator provided by salesforce for use when a query results in more records than can be retrieved in a s [...] - "rawPayload": { "index": 37, "kind": "property", "displayName": "Raw Payload", "group": "common", "label": "", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "config", "description": "Use raw payload String for request and response (either JSON or XML depending on format), instead o [...] + "rawPayload": { "index": 37, "kind": "property", "displayName": "Raw Payload", "group": "common", "label": "", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "config", "description": "Use raw payload for request and response, instead of DTOs, false by default. The REST, apexCall and [...] "reportId": { "index": 38, "kind": "property", "displayName": "Report Id", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "config", "description": "Salesforce1 Analytics report Id" }, "reportMetadata": { "index": 39, "kind": "property", "displayName": "Report Metadata", "group": "common", "label": "", "required": false, "type": "object", "javaType": "org.apache.camel.component.salesforce.api.dto.analytics.reports.ReportMetadata", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "config", "description": "Salesforce1 Analytics report metadata for fi [...] "resultId": { "index": 40, "kind": "property", "displayName": "Result Id", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "config", "description": "Identifies one result set of a completed Bulk API v1 query batch, as returned by the getQueryResultIds operation. Used [...] @@ -162,7 +162,7 @@ }, "bodyTypes": { "consumerOut": { "carriers": [ "map", "pojo" ], "byOption": [ { "option": "operationName", "value": "pubSubSubscribe", "carriers": [ "pojo" ] }, { "option": "rawPayload", "value": "true", "carriers": [ "text", "pojo" ] }, { "option": "pubSubDeserializeType", "value": "JSON", "carriers": [ "text", "map", "pojo" ] } ], "description": "The subscribe operation gives a push topic record as a Map, or an object of sObjectClass (or the DTO of sObjectName), a platform event (\/event\/) as a P [...] - "producerIn": { "carriers": [ "text", "bytes", "stream", "file", "pojo", "list", "map", "null" ], "description": "The REST operations send a generated DTO (an AbstractSObjectBase) as JSON, or JSON text, bytes, a stream or a file as is; a Map or another object is sent as its toString, not as JSON: marshal it first. Text in the body is the id, SOQL or SOSL when the option or header is not set. apexCall takes a Map as query parameters; bulk operations take a JobInfo, BatchInfo or Job, a [...] + "producerIn": { "carriers": [ "text", "bytes", "stream", "file", "pojo", "list", "map", "null" ], "description": "The REST operations send a generated DTO (an AbstractSObjectBase), a Map or another object as JSON, or JSON text, bytes, a stream or a file as is. Text in the body is the id, SOQL or SOSL when the option or header is not set. apexCall takes a Map as query parameters; bulk operations take a JobInfo, BatchInfo or Job, and createBatch the CSV or XML records as a stream; comp [...] "producerOut": { "carriers": [ "pojo", "list", "map", "stream", "unchanged", "null" ], "dependsOn": [ "streamQueryResult", "notFoundBehaviour" ], "byOption": [ { "option": "operationName", "value": "createSObject", "carriers": [ "pojo" ], "javaType": "org.apache.camel.component.salesforce.api.dto.CreateSObjectResult" }, { "option": "operationName", "value": "upsertSObject", "carriers": [ "pojo", "unchanged" ] }, { "option": "operationName", "value": "updateSObject", "carriers": [ "un [...] }, "properties": { @@ -199,7 +199,7 @@ "pkChunkingParent": { "index": 30, "kind": "parameter", "displayName": "Pk Chunking Parent", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "configuration", "description": "Specifies the parent object when you're enabling PK chunking for queries on sharing objects. [...] "pkChunkingStartRow": { "index": 31, "kind": "parameter", "displayName": "Pk Chunking Start Row", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "configuration", "description": "Specifies the 15-character or 18-character record ID to be used as the lower boundary fo [...] "queryLocator": { "index": 32, "kind": "parameter", "displayName": "Query Locator", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "configuration", "description": "Query Locator provided by salesforce for use when a query results in more records than can be retrieve [...] - "rawPayload": { "index": 33, "kind": "parameter", "displayName": "Raw Payload", "group": "common", "label": "", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "configuration", "description": "Use raw payload String for request and response (either JSON or XML depending on format), i [...] + "rawPayload": { "index": 33, "kind": "parameter", "displayName": "Raw Payload", "group": "common", "label": "", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "configuration", "description": "Use raw payload for request and response, instead of DTOs, false by default. The REST, apex [...] "reportId": { "index": 34, "kind": "parameter", "displayName": "Report Id", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "configuration", "description": "Salesforce1 Analytics report Id" }, "reportMetadata": { "index": 35, "kind": "parameter", "displayName": "Report Metadata", "group": "common", "label": "", "required": false, "type": "object", "javaType": "org.apache.camel.component.salesforce.api.dto.analytics.reports.ReportMetadata", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "configuration", "description": "Salesforce1 Analytics report metadat [...] "resultId": { "index": 36, "kind": "parameter", "displayName": "Result Id", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.salesforce.SalesforceEndpointConfig", "configurationField": "configuration", "description": "Identifies one result set of a completed Bulk API v1 query batch, as returned by the getQueryResultIds operati [...] diff --git a/components/camel-salesforce/camel-salesforce-component/src/main/docs/salesforce-component.adoc b/components/camel-salesforce/camel-salesforce-component/src/main/docs/salesforce-component.adoc index a40ad7f27145..6e636d2f7688 100644 --- a/components/camel-salesforce/camel-salesforce-component/src/main/docs/salesforce-component.adoc +++ b/components/camel-salesforce/camel-salesforce-component/src/main/docs/salesforce-component.adoc @@ -267,7 +267,7 @@ header will be automatically set based on the `format` option, but this can be o |=== | Parameter | Type | Description| Default| Required -| Body | `String` or `InputStream` | Body of the HTTP request | | +| Body | `String` or `InputStream` | Body of the HTTP request. A body that cannot be converted to an `InputStream`, such as a `Map`, fails with an `InvalidPayloadException`. | | | `rawPath` | `String` | The portion of the endpoint URL after the domain name, e.g., '/services/data/v51.0/sobjects/Account/' | | x | `rawMethod` | `String` | The HTTP method | | x | `rawQueryParameters` | `String` | Comma separated list of message headers to include as query parameters. Do not url-encode values as this will be done automatically. | | diff --git a/components/camel-salesforce/camel-salesforce-component/src/main/docs/salesforce-rest-api.adoc b/components/camel-salesforce/camel-salesforce-component/src/main/docs/salesforce-rest-api.adoc index 4f7dcb91491b..7d446454ca6d 100644 --- a/components/camel-salesforce/camel-salesforce-component/src/main/docs/salesforce-rest-api.adoc +++ b/components/camel-salesforce/camel-salesforce-component/src/main/docs/salesforce-rest-api.adoc @@ -41,6 +41,9 @@ request as the input to a subsequent request. Unless otherwise specified, DTO types for the following options are from `org.apache.camel.component.salesforce.api.dto` or one if its sub-packages. +The operations that send a request body (such as `createSObject`, `updateSObject` and `upsertSObject`) send an SObject DTO, +a `Map` or another object as JSON, and JSON text, bytes, a stream or a file as is. + [[getVersions]] == Versions diff --git a/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/SalesforceEndpoint.java b/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/SalesforceEndpoint.java index ca427742ab57..4acf416e97b8 100644 --- a/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/SalesforceEndpoint.java +++ b/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/SalesforceEndpoint.java @@ -60,15 +60,16 @@ import org.slf4j.LoggerFactory; producerIn = @BodyType(value = { Carrier.TEXT, Carrier.BYTES, Carrier.STREAM, Carrier.FILE, Carrier.POJO, Carrier.LIST, Carrier.MAP, Carrier.NULL }, - description = "The REST operations send a generated DTO (an AbstractSObjectBase) as" - + " JSON, or JSON text, bytes, a stream or a file as is; a Map or another" - + " object is sent as its toString, not as JSON: marshal it first. Text in" + description = "The REST operations send a generated DTO (an AbstractSObjectBase), a" + + " Map or another object as JSON, or JSON text, bytes, a stream or a file" + + " as is. Text in" + " the body is the id, SOQL or SOSL when the option or header is not set." + " apexCall takes a Map as query parameters; bulk operations take a" + " JobInfo, BatchInfo or Job, and createBatch the CSV or XML records as a" + " stream; composite operations an SObjectComposite, SObjectBatch or" + " SObjectTree, the SObject collections a List of DTOs; pubSubPublish a" - + " List of events; raw the request as a stream"), + + " List of events; raw the request as a stream (a body that cannot be" + + " converted to a stream, such as a Map, fails)"), producerOut = @BodyType(value = { Carrier.POJO, Carrier.LIST, Carrier.MAP, Carrier.STREAM, Carrier.UNCHANGED, Carrier.NULL }, dependsOn = { "streamQueryResult", "notFoundBehaviour" }, diff --git a/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/SalesforceEndpointConfig.java b/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/SalesforceEndpointConfig.java index 4ab658b1f139..07ae447d4030 100644 --- a/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/SalesforceEndpointConfig.java +++ b/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/SalesforceEndpointConfig.java @@ -327,8 +327,9 @@ public class SalesforceEndpointConfig implements Cloneable { } /** - * Use raw payload {@link String} for request and response (either JSON or XML depending on {@code format}), instead - * of DTOs, false by default + * Use raw payload for request and response, instead of DTOs, false by default. The REST, apexCall and composite + * operations return the response as is, as an {@code InputStream}, and the streaming subscribe consumer gives the + * message as JSON text. */ public void setRawPayload(boolean rawPayload) { this.rawPayload = rawPayload; diff --git a/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/internal/processor/CompositeSObjectCollectionsProcessor.java b/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/internal/processor/CompositeSObjectCollectionsProcessor.java index 4627a02d830e..308dcecd99d5 100644 --- a/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/internal/processor/CompositeSObjectCollectionsProcessor.java +++ b/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/internal/processor/CompositeSObjectCollectionsProcessor.java @@ -174,6 +174,9 @@ public class CompositeSObjectCollectionsProcessor extends AbstractSalesforceProc try { if (exception != null) { exchange.setException(exception); + } else if (responseBody.isEmpty()) { + exchange.setException(new SalesforceException( + "Unable to read the response of the SObject Collections API: the response is empty or not valid JSON")); } else { Message in = exchange.getIn(); Message out = exchange.getOut(); diff --git a/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/internal/processor/JsonRestProcessor.java b/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/internal/processor/JsonRestProcessor.java index 5803a05fbd03..894d6127cb79 100644 --- a/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/internal/processor/JsonRestProcessor.java +++ b/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/internal/processor/JsonRestProcessor.java @@ -145,9 +145,13 @@ public class JsonRestProcessor extends AbstractRestProcessor { request = in.getBody(InputStream.class); if (request == null) { AbstractDTOBase dto = in.getBody(AbstractDTOBase.class); + final Object payload = in.getBody(); if (dto != null) { // marshall the DTO request = getRequestStream(in, dto); + } else if (payload != null && !isTextOrSimpleValue(payload)) { + // marshall a Map or another POJO, as its toString is not JSON + request = getRequestStream(in, payload); } else { // if all else fails, get body as String final String body = in.getBody(String.class); @@ -163,6 +167,15 @@ public class JsonRestProcessor extends AbstractRestProcessor { return request; } + /** + * Whether the body is text or a simple value (number, boolean, enum), which is sent as its String value. A Map or + * another POJO is marshalled as JSON instead, as its toString is not JSON. + */ + private static boolean isTextOrSimpleValue(Object body) { + return body instanceof CharSequence || body instanceof Number || body instanceof Boolean + || body instanceof Character || body instanceof Enum; + } + @Override protected InputStream getRequestStream(final Message in, final Object object) throws SalesforceException { final ByteArrayOutputStream out = new ByteArrayOutputStream(); diff --git a/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/internal/processor/RawProcessor.java b/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/internal/processor/RawProcessor.java index 126064450516..15519d7579b1 100644 --- a/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/internal/processor/RawProcessor.java +++ b/components/camel-salesforce/camel-salesforce-component/src/main/java/org/apache/camel/component/salesforce/internal/processor/RawProcessor.java @@ -86,7 +86,9 @@ public class RawProcessor extends AbstractSalesforceProcessor { } } - InputStream body = exchange.getIn().getBody(InputStream.class); + // a body that cannot be converted to a stream (such as a Map) fails instead of sending an empty request + InputStream body = exchange.getIn().getBody() != null + ? exchange.getIn().getMandatoryBody(InputStream.class) : null; rawClient.makeRequest(method, path.toString(), format, body, determineHeaders(exchange), (response, headers, exception) -> { Message in = exchange.getIn(); diff --git a/components/camel-salesforce/camel-salesforce-component/src/test/java/org/apache/camel/component/salesforce/ProducerRequestBodyTest.java b/components/camel-salesforce/camel-salesforce-component/src/test/java/org/apache/camel/component/salesforce/ProducerRequestBodyTest.java new file mode 100644 index 000000000000..2a7fa2e672b5 --- /dev/null +++ b/components/camel-salesforce/camel-salesforce-component/src/test/java/org/apache/camel/component/salesforce/ProducerRequestBodyTest.java @@ -0,0 +1,163 @@ +/* + * 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.camel.component.salesforce; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import mockwebserver3.Dispatcher; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; +import okhttp3.HttpUrl; +import org.apache.camel.Exchange; +import org.apache.camel.InvalidPayloadException; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.salesforce.api.SalesforceException; +import org.apache.camel.component.salesforce.api.dto.CreateSObjectResult; +import org.apache.camel.component.salesforce.dto.generated.Merchandise__c; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Checks the request bodies the producer sends, and the errors it reports, against a mock Salesforce server. + */ +public class ProducerRequestBodyTest extends AbstractSalesforceTestBase { + + private static final String OAUTH2_TOKEN_PATH = "/services/oauth2/token"; + + private static final LinkedBlockingQueue<RecordedRequest> REQUESTS = new LinkedBlockingQueue<>(); + + private static MockWebServer server; + private static HttpUrl loginUrl; + + @BeforeAll + public static void startServer() throws IOException { + server = new MockWebServer(); + server.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest recordedRequest) { + final String path = recordedRequest.getUrl().encodedPath(); + if (path.equals(OAUTH2_TOKEN_PATH)) { + return new MockResponse.Builder() + .code(200) + .body("{ \"access_token\": \"mock_token\", \"id\": \"https://login.salesforce.com/id/00D4100000xxxxxxxx/0054100000xxxxxxxx\", \"instance_url\": \"" + + loginUrl + "\"}") + .build(); + } + if (path.startsWith("/services/data/")) { + REQUESTS.add(recordedRequest); + } + if (path.endsWith("/composite/sobjects")) { + // not a JSON list of results + return new MockResponse.Builder().code(200).body("not json").build(); + } + return new MockResponse.Builder() + .code(201) + .addHeader("Content-Type", "application/json") + .body("{ \"id\": \"001D000000IqhSLIAZ\", \"errors\": [], \"success\": true }") + .build(); + } + }); + server.start(); + loginUrl = server.url(""); + } + + @AfterAll + public static void shutDownServer() throws IOException { + if (server != null) { + server.close(); + } + } + + @BeforeEach + public void clearRequests() { + REQUESTS.clear(); + } + + @Override + protected void createComponent() throws Exception { + final SalesforceComponent component = new SalesforceComponent(); + final SalesforceEndpointConfig config = new SalesforceEndpointConfig(); + config.setApiVersion(SalesforceEndpointConfig.DEFAULT_VERSION); + component.setConfig(config); + + final SalesforceLoginConfig loginConfig = new SalesforceLoginConfig(); + loginConfig.setClientId("ignored"); + loginConfig.setClientSecret("ignored"); + loginConfig.setRefreshToken("ignored"); + loginConfig.setLoginUrl(loginUrl.toString()); + component.setLoginConfig(loginConfig); + component.setPackages(Merchandise__c.class.getPackage().getName()); + + context().addComponent("salesforce", component); + } + + @Test + public void createSObjectShouldSendMapBodyAsJson() throws Exception { + final CreateSObjectResult result = template().requestBody("direct:createSObject", Map.of("Name", "x"), + CreateSObjectResult.class); + + assertThat(result.getId()).isEqualTo("001D000000IqhSLIAZ"); + final RecordedRequest request = REQUESTS.poll(10, TimeUnit.SECONDS); + assertThat(request).isNotNull(); + assertThat(request.getMethod()).isEqualTo("POST"); + assertThat(request.getBody().utf8()).isEqualTo("{\"Name\":\"x\"}"); + } + + @Test + public void rawWithMapBodyShouldFail() { + final Exchange exchange = template().request("direct:raw", e -> e.getIn().setBody(Map.of("Name", "x"))); + + assertThat(exchange.getException()).isInstanceOf(InvalidPayloadException.class); + assertThat(REQUESTS).isEmpty(); + } + + @Test + public void compositeCollectionsWithUnparseableResponseShouldFail() { + final Merchandise__c merchandise = new Merchandise__c(); + merchandise.setName("x"); + + final Exchange exchange = template().request("direct:compositeCreateSObjectCollections", + e -> e.getIn().setBody(List.of(merchandise))); + + assertThat(exchange.getException()).isInstanceOf(SalesforceException.class) + .hasMessageContaining("SObject Collections API"); + } + + @Override + protected RouteBuilder doCreateRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:createSObject").to("salesforce:createSObject?sObjectName=Account"); + + from("direct:raw") + .to("salesforce:raw?format=JSON&rawMethod=POST&rawPath=/services/data/v51.0/sobjects/Account"); + + from("direct:compositeCreateSObjectCollections").to("salesforce:compositeCreateSObjectCollections"); + } + }; + } +} diff --git a/components/camel-salesforce/camel-salesforce-component/src/test/java/org/apache/camel/component/salesforce/internal/processor/JsonRestProcessorTest.java b/components/camel-salesforce/camel-salesforce-component/src/test/java/org/apache/camel/component/salesforce/internal/processor/JsonRestProcessorTest.java index bc47000a7d16..22f2fe612ce6 100644 --- a/components/camel-salesforce/camel-salesforce-component/src/test/java/org/apache/camel/component/salesforce/internal/processor/JsonRestProcessorTest.java +++ b/components/camel-salesforce/camel-salesforce-component/src/test/java/org/apache/camel/component/salesforce/internal/processor/JsonRestProcessorTest.java @@ -19,9 +19,12 @@ package org.apache.camel.component.salesforce.internal.processor; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.StringReader; import java.nio.charset.StandardCharsets; import java.time.ZoneId; import java.time.ZonedDateTime; +import java.util.LinkedHashMap; +import java.util.Map; import org.apache.camel.Exchange; import org.apache.camel.ExchangePattern; @@ -112,4 +115,50 @@ public class JsonRestProcessorTest { } } } + + public static class Contact { + + private final String lastName; + + public Contact(String lastName) { + this.lastName = lastName; + } + + public String getLastName() { + return lastName; + } + } + + @Test + public void getRequestStreamShouldMarshalMapAsJson() throws Exception { + final Map<String, Object> body = new LinkedHashMap<>(); + body.put("Name", "x"); + body.put("NumberOfEmployees", 5); + + assertThat(requestOf(body)).isEqualTo("{\"Name\":\"x\",\"NumberOfEmployees\":5}"); + } + + @Test + public void getRequestStreamShouldMarshalPojoAsJson() throws Exception { + assertThat(requestOf(new Contact("Doe"))).isEqualTo("{\"lastName\":\"Doe\"}"); + } + + @Test + public void getRequestStreamShouldSendTextAsIs() throws Exception { + assertThat(requestOf("{ \"Name\" : \"x\" }")).isEqualTo("{ \"Name\" : \"x\" }"); + assertThat(requestOf(new StringReader("{ \"Name\" : \"x\" }"))).isEqualTo("{ \"Name\" : \"x\" }"); + } + + private static String requestOf(Object body) throws Exception { + final SalesforceComponent comp = new SalesforceComponent(); + final SalesforceEndpointConfig conf = new SalesforceEndpointConfig(); + final SalesforceEndpoint endpoint = new SalesforceEndpoint("", comp, conf, OperationName.CREATE_SOBJECT, ""); + try (final JsonRestProcessor jsonRestProcessor = new JsonRestProcessor(endpoint)) { + final Exchange exchange = new DefaultExchange(new DefaultCamelContext(), ExchangePattern.InOut); + exchange.getIn().setBody(body); + try (InputStream stream = jsonRestProcessor.getRequestStream(exchange)) { + return IOUtils.toString(stream, StandardCharsets.UTF_8); + } + } + } } diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index 1bc188419d61..e20125e168f0 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -716,6 +716,15 @@ CamelContext of that provider. It now closes each of them only when no other `JC it returned the closed cache, whose operations throw `IllegalStateException`. This matters for code that calls a `JCacheManager` (for example one created by a custom `JCacheManagerFactory`) after it was closed. +=== camel-salesforce - a Map or POJO body is sent as JSON, and raw fails on a body it cannot send + +The REST operations (such as `createSObject`, `updateSObject` and `upsertSObject`) now marshal a `Map` or another +object that is not an SObject DTO as JSON. Before, they sent its `toString()`, for example `{Name=x}`, which is not +JSON. Text, bytes, a stream or a file in the body are sent as is, as before. + +The `raw` operation now fails with an `InvalidPayloadException` when the body cannot be converted to an `InputStream`, +for example a `Map`. Before, it sent the request with an empty body. + === camel-spring-rabbitmq - confirm=auto waits for simple publisher confirms With the default `confirm=auto`, an InOnly producer now also waits for the publisher confirm when the connection diff --git a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/SalesforceComponentBuilderFactory.java b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/SalesforceComponentBuilderFactory.java index e97ce090673b..992602bddc59 100644 --- a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/SalesforceComponentBuilderFactory.java +++ b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/SalesforceComponentBuilderFactory.java @@ -700,8 +700,10 @@ public interface SalesforceComponentBuilderFactory { /** - * Use raw payload String for request and response (either JSON or XML - * depending on format), instead of DTOs, false by default. + * Use raw payload for request and response, instead of DTOs, false by + * default. The REST, apexCall and composite operations return the + * response as is, as an InputStream, and the streaming subscribe + * consumer gives the message as JSON text. * * The option is a: <code>boolean</code> type. * diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SalesforceEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SalesforceEndpointBuilderFactory.java index c6d98088403b..99ed8c303c99 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SalesforceEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SalesforceEndpointBuilderFactory.java @@ -906,8 +906,10 @@ public interface SalesforceEndpointBuilderFactory { return this; } /** - * Use raw payload String for request and response (either JSON or XML - * depending on format), instead of DTOs, false by default. + * Use raw payload for request and response, instead of DTOs, false by + * default. The REST, apexCall and composite operations return the + * response as is, as an InputStream, and the streaming subscribe + * consumer gives the message as JSON text. * * The option is a: <code>boolean</code> type. * @@ -922,8 +924,10 @@ public interface SalesforceEndpointBuilderFactory { return this; } /** - * Use raw payload String for request and response (either JSON or XML - * depending on format), instead of DTOs, false by default. + * Use raw payload for request and response, instead of DTOs, false by + * default. The REST, apexCall and composite operations return the + * response as is, as an InputStream, and the streaming subscribe + * consumer gives the message as JSON text. * * The option will be converted to a <code>boolean</code> type. * @@ -2508,8 +2512,10 @@ public interface SalesforceEndpointBuilderFactory { return this; } /** - * Use raw payload String for request and response (either JSON or XML - * depending on format), instead of DTOs, false by default. + * Use raw payload for request and response, instead of DTOs, false by + * default. The REST, apexCall and composite operations return the + * response as is, as an InputStream, and the streaming subscribe + * consumer gives the message as JSON text. * * The option is a: <code>boolean</code> type. * @@ -2524,8 +2530,10 @@ public interface SalesforceEndpointBuilderFactory { return this; } /** - * Use raw payload String for request and response (either JSON or XML - * depending on format), instead of DTOs, false by default. + * Use raw payload for request and response, instead of DTOs, false by + * default. The REST, apexCall and composite operations return the + * response as is, as an InputStream, and the streaming subscribe + * consumer gives the message as JSON text. * * The option will be converted to a <code>boolean</code> type. * @@ -3952,8 +3960,10 @@ public interface SalesforceEndpointBuilderFactory { return this; } /** - * Use raw payload String for request and response (either JSON or XML - * depending on format), instead of DTOs, false by default. + * Use raw payload for request and response, instead of DTOs, false by + * default. The REST, apexCall and composite operations return the + * response as is, as an InputStream, and the streaming subscribe + * consumer gives the message as JSON text. * * The option is a: <code>boolean</code> type. * @@ -3968,8 +3978,10 @@ public interface SalesforceEndpointBuilderFactory { return this; } /** - * Use raw payload String for request and response (either JSON or XML - * depending on format), instead of DTOs, false by default. + * Use raw payload for request and response, instead of DTOs, false by + * default. The REST, apexCall and composite operations return the + * response as is, as an InputStream, and the streaming subscribe + * consumer gives the message as JSON text. * * The option will be converted to a <code>boolean</code> type. *
