diff --git a/README.md b/README.md index b274e08..6f45f65 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,10 @@ # Overview -Contains CDAP pipeline plugins for Salesforce Marketing Cloud +Contains CDAP pipeline plugins for Salesforce Marketing Cloud. Following plugins are available in this repository. + +* [Salesforce Marketing Cloud Source](docs/MarketingCloud-batchsource.md) +* [Salesforce Marketing Cloud Data Extension Sink](docs/SalesforceDataExtension-batchsink.md) # Contact @@ -14,7 +17,7 @@ CDAP User Group and Development Discussions: * [cdap-user@googlegroups.com](https://groups.google.com/d/forum/cdap-user) The *cdap-user* mailing list is primarily for users using the product to develop -applications or building plugins for appplications. You can expect questions from +applications or building plugins for applications. You can expect questions from users, release announcements, and any other discussions that we think will be helpful to the users. diff --git a/docs/MarketingCloud-batchsource.md b/docs/MarketingCloud-batchsource.md new file mode 100644 index 0000000..654f737 --- /dev/null +++ b/docs/MarketingCloud-batchsource.md @@ -0,0 +1,105 @@ +# Salesforce Marketing Cloud Source + +Description +----------- + +Reads from one or multiple objects withing Salesforce Marketing Cloud depending on the mode values set for this plugin. +In case of `Multi Object` mode, the source will output a record for each row from the object it reads, with each record +containing an additional field that holds the name of the object from which the record came from. In case of +`Single Object` mode, this additional field will not be there in the output. In addition, for each object that will be +read, this plugin will set pipeline arguments where the key is `multisink.[objectname]` and the value is the schema of +the object. + +Properties +---------- + +**Reference Name**: Name used to uniquely identify this source for lineage, annotating metadata, etc. + +**Data Retrieval Mode**: Mode of data retrieval. The mode can be one of two values: + +`Multi Object` - will allow user to fetch data for multiple data extensions, + +`Single Object` - will allow user to fetch data for single data extension. + +**Object**: Specify the object for which data to be fetched. This can be one of following values: + +`Data Extension` - will allow user to fetch data for a single Data Extension object, + +`Email` - will allow user to fetch data for Email object, + +`Mailing List` - will allow user to fetch data for Mailing List object, + +`Bounce Event` - will allow user to fetch Tracking Bounce Events, + +`Open Event` - will allow user to fetch Tracking Open Events, + +`Unsub Event` - will allow user to fetch Tracking UnSubscribe Events, + +`Sent Event` - will allow user to fetch Tracking Sent Events, + +`UnSent Event` - will allow user to fetch Tracking UnSent Events. + +Note, this value will be ignored if the Mode is set to `Multi Object`. + +**Data Extension External Key**: Specify the data extension key from which data to be fetched. Note, this value will +be ignored in following two cases: + +* If the Mode is set to `Multi Object` +* If the selected object name is other than `Data Extension`. + +**Object List**: Specify the comma-separated list of objects for which data to be fetched; for example: +'Object1,Object2'. This can be one or more values from following possible values: + +`Data Extension` - will allow user to fetch data for a single Data Extension object, + +`Email` - will allow user to fetch data for Email object, + +`Mailing List` - will allow user to fetch data for Mailing List object + +`Bounce Event` - will allow user to fetch Tracking Bounce Events, + +`Open Event` - will allow user to fetch Tracking Open Events, + +`Unsub Event` - will allow user to fetch Tracking UnSubscribe Events, + +`Sent Event` - will allow user to fetch Tracking Sent Events, + +`UnSent Event` - will allow user to fetch Tracking UnSent Events. + +Note, this value will be ignored if the Mode is set to `Single Object`. + +**Data Extension External Keys**: Specify the data extension keys from which data to be fetched; for example: +'Key1,Key2'. Note, this value will be ignored in following two cases: + +* If the Mode is set to `Single Object`, + +* If the selected object list does not contain `Data Extension` as one of the objects. + +**Table Name Field**: The name of the field that holds the object name to which the data belongs to. Must not be the +name of any column for any of the objects that will be read. Defaults to `tablename`. In case of `Data Extension` +object, this field will have value in `dataextension_[Data Extension Key]` format. Note, the Table name field value +will be ignored if the Mode is set to `Single Object`. + +**Filter**: The filter selection criteria. eg: eventDate > '2010-01-01' and subscriberKey = 1001 + +**Client ID**: OAuth2 client ID associated with an installed package in the Salesforce Marketing Cloud. + +**Client Secret**: TOAuth2 client secret associated with an installed package in the Salesforce Marketing Cloud. + +**Authentication Base URI**: Authentication Base URL associated for the Server-to-Server API integration. +For example, `https://instance.auth.marketingcloudapis.com/` + +**SOAP API Endpoint**: The SOAP Endpoint URL associated for the Server-to-Server API integration. For example, +`https://instance.soap.marketingcloudapis.com/Service.asmx` + +**REST API Base URI**: The REST API Base URL associated for the Server-to-Server API integration. For example, +`https://instance.rest.marketingcloudapis.com/` + +**Error handling:** Whether to fail the pipeline if an error encountered while reading records. This can be one of +following values: + +`Stop on error` - will fail the pipeline if an error encountered, + +`Skip on error` - will not fail the pipeline even if an error encountered and will continue the pipeline. + +Note: the default value is `Skip on error`. \ No newline at end of file diff --git a/examples/example_sfmc_multiobject_pipeline-cdap-data-pipeline.json b/examples/example_sfmc_multiobject_pipeline-cdap-data-pipeline.json new file mode 100644 index 0000000..3c1f4a4 --- /dev/null +++ b/examples/example_sfmc_multiobject_pipeline-cdap-data-pipeline.json @@ -0,0 +1,101 @@ +{ + "artifact": { + "name": "cdap-data-pipeline", + "version": "6.2.1", + "scope": "SYSTEM" + }, + "description": "Data Pipeline Application", + "name": "example_sfmc_multiobject_pipeline", + "config": { + "resources": { + "memoryMB": 2048, + "virtualCores": 1 + }, + "driverResources": { + "memoryMB": 2048, + "virtualCores": 1 + }, + "connections": [ + { + "from": "Salesforce-Marketing", + "to": "File" + } + ], + "comments": [], + "postActions": [], + "properties": {}, + "processTimingEnabled": true, + "stageLoggingEnabled": false, + "stages": [ + { + "name": "Salesforce-Marketing", + "plugin": { + "name": "MarketingCloud", + "type": "batchsource", + "label": "Salesforce Marketing", + "artifact": { + "name": "salesforce-marketing-plugins", + "version": "1.2.2-SNAPSHOT", + "scope": "USER" + }, + "properties": { + "queryMode": "Multi Object", + "objectList": "Data Extension", + "tableNameField": "tablename", + "failOnError": "false", + "referenceName": "SFMC", + "clientId": "${SFMC_CLIENT_ID}", + "clientSecret": "${SFMC_CLIENT_SECRET}", + "authEndpoint": "${SFMC_AUTH_URI}", + "soapEndpoint": "${SFMC_SOAP_URI}", + "restEndpoint": "${SFMC_REST_URI}", + "dataExtensionKeyList": "CA397115-F3E1-4180-B7D0-AF91A60E8B2D,CFA3EE8B-320E-4E27-BBCC-CF4291556BE0" + } + }, + "outputSchema": [ + { + "name": "etlSchemaBody", + "schema": "" + } + ] + }, + { + "name": "File", + "plugin": { + "name": "File", + "type": "batchsink", + "label": "File", + "artifact": { + "name": "core-plugins", + "version": "2.4.1", + "scope": "SYSTEM" + }, + "properties": { + "suffix": "yyyy-MM-dd-HH-mm", + "format": "csv", + "referenceName": "SFMCData", + "path": "${OUTPUT_DIR}", + "schema": "{\"type\":\"record\",\"name\":\"etlSchemaBody\",\"fields\":[{\"name\":\"subscriber_key\",\"type\":[\"string\",\"null\"]},{\"name\":\"campaign_c\",\"type\":[\"string\",\"null\"]},{\"name\":\"email_address\",\"type\":[\"string\",\"null\"]},{\"name\":\"campaign_a\",\"type\":[\"string\",\"null\"]},{\"name\":\"account_unsub\",\"type\":[\"string\",\"null\"]},{\"name\":\"campaign_b\",\"type\":[\"string\",\"null\"]},{\"name\":\"first__name\",\"type\":[\"string\",\"null\"]},{\"name\":\"emailaddress\",\"type\":[\"string\",\"null\"]},{\"name\":\"subscriberkey\",\"type\":[\"string\",\"null\"]},{\"name\":\"tablename\",\"type\":\"string\"}]}" + } + }, + "outputSchema": [ + { + "name": "etlSchemaBody", + "schema": "{\"type\":\"record\",\"name\":\"etlSchemaBody\",\"fields\":[{\"name\":\"subscriber_key\",\"type\":[\"string\",\"null\"]},{\"name\":\"campaign_c\",\"type\":[\"string\",\"null\"]},{\"name\":\"email_address\",\"type\":[\"string\",\"null\"]},{\"name\":\"campaign_a\",\"type\":[\"string\",\"null\"]},{\"name\":\"account_unsub\",\"type\":[\"string\",\"null\"]},{\"name\":\"campaign_b\",\"type\":[\"string\",\"null\"]},{\"name\":\"first__name\",\"type\":[\"string\",\"null\"]},{\"name\":\"emailaddress\",\"type\":[\"string\",\"null\"]},{\"name\":\"subscriberkey\",\"type\":[\"string\",\"null\"]},{\"name\":\"tablename\",\"type\":\"string\"}]}" + } + ], + "inputSchema": [ + { + "name": "Salesforce Marketing", + "schema": "" + } + ] + } + ], + "schedule": "0 * * * *", + "engine": "spark", + "numOfRecordsPreview": 100, + "description": "Data Pipeline Application", + "maxConcurrentRuns": 1 + } +} \ No newline at end of file diff --git a/examples/example_sfmc_openevent_withfilter-cdap-data-pipeline.json b/examples/example_sfmc_openevent_withfilter-cdap-data-pipeline.json new file mode 100644 index 0000000..05471cf --- /dev/null +++ b/examples/example_sfmc_openevent_withfilter-cdap-data-pipeline.json @@ -0,0 +1,94 @@ +{ + "artifact": { + "name": "cdap-data-pipeline", + "version": "6.2.1", + "scope": "SYSTEM" + }, + "description": "", + "name": "example_sfmc_openevent_withfilter", + "config": { + "resources": { + "memoryMB": 2048, + "virtualCores": 1 + }, + "driverResources": { + "memoryMB": 2048, + "virtualCores": 1 + }, + "connections": [ + { + "from": "Salesforce-Marketing", + "to": "File" + } + ], + "comments": [], + "postActions": [], + "properties": {}, + "processTimingEnabled": true, + "stageLoggingEnabled": false, + "stages": [ + { + "name": "Salesforce-Marketing", + "plugin": { + "name": "MarketingCloud", + "type": "batchsource", + "label": "Salesforce Marketing", + "artifact": { + "name": "salesforce-marketing-plugins", + "version": "1.2.2-SNAPSHOT", + "scope": "USER" + }, + "properties": { + "queryMode": "Single Object", + "objectName": "Open Event", + "failOnError": "false", + "referenceName": "SFMC", + "clientId": "${SFMC_CLIENT_ID}", + "clientSecret": "${SFMC_CLIENT_SECRET}", + "authEndpoint": "${SFMC_AUTH_URI}", + "soapEndpoint": "${SFMC_SOAP_URI}", + "restEndpoint": "${SFMC_REST_URI}", + "filter": "subscriberKey = 'manoj.muralidharan@verticurl.com' and eventDate > '04-19-2019'" + } + }, + "outputSchema": [ + { + "name": "etlSchemaBody", + "schema": "{\"type\":\"record\",\"name\":\"etlSchemaBody\",\"fields\":[{\"name\":\"id\",\"type\":[\"string\",\"null\"]},{\"name\":\"sendID\",\"type\":[\"int\",\"null\"]},{\"name\":\"subscriberKey\",\"type\":[\"string\",\"null\"]},{\"name\":\"eventDate\",\"type\":[\"string\",\"null\"]},{\"name\":\"eventType\",\"type\":[\"string\",\"null\"]},{\"name\":\"triggeredSendDefinitionObjectID\",\"type\":[\"string\",\"null\"]},{\"name\":\"batchID\",\"type\":[\"int\",\"null\"]}]}" + } + ] + }, + { + "name": "File", + "plugin": { + "name": "File", + "type": "batchsink", + "label": "File", + "artifact": { + "name": "core-plugins", + "version": "2.4.1", + "scope": "SYSTEM" + }, + "properties": { + "suffix": "yyyy-MM-dd-HH-mm", + "format": "csv", + "schema": "{\"type\":\"record\",\"name\":\"etlSchemaBody\",\"fields\":[{\"name\":\"id\",\"type\":[\"string\",\"null\"]},{\"name\":\"sendID\",\"type\":[\"int\",\"null\"]},{\"name\":\"subscriberKey\",\"type\":[\"string\",\"null\"]},{\"name\":\"eventDate\",\"type\":[\"string\",\"null\"]},{\"name\":\"eventType\",\"type\":[\"string\",\"null\"]},{\"name\":\"triggeredSendDefinitionObjectID\",\"type\":[\"string\",\"null\"]},{\"name\":\"batchID\",\"type\":[\"int\",\"null\"]}]}", + "referenceName": "FILEOUT", + "path": "${OUTPUT_DIR}" + } + }, + "outputSchema": "{\"type\":\"record\",\"name\":\"etlSchemaBody\",\"fields\":[{\"name\":\"id\",\"type\":[\"string\",\"null\"]},{\"name\":\"sendID\",\"type\":[\"int\",\"null\"]},{\"name\":\"subscriberKey\",\"type\":[\"string\",\"null\"]},{\"name\":\"eventDate\",\"type\":[\"string\",\"null\"]},{\"name\":\"eventType\",\"type\":[\"string\",\"null\"]},{\"name\":\"triggeredSendDefinitionObjectID\",\"type\":[\"string\",\"null\"]},{\"name\":\"batchID\",\"type\":[\"int\",\"null\"]}]}", + "inputSchema": [ + { + "name": "Salesforce Marketing", + "schema": "{\"type\":\"record\",\"name\":\"etlSchemaBody\",\"fields\":[{\"name\":\"id\",\"type\":[\"string\",\"null\"]},{\"name\":\"sendID\",\"type\":[\"int\",\"null\"]},{\"name\":\"subscriberKey\",\"type\":[\"string\",\"null\"]},{\"name\":\"eventDate\",\"type\":[\"string\",\"null\"]},{\"name\":\"eventType\",\"type\":[\"string\",\"null\"]},{\"name\":\"triggeredSendDefinitionObjectID\",\"type\":[\"string\",\"null\"]},{\"name\":\"batchID\",\"type\":[\"int\",\"null\"]}]}" + } + ] + } + ], + "schedule": "0 * * * *", + "engine": "spark", + "numOfRecordsPreview": 100, + "maxConcurrentRuns": 1 + } +} \ No newline at end of file diff --git a/icons/MarketingCloud-batchsource.png b/icons/MarketingCloud-batchsource.png new file mode 100644 index 0000000..46817a6 Binary files /dev/null and b/icons/MarketingCloud-batchsource.png differ diff --git a/pom.xml b/pom.xml index 7c716e0..d7b5136 100644 --- a/pom.xml +++ b/pom.xml @@ -22,7 +22,7 @@ Salesforce Marketing plugins io.cdap.plugin salesforce-marketing-plugins - 1.1.0-SNAPSHOT + 1.2.2-SNAPSHOT jar @@ -30,11 +30,13 @@ true UTF-8 - 6.1.0-SNAPSHOT + 6.1.1 + 2.3.4 4.12 2.3.0 2.2.0 - 1.4.0 + 27.0.1-jre + 1.5.0 @@ -56,22 +58,26 @@ - - io.cdap.cdap - cdap-api - ${cdap.version} - - io.cdap.cdap cdap-etl-api ${cdap.version} - io.cdap.plugin hydrator-common - ${hydrator.version} + ${cdap.plugin.version} + + + com.google.guava + guava + ${guava.version} + + + io.cdap.cdap + hydrator-test + ${cdap.version} + provided org.apache.hadoop @@ -196,6 +202,11 @@ commons-lang 2.6 + + commons-collections + commons-collections + 3.2.1 + org.slf4j jcl-over-slf4j @@ -210,6 +221,12 @@ ${junit.version} test + + org.mockito + mockito-core + 2.24.0 + test + @@ -368,7 +385,7 @@ org.apache.felix maven-bundle-plugin - 3.3.0 + 3.5.1 true @@ -389,6 +406,7 @@ + io.cdap cdap-maven-plugin diff --git a/src/main/java/com/custom/fuelsdk/ETClickEvent.java b/src/main/java/com/custom/fuelsdk/ETClickEvent.java new file mode 100644 index 0000000..748e4aa --- /dev/null +++ b/src/main/java/com/custom/fuelsdk/ETClickEvent.java @@ -0,0 +1,189 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 com.custom.fuelsdk; + +import com.custom.fuelsdk.internal.ClickEvent; +import com.exacttarget.fuelsdk.ETSoapObject; +import com.exacttarget.fuelsdk.annotations.ExternalName; +import com.exacttarget.fuelsdk.annotations.InternalName; +import com.exacttarget.fuelsdk.annotations.SoapObject; +import com.exacttarget.fuelsdk.internal.EventType; + +import java.util.Date; + +/** + * An ETClickEvent object represents information pertaining to the + * click events of an email message in the Salesforce Marketing Cloud. + */ +@SoapObject(internalType = ClickEvent.class) +public class ETClickEvent extends ETSoapObject { + + @InternalName("objectID") + private String id; + + @ExternalName("sendID") + private Integer sendID; + + @ExternalName("subscriberKey") + private String subscriberKey; + + @ExternalName("eventDate") + private Date eventDate; + + @ExternalName("eventType") + private EventType eventType; + + @ExternalName("triggeredSendDefinitionObjectID") + private String triggeredSendDefinitionObjectID; + + @ExternalName("batchID") + private Integer batchID; + + @ExternalName("urlid") + private Integer urlid; + + @ExternalName("url") + private String url; + + /** + * @return the id + */ + @Override + public String getId() { + return id; + } + + /** + * @param id the id to set + */ + @Override + public void setId(String id) { + this.id = id; + } + + /** + * @return the sendID + */ + public Integer getSendID() { + return sendID; + } + + /** + * @param sendID the sendID to set + */ + public void setSendID(Integer sendID) { + this.sendID = sendID; + } + + /** + * @return the subscriberKey + */ + public String getSubscriberKey() { + return subscriberKey; + } + + /** + * @param subscriberKey the subscriberKey to set + */ + public void setSubscriberKey(String subscriberKey) { + this.subscriberKey = subscriberKey; + } + + /** + * @return the eventDate + */ + public Date getEventDate() { + return eventDate; + } + + /** + * @param eventDate the eventDate to set + */ + public void setEventDate(Date eventDate) { + this.eventDate = eventDate; + } + + /** + * @return the eventType + */ + public EventType getEventType() { + return eventType; + } + + /** + * @param eventType the eventType to set + */ + public void setEventType(EventType eventType) { + this.eventType = eventType; + } + + /** + * @return the triggeredSendDefinitionObjectID + */ + public String getTriggeredSendDefinitionObjectID() { + return triggeredSendDefinitionObjectID; + } + + /** + * @param triggeredSendDefinitionObjectID the triggeredSendDefinitionObjectID to set + */ + public void setTriggeredSendDefinitionObjectID(String triggeredSendDefinitionObjectID) { + this.triggeredSendDefinitionObjectID = triggeredSendDefinitionObjectID; + } + + /** + * @return the batchID + */ + public Integer getBatchID() { + return batchID; + } + + /** + * @param batchID the batchID to set + */ + public void setBatchID(Integer batchID) { + this.batchID = batchID; + } + + /** + * @return the urlid + */ + public Integer getUrlid() { + return urlid; + } + + /** + * @param urlid the urlid to set + */ + public void setUrlid(Integer urlid) { + this.urlid = urlid; + } + + /** + * @return the url + */ + public String getUrl() { + return url; + } + + /** + * @param url the url to set + */ + public void setUrl(String url) { + this.url = url; + } + +} diff --git a/src/main/java/com/custom/fuelsdk/ETNotSentEvent.java b/src/main/java/com/custom/fuelsdk/ETNotSentEvent.java new file mode 100644 index 0000000..8a21078 --- /dev/null +++ b/src/main/java/com/custom/fuelsdk/ETNotSentEvent.java @@ -0,0 +1,155 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 com.custom.fuelsdk; + +import com.exacttarget.fuelsdk.ETSoapObject; +import com.exacttarget.fuelsdk.annotations.ExternalName; +import com.exacttarget.fuelsdk.annotations.InternalName; +import com.exacttarget.fuelsdk.annotations.SoapObject; +import com.exacttarget.fuelsdk.internal.EventType; +import com.exacttarget.fuelsdk.internal.NotSentEvent; + +import java.util.Date; + +/** + * An ETNotSentEvent object represents information pertaining to the notsent events + * of an email message in the Salesforce Marketing Cloud. + * This oject type is currently not supported in FuelSDK and hence the provided here. + */ +@SoapObject(internalType = NotSentEvent.class) +public class ETNotSentEvent extends ETSoapObject { + @InternalName("objectID") + private String id; + + @ExternalName("sendID") + private Integer sendID; + + @ExternalName("subscriberKey") + private String subscriberKey; + + @ExternalName("eventDate") + private Date eventDate; + + @ExternalName("eventType") + private EventType eventType; + + @ExternalName("triggeredSendDefinitionObjectID") + private String triggeredSendDefinitionObjectID; + + @ExternalName("batchID") + private Integer batchID; + + /** + * @return the id + */ + @Override + public String getId() { + return id; + } + + /** + * @param id the id to set + */ + @Override + public void setId(String id) { + this.id = id; + } + + /** + * @return the sendID + */ + public Integer getSendID() { + return sendID; + } + + /** + * @param sendID the sendID to set + */ + public void setSendID(Integer sendID) { + this.sendID = sendID; + } + + /** + * @return the subscriberKey + */ + public String getSubscriberKey() { + return subscriberKey; + } + + /** + * @param subscriberKey the subscriberKey to set + */ + public void setSubscriberKey(String subscriberKey) { + this.subscriberKey = subscriberKey; + } + + /** + * @return the eventDate + */ + public Date getEventDate() { + return eventDate; + } + + /** + * @param eventDate the eventDate to set + */ + public void setEventDate(Date eventDate) { + this.eventDate = eventDate; + } + + /** + * @return the eventType + */ + public EventType getEventType() { + return eventType; + } + + /** + * @param eventType the eventType to set + */ + public void setEventType(EventType eventType) { + this.eventType = eventType; + } + + /** + * @return the triggeredSendDefinitionObjectID + */ + public String getTriggeredSendDefinitionObjectID() { + return triggeredSendDefinitionObjectID; + } + + /** + * @param triggeredSendDefinitionObjectID the triggeredSendDefinitionObjectID to set + */ + public void setTriggeredSendDefinitionObjectID(String triggeredSendDefinitionObjectID) { + this.triggeredSendDefinitionObjectID = triggeredSendDefinitionObjectID; + } + + /** + * @return the batchID + */ + public Integer getBatchID() { + return batchID; + } + + /** + * @param batchID the batchID to set + */ + public void setBatchID(Integer batchID) { + this.batchID = batchID; + } + +} diff --git a/src/main/java/com/custom/fuelsdk/PaginationETSoapObject.java b/src/main/java/com/custom/fuelsdk/PaginationETSoapObject.java new file mode 100644 index 0000000..1f4b339 --- /dev/null +++ b/src/main/java/com/custom/fuelsdk/PaginationETSoapObject.java @@ -0,0 +1,365 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 com.custom.fuelsdk; + +import com.exacttarget.fuelsdk.ETClient; +import com.exacttarget.fuelsdk.ETDataExtensionColumn; +import com.exacttarget.fuelsdk.ETDataExtensionRow; +import com.exacttarget.fuelsdk.ETExpression; +import com.exacttarget.fuelsdk.ETFilter; +import com.exacttarget.fuelsdk.ETResponse; +import com.exacttarget.fuelsdk.ETResult; +import com.exacttarget.fuelsdk.ETSdkException; +import com.exacttarget.fuelsdk.ETSoapConnection; +import com.exacttarget.fuelsdk.ETSoapObject; +import com.exacttarget.fuelsdk.annotations.SoapObject; +import com.exacttarget.fuelsdk.internal.APIObject; +import com.exacttarget.fuelsdk.internal.RetrieveRequest; +import com.exacttarget.fuelsdk.internal.RetrieveRequestMsg; +import com.exacttarget.fuelsdk.internal.RetrieveResponseMsg; +import com.exacttarget.fuelsdk.internal.Soap; +import org.apache.log4j.Logger; + +import java.util.ArrayList; +import java.util.List; + +import static com.exacttarget.fuelsdk.ETDataExtension.retrieveColumns; + +/** + * Extending class to fix the bug with Fuel-SDK on pagination. + */ +public class PaginationETSoapObject extends ETSoapObject { + + static final int DEFAULT_PAGE_SIZE = 2500; + private static Logger logger = Logger.getLogger(PaginationETSoapObject.class); + + /** + * @param client + * @param dataExtension + * @param filter + * @return + * @throws ETSdkException + */ + public static ETResponse select(ETClient client, + String dataExtension, + ETFilter filter) + throws ETSdkException { + String name = null; + + // + // The data extension can be specified using key or name: + // + + ETExpression e = ETExpression.parse(dataExtension); + if (e.getProperty().toLowerCase().equals("key") + && e.getOperator() == ETExpression.Operator.EQUALS) { + name = e.getValue(); + // if no columns are explicitly requested + // retrieve all columns + if (filter.getProperties().isEmpty()) { + filter.setProperties(retrieveColumnNames(client, name)); + } + } else if (e.getProperty().toLowerCase().equals("name") + && e.getOperator() == ETExpression.Operator.EQUALS) { + name = e.getValue(); + // if no columns are explicitly requested + // throw an exception + // because we need the key + // to retrieve columns + if (filter.getProperties().isEmpty()) { + throw new ETSdkException("columns must be specified " + + "when retrieving data extensions by name"); + } + + } else { + throw new ETSdkException("invalid data extension filter string"); + } + + ETResponse response = customRetrieve(client, "DataExtensionObject[" + name + "]", + filter, null, ETDataExtensionRow.class); + return response; + } + + /** + * @param client + * @param soapObject + * @param requestID + * @param filter + * @return + * @throws ETSdkException + */ + public static ETResponse continueRequest(ETClient client, String soapObject, String requestID, + ETFilter filter) throws ETSdkException { + ETResponse response = customRetrieve(client, soapObject, filter, requestID, + ETDataExtensionRow.class); + return response; + } + + //there was a bug with original method related with continueRequest + public static ETResponse customRetrieve(ETClient client, + String soapObjectName, + ETFilter filter, + String continueRequest, + Class type) + throws ETSdkException { + ETResponse response = new ETResponse(); + + // + // Get handle to the SOAP connection: + // + + ETSoapConnection connection = client.getSoapConnection(); + + // + // Automatically refresh the token if necessary: + // + + client.refreshToken(); + + // + // Read internal type from the SoapObject annotation: + // + + Class externalType = type; // for code readability + + SoapObject internalTypeAnnotation + = externalType.getAnnotation(SoapObject.class); + assert internalTypeAnnotation != null; + Class internalType = internalTypeAnnotation.internalType(); + assert internalType != null; + + ETExpression expression = filter.getExpression(); + + // + // Determine properties to retrieve: + // + + List externalProperties = filter.getProperties(); + List internalProperties = null; + + if (externalProperties.size() > 0) { + // + // Only request those properties specified: + // + + internalProperties = new ArrayList(); + + for (String externalProperty : externalProperties) { + String internalProperty = + getInternalProperty(externalType, externalProperty); + assert internalProperty != null; + internalProperties.add(internalProperty); + } + } else { + // + // No properties were explicitly requested: + // + + internalProperties = getInternalProperties(externalType); + + // + // Remove properties that are unretrievable: + // + + for (String property : internalTypeAnnotation.unretrievable()) { + internalProperties.remove(property); + } + } + + // + // Perform the SOAP retrieve: + // + + //Soap soap = connection.getSoap(); + Soap soap = null; + + RetrieveRequest retrieveRequest = new RetrieveRequest(); + + if (continueRequest == null) { + // if soapObjectType is specified, use it; otherwise, default + // to the name of the internal class representing the object: + if (soapObjectName != null) { + retrieveRequest.setObjectType(soapObjectName); + soap = connection.getSoap("retrieve", soapObjectName); + } else { + retrieveRequest.setObjectType(internalType.getSimpleName()); + soap = connection.getSoap("retrieve", internalType.getSimpleName()); + } + retrieveRequest.getProperties().addAll(internalProperties); + + if (expression.getOperator() != null) { + // + // Convert the property names to their internal counterparts: + // + + String property = expression.getProperty(); + if (property != null) { + expression.setProperty(getInternalProperty(type, property)); + } + for (ETExpression subexpression : expression.getSubexpressions()) { + String p = subexpression.getProperty(); + if (p != null) { + subexpression.setProperty(getInternalProperty(type, p)); + } + } + + retrieveRequest.setFilter(toFilterPart(expression)); + } + } else { + if (continueRequest != null) { + retrieveRequest.setContinueRequest(continueRequest); + } + } + +// //todo reduce batchsize +// RetrieveOptions retrieveOptions = new RetrieveOptions(); +//// retrieveOptions.setBatchSize(1); +// retrieveRequest.setOptions(retrieveOptions); + + if (logger.isTraceEnabled()) { + logger.trace("RetrieveRequest:"); + logger.trace(" objectType = " + retrieveRequest.getObjectType()); + StringBuilder line = new StringBuilder(" properties = { "); + //String line = null; + for (String property : retrieveRequest.getProperties()) { + + line.append(property).append(", "); + } + if (retrieveRequest.getProperties().size() > 0) { + line.setLength(line.length() - 2); + /*if (line == null) { + line = " properties = { " + property; + } else { + line += ", " + property; + }*/ + } + line.append(" }"); + logger.trace(line.toString()); + //logger.trace(line + " }"); + if (filter != null) { + logger.trace(" filter = " + toFilterPart(expression)); + } + } + + logger.trace("calling soap.retrieve..."); + + RetrieveRequestMsg retrieveRequestMsg = new RetrieveRequestMsg(); + retrieveRequestMsg.setRetrieveRequest(retrieveRequest); + + //todo temp fix for continue request + if (soap == null) { + if (soapObjectName != null) { +// retrieveRequest.setObjectType(soapObjectName); + soap = connection.getSoap("retrieve", soapObjectName); + } else { +// retrieveRequest.setObjectType(internalType.getSimpleName()); + soap = connection.getSoap("retrieve", internalType.getSimpleName()); + } + } + RetrieveResponseMsg retrieveResponseMsg = soap.retrieve(retrieveRequestMsg); + + if (logger.isTraceEnabled()) { + logger.trace("RetrieveResponseMsg:"); + logger.trace(" requestId = " + retrieveResponseMsg.getRequestID()); + logger.trace(" overallStatus = " + retrieveResponseMsg.getOverallStatus()); + logger.trace(" results = {"); + for (APIObject result : retrieveResponseMsg.getResults()) { + logger.trace(" " + result); + } + logger.trace(" }"); + } + + response.setRequestId(retrieveResponseMsg.getRequestID()); + if (retrieveResponseMsg.getOverallStatus().equals("OK")) { + response.setStatus(ETResult.Status.OK); + } else if (retrieveResponseMsg.getOverallStatus().equals("Error")) { + response.setStatus(ETResult.Status.ERROR); + } + response.setResponseCode(retrieveResponseMsg.getOverallStatus()); + response.setResponseMessage(retrieveResponseMsg.getOverallStatus()); + for (APIObject internalObject : retrieveResponseMsg.getResults()) { + // + // Allocate a new (external) object: + // + + T externalObject = null; + try { + externalObject = externalType.newInstance(); + } catch (Exception ex) { + throw new ETSdkException("could not instantiate " + + externalType.getName(), ex); + } + + externalObject.setClient(client); + + // + // Convert from internal representation: + // + + externalObject.fromInternal(internalObject); + + // + // Add result to the list of results: + // + + ETResult result = new ETResult(); + result.setObject(externalObject); + response.addResult(result); + } + + if (retrieveResponseMsg.getOverallStatus().equals("MoreDataAvailable")) { + response.setMoreResults(true); + } + + return response; + } + + /** + * @param client The ETClient object + * @param key The key + * @return The List of Column names + * @throws ETSdkException + */ + private static List retrieveColumnNames(ETClient client, + String key) + throws ETSdkException { + return getColumnNames(retrieveColumns(client, key)); + } + + + /** + * @param columns The List of ETDataExtensionColumn object + * @return The List of ETDataExtension Column names + */ + private static List getColumnNames(List columns) { + List columnNames = new ArrayList(); + for (ETDataExtensionColumn column : columns) { + columnNames.add(column.getName()); + } + return columnNames; + } + + @Override + public String getId() { + return null; + } + + @Override + public void setId(String s) { + + } +} diff --git a/src/main/java/com/custom/fuelsdk/internal/ClickEvent.java b/src/main/java/com/custom/fuelsdk/internal/ClickEvent.java new file mode 100644 index 0000000..19b0b7f --- /dev/null +++ b/src/main/java/com/custom/fuelsdk/internal/ClickEvent.java @@ -0,0 +1,133 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 com.custom.fuelsdk.internal; + +import com.exacttarget.fuelsdk.internal.TrackingEvent; +import org.apache.commons.lang.builder.ToStringBuilder; +import org.apache.cxf.xjc.runtime.JAXBToStringStyle; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlType; + + +/** + *

Java class for ClickEvent complex type. + * + *

The following schema fragment specifies the expected content contained within this class. + * + *

+ * <complexType name="ClickEvent">
+ *   <complexContent>
+ *     <extension base="{http://exacttarget.com/wsdl/partnerAPI}TrackingEvent">
+ *       <sequence>
+ *         <element name="URLID" type="{http://www.w3.org/2001/XMLSchema}int" minOccurs="0"/>
+ *         <element name="URL" type="{http://www.w3.org/2001/XMLSchema}string" minOccurs="0"/>
+ *         <element name="URLIDLong" type="{http://www.w3.org/2001/XMLSchema}long" minOccurs="0"/>
+ *       </sequence>
+ *     </extension>
+ *   </complexContent>
+ * </complexType>
+ * 
+ */ +@XmlAccessorType(XmlAccessType.FIELD) +@XmlType(name = "ClickEvent", propOrder = { + "urlid", + "url", + "urlIdLong" +}) +public class ClickEvent + extends TrackingEvent { + + @XmlElement(name = "URLID") + protected Integer urlid; + @XmlElement(name = "URL") + protected String url; + @XmlElement(name = "URLIDLong") + protected Long urlIdLong; + + /** + * Gets the value of the urlid property. + * + * @return possible object is + * {@link Integer } + */ + public Integer getUrlid() { + return urlid; + } + + /** + * Sets the value of the urlid property. + * + * @param value allowed object is + * {@link Integer } + */ + public void setUrlid(Integer value) { + this.urlid = value; + } + + /** + * Gets the value of the url property. + * + * @return possible object is + * {@link String } + */ + public String getUrl() { + return url; + } + + /** + * Sets the value of the url property. + * + * @param value allowed object is + * {@link String } + */ + public void setUrl(String value) { + this.url = value; + } + + /** + * Gets the value of the urlIdLong property. + * + * @return possible object is + * {@link Long } + */ + public Long getUrlIdLong() { + return urlIdLong; + } + + /** + * Sets the value of the urlIdLong property. + * + * @param value allowed object is + * {@link Long } + */ + public void setUrlIdLong(Long value) { + this.urlIdLong = value; + } + + /** + * Generates a String representation of the contents of this type. + * This is an extension method, produced by the 'ts' xjc plugin + */ + @Override + public String toString() { + return ToStringBuilder.reflectionToString(this, JAXBToStringStyle.DEFAULT_STYLE); + } + +} diff --git a/src/main/java/io/cdap/plugin/sfmc/DataExtensionClient.java b/src/main/java/io/cdap/plugin/sfmc/sink/DataExtensionClient.java similarity index 99% rename from src/main/java/io/cdap/plugin/sfmc/DataExtensionClient.java rename to src/main/java/io/cdap/plugin/sfmc/sink/DataExtensionClient.java index f003396..303c08b 100644 --- a/src/main/java/io/cdap/plugin/sfmc/DataExtensionClient.java +++ b/src/main/java/io/cdap/plugin/sfmc/sink/DataExtensionClient.java @@ -14,7 +14,7 @@ * the License. */ -package io.cdap.plugin.sfmc; +package io.cdap.plugin.sfmc.sink; import com.exacttarget.fuelsdk.ETClient; import com.exacttarget.fuelsdk.ETConfiguration; diff --git a/src/main/java/io/cdap/plugin/sfmc/DataExtensionInfo.java b/src/main/java/io/cdap/plugin/sfmc/sink/DataExtensionInfo.java similarity index 98% rename from src/main/java/io/cdap/plugin/sfmc/DataExtensionInfo.java rename to src/main/java/io/cdap/plugin/sfmc/sink/DataExtensionInfo.java index 95f6e85..a551df8 100644 --- a/src/main/java/io/cdap/plugin/sfmc/DataExtensionInfo.java +++ b/src/main/java/io/cdap/plugin/sfmc/sink/DataExtensionInfo.java @@ -14,7 +14,7 @@ * the License. */ -package io.cdap.plugin.sfmc; +package io.cdap.plugin.sfmc.sink; import com.exacttarget.fuelsdk.ETDataExtensionColumn; diff --git a/src/main/java/io/cdap/plugin/sfmc/DataExtensionOutputFormat.java b/src/main/java/io/cdap/plugin/sfmc/sink/DataExtensionOutputFormat.java similarity index 99% rename from src/main/java/io/cdap/plugin/sfmc/DataExtensionOutputFormat.java rename to src/main/java/io/cdap/plugin/sfmc/sink/DataExtensionOutputFormat.java index 34fbf72..7930b5f 100644 --- a/src/main/java/io/cdap/plugin/sfmc/DataExtensionOutputFormat.java +++ b/src/main/java/io/cdap/plugin/sfmc/sink/DataExtensionOutputFormat.java @@ -14,7 +14,7 @@ * the License. */ -package io.cdap.plugin.sfmc; +package io.cdap.plugin.sfmc.sink; import com.exacttarget.fuelsdk.ETSdkException; import io.cdap.cdap.api.data.format.StructuredRecord; diff --git a/src/main/java/io/cdap/plugin/sfmc/DataExtensionRecordWriter.java b/src/main/java/io/cdap/plugin/sfmc/sink/DataExtensionRecordWriter.java similarity index 99% rename from src/main/java/io/cdap/plugin/sfmc/DataExtensionRecordWriter.java rename to src/main/java/io/cdap/plugin/sfmc/sink/DataExtensionRecordWriter.java index 1052e1e..76226ef 100644 --- a/src/main/java/io/cdap/plugin/sfmc/DataExtensionRecordWriter.java +++ b/src/main/java/io/cdap/plugin/sfmc/sink/DataExtensionRecordWriter.java @@ -14,7 +14,7 @@ * the License. */ -package io.cdap.plugin.sfmc; +package io.cdap.plugin.sfmc.sink; import com.exacttarget.fuelsdk.ETDataExtensionRow; import com.exacttarget.fuelsdk.ETResponse; diff --git a/src/main/java/io/cdap/plugin/sfmc/MarketingCloudConf.java b/src/main/java/io/cdap/plugin/sfmc/sink/MarketingCloudConf.java similarity index 99% rename from src/main/java/io/cdap/plugin/sfmc/MarketingCloudConf.java rename to src/main/java/io/cdap/plugin/sfmc/sink/MarketingCloudConf.java index 7cb9bb7..a2ebedb 100644 --- a/src/main/java/io/cdap/plugin/sfmc/MarketingCloudConf.java +++ b/src/main/java/io/cdap/plugin/sfmc/sink/MarketingCloudConf.java @@ -14,7 +14,7 @@ * the License. */ -package io.cdap.plugin.sfmc; +package io.cdap.plugin.sfmc.sink; import com.exacttarget.fuelsdk.ETSdkException; import io.cdap.cdap.api.annotation.Description; diff --git a/src/main/java/io/cdap/plugin/sfmc/MarketingCloudDataExtensionSink.java b/src/main/java/io/cdap/plugin/sfmc/sink/MarketingCloudDataExtensionSink.java similarity index 99% rename from src/main/java/io/cdap/plugin/sfmc/MarketingCloudDataExtensionSink.java rename to src/main/java/io/cdap/plugin/sfmc/sink/MarketingCloudDataExtensionSink.java index a8936b6..31f0b3d 100644 --- a/src/main/java/io/cdap/plugin/sfmc/MarketingCloudDataExtensionSink.java +++ b/src/main/java/io/cdap/plugin/sfmc/sink/MarketingCloudDataExtensionSink.java @@ -14,7 +14,7 @@ * the License. */ -package io.cdap.plugin.sfmc; +package io.cdap.plugin.sfmc.sink; import io.cdap.cdap.api.annotation.Description; import io.cdap.cdap.api.annotation.Name; diff --git a/src/main/java/io/cdap/plugin/sfmc/Operation.java b/src/main/java/io/cdap/plugin/sfmc/sink/Operation.java similarity index 95% rename from src/main/java/io/cdap/plugin/sfmc/Operation.java rename to src/main/java/io/cdap/plugin/sfmc/sink/Operation.java index 72ce14e..417e1a5 100644 --- a/src/main/java/io/cdap/plugin/sfmc/Operation.java +++ b/src/main/java/io/cdap/plugin/sfmc/sink/Operation.java @@ -14,7 +14,7 @@ * the License. */ -package io.cdap.plugin.sfmc; +package io.cdap.plugin.sfmc.sink; /** * The type of write operation. diff --git a/src/main/java/io/cdap/plugin/sfmc/RecordDataExtensionRowConverter.java b/src/main/java/io/cdap/plugin/sfmc/sink/RecordDataExtensionRowConverter.java similarity index 99% rename from src/main/java/io/cdap/plugin/sfmc/RecordDataExtensionRowConverter.java rename to src/main/java/io/cdap/plugin/sfmc/sink/RecordDataExtensionRowConverter.java index e2e905d..0fefe6d 100644 --- a/src/main/java/io/cdap/plugin/sfmc/RecordDataExtensionRowConverter.java +++ b/src/main/java/io/cdap/plugin/sfmc/sink/RecordDataExtensionRowConverter.java @@ -14,7 +14,7 @@ * the License. */ -package io.cdap.plugin.sfmc; +package io.cdap.plugin.sfmc.sink; import com.exacttarget.fuelsdk.ETDataExtensionColumn; import com.exacttarget.fuelsdk.ETDataExtensionRow; diff --git a/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudClient.java b/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudClient.java new file mode 100644 index 0000000..f1e6867 --- /dev/null +++ b/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudClient.java @@ -0,0 +1,241 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 io.cdap.plugin.sfmc.source; + +//import com.exacttarget.fuelsdk.ETApiObject; + +import com.custom.fuelsdk.PaginationETSoapObject; +import com.exacttarget.fuelsdk.ETClient; +import com.exacttarget.fuelsdk.ETConfiguration; +import com.exacttarget.fuelsdk.ETDataExtensionColumn; +import com.exacttarget.fuelsdk.ETDataExtensionRow; +import com.exacttarget.fuelsdk.ETExpression; +import com.exacttarget.fuelsdk.ETFilter; +import com.exacttarget.fuelsdk.ETResponse; +import com.exacttarget.fuelsdk.ETSdkException; +import com.exacttarget.fuelsdk.ETSoapObject; +import io.cdap.plugin.sfmc.source.util.MarketingCloudColumn; +import io.cdap.plugin.sfmc.source.util.MarketingCloudObjectInfo; +import io.cdap.plugin.sfmc.source.util.SourceObject; +import io.cdap.plugin.sfmc.source.util.Util; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Utility class that provides methods to connect to Salesforce instance. + */ +public class MarketingCloudClient { + private final ETClient client; + private static final Logger LOG = LoggerFactory.getLogger(MarketingCloudClient.class); + + MarketingCloudClient(ETClient client) { + this.client = client; + } + + /** + * Initialize the connection with Salesforce Marketing Cloud using FuelSDK. + * + * @param clientId The Salesforce Marketing Cloud Client Id + * @param clientSecret The Salesforce Marketing Cloud Client Secret + * @param authEndpoint Auth Endpoint url for Salesforce Marketing Cloud + * @param soapEndpoint SOAP Endpoint url for Salesforce Marketing Cloud + * @return The instance of MarketingCloudClient object + * @throws ETSdkException The FuelSDKException + */ + public static MarketingCloudClient create(String clientId, String clientSecret, String authEndpoint, + String soapEndpoint) throws ETSdkException { + ETConfiguration conf = new ETConfiguration(); + conf.set("clientId", clientId); + conf.set("clientSecret", clientSecret); + conf.set("authEndpoint", authEndpoint); + conf.set("soapEndpoint", soapEndpoint); + conf.set("useOAuth2Authentication", "true"); + ClassLoader oldCL = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(MarketingCloudClient.class.getClassLoader()); + return new MarketingCloudClient(new ETClient(conf)); + } finally { + Thread.currentThread().setContextClassLoader(oldCL); + } + } + + /** + * Check if filter string is a valid SFMC Expression. + * + * @param filter + * @return + */ + public static void validateFilter(String filter) throws ETSdkException { + if (!Util.isNullOrEmpty(filter)) { + ETExpression expression = ETExpression.parse(filter); + } + } + + private ETExpression getExpressionfromString(String filter) throws ETSdkException { + if (!Util.isNullOrEmpty(filter)) { + return ETExpression.parse(filter); + } else { + return new ETExpression(); + } + } + + /** + * Fetch records for passed object from Salesforce Marketing Cloud. + * + * @param object The SourceObject which tells what data to be fetched from Salesforce Marketing Cloud + * @return The list of ETApiObject representing the records from requested object + */ + public List fetchObjectRecords(SourceObject object) throws ETSdkException { + ETFilter filter = new ETFilter(); + filter.setExpression(getExpressionfromString(object.getFilter())); + return fetchObjectData(client, object.getClassRef(), filter); + } + + /** + * Fetch the schema information for passed object from Salesforce Marketing Cloud. + * + * @param object The SourceObject which tells for which schema to be fetched from Salesforce Marketing Cloud + * @return The instance of MarketingCloudObjectInfo object + * @throws ETSdkException The FuelSDKException + */ + public MarketingCloudObjectInfo fetchObjectSchema(SourceObject object) + throws ETSdkException { + Class clazz = object.getClassRef(); + return new MarketingCloudObjectInfo(object, fetchObjectFields(clazz)); + } + + /** + * Fetch records for passed object from Salesforce Marketing Cloud. + * + * @param dataExtensionKey The data extension key for which data to be fetched from Salesforce Marketing Cloud + * @return The list of ETDataExtensionRow representing the records from requested data extension + * @throws ETSdkException The FuelSDKException + */ + public List fetchDataExtensionRecords(String dataExtensionKey, String filterStr) + throws ETSdkException { + + ETFilter filter = new ETFilter(); + filter.setExpression(getExpressionfromString(filterStr)); + + ETResponse response = PaginationETSoapObject.select(client, "key=" + dataExtensionKey, + filter); + List rows = response.getObjects(); + while (response.getResponseMessage().equals("MoreDataAvailable")) { + response = PaginationETSoapObject.continueRequest(client, null, response.getRequestId(), + filter); + rows.addAll(response.getObjects()); + } + return rows; + //return call(() -> ETDataExtension.select(client, "key=" + dataExtensionKey) + //.getObjects()); + } + + /** + * Fetch the schema information for passed object from Salesforce Marketing Cloud. + * + * @param dataExtensionKey The data extension key for which schema to be fetched from Salesforce Marketing Cloud + * @return The instance of MarketingCloudObjectInfo object + * @throws ETSdkException The FuelSDKException + */ + public MarketingCloudObjectInfo fetchDataExtensionSchema(String dataExtensionKey) + throws ETSdkException { + return call(() -> { + ETExpression expression = buildDataExtensionExpression(dataExtensionKey); + + ETFilter filter = new ETFilter(); + filter.setExpression(expression); + filter.addProperty("name"); + filter.addProperty("type"); + + ETResponse response = ETDataExtensionColumn.retrieve(client, ETDataExtensionColumn.class, + (Integer) null, (Integer) null, + filter); + List columns = response.getObjects().stream() + .map(o -> new MarketingCloudColumn(o.getName(), o.getType().name())) + .collect(Collectors.toList()); + + //Integer recordCount = fetchDataExtensionRecordCount(dataExtensionKey); + + return new MarketingCloudObjectInfo(SourceObject.DATA_EXTENSION, dataExtensionKey, columns); + }); + } + + private ETExpression buildDataExtensionExpression(String dataExtensionKey) { + ETExpression expression = new ETExpression(); + expression.setProperty("DataExtension.CustomerKey"); + expression.setOperator(ETExpression.Operator.EQUALS); + expression.addValue(dataExtensionKey); + return expression; + } + + private List fetchObjectData(ETClient client, Class clazz, ETFilter filter) + throws ETSdkException { + + ClassLoader oldCL = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(MarketingCloudClient.class.getClassLoader()); + //ETResponse etResponse = client.retrieve(clazz, new ETFilter()); + ETResponse response = (ETResponse) PaginationETSoapObject.customRetrieve(client, null, + filter, null, + clazz); + List rows = response.getObjects(); + while (response.getResponseMessage().equals("MoreDataAvailable")) { + response = (ETResponse) PaginationETSoapObject.customRetrieve(client, null, + filter, response.getRequestId(), + clazz); + rows.addAll(response.getObjects()); + } + return rows; + } finally { + Thread.currentThread().setContextClassLoader(oldCL); + } + } + + + private List fetchObjectFields(Class clazz) { + return Arrays.stream(clazz.getDeclaredFields()) + .map(o -> new MarketingCloudColumn(o.getName(), o.getType().getSimpleName().toUpperCase())) + .collect(Collectors.toList()); + } + + private T call(SFMCCall callable) throws ETSdkException { + ClassLoader oldClassloader = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(getClass().getClassLoader()); + return callable.call(); + } finally { + Thread.currentThread().setContextClassLoader(oldClassloader); + } + } + + /** + * A SFMC call. + * + * @param type of return object + */ + private interface SFMCCall { + + /** + * Perform a call. + */ + T call() throws ETSdkException; + } +} diff --git a/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudInputFormat.java b/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudInputFormat.java new file mode 100644 index 0000000..a3d9eff --- /dev/null +++ b/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudInputFormat.java @@ -0,0 +1,176 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 io.cdap.plugin.sfmc.source; + +import io.cdap.cdap.api.data.format.StructuredRecord; +import io.cdap.plugin.sfmc.source.util.MarketingCloudObjectInfo; +import io.cdap.plugin.sfmc.source.util.SourceObject; +import io.cdap.plugin.sfmc.source.util.SourceQueryMode; +import io.cdap.plugin.sfmc.source.util.Util; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.io.NullWritable; +import org.apache.hadoop.mapreduce.InputFormat; +import org.apache.hadoop.mapreduce.InputSplit; +import org.apache.hadoop.mapreduce.JobContext; +import org.apache.hadoop.mapreduce.RecordReader; +import org.apache.hadoop.mapreduce.TaskAttemptContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Salesforce input format. + */ +public class MarketingCloudInputFormat extends InputFormat { + private static final Logger LOG = LoggerFactory.getLogger(MarketingCloudInputFormat.class); + + /** + * Configure the input format to read tables from Salesforce. Should be called from the mapreduce client. + * + * @param jobConfig the job configuration + * @param mode the query mode + * @param conf the plugin conf + * @return Collection of MarketingCloudObjectInfo containing table and schema. + */ + public static List setInput(Configuration jobConfig, SourceQueryMode mode, + MarketingCloudSourceConfig conf) { + MarketingCloudJobConfiguration jobConf = new MarketingCloudJobConfiguration(jobConfig); + jobConf.setPluginConfiguration(conf); + + //Depending on the selected objects in the conf, get the schema for each object as MarketingCloudObjectInfo + List tableInfos = fetchTableInfo(mode, conf); + + jobConf.setTableInfos(tableInfos); + + LOG.debug("setInput::tableInfos = {}", tableInfos.size()); + return tableInfos; + } + + /** + * Depending on conf value fetch the list of fields for each object and create schema object. + * + * @param mode the query mode + * @param conf the plugin conf + * @return Collection of MarketingCloudObjectInfo containing table and schema. + */ + private static List fetchTableInfo(SourceQueryMode mode, MarketingCloudSourceConfig conf) { + try { + MarketingCloudClient client = MarketingCloudClient.create(conf.getClientId(), conf.getClientSecret(), + conf.getAuthEndpoint(), conf.getSoapEndpoint()); + + boolean failOnError = conf.isFailOnError(); + + //When mode = SingleObject, fetch fields for the object selected in plugin config + if (mode == SourceQueryMode.SINGLE_OBJECT) { + MarketingCloudObjectInfo tableInfo = getTableMetaData(conf.getObject(), conf.getDataExtensionKey(), client, + failOnError); + return (tableInfo == null) ? Collections.emptyList() : Collections.singletonList(tableInfo); + } + + //When mode = MultiObject, get the list of objects provided in plugin config and the fetch fields for each of + //then objects. + List tableInfos = new ArrayList<>(); + List objectList = conf.getObjectList(); + + for (SourceObject object : objectList) { + MarketingCloudObjectInfo tableInfo = null; + + if (object == SourceObject.DATA_EXTENSION) { + //if the object = Data Extension then get the list of data extension keys and then fetch fields for each of + //the data extension objects. + List dataExtensionKeys = Util.splitToList(conf.getDataExtensionKeys(), ','); + for (String dataExtensionKey : dataExtensionKeys) { + tableInfo = getTableMetaData(object, dataExtensionKey, client, failOnError); + if (tableInfo == null) { + continue; + } + tableInfos.add(tableInfo); + } + } else { + tableInfo = getTableMetaData(object, "", client, failOnError); + if (tableInfo != null) { + tableInfos.add(tableInfo); + } + } + } + + return tableInfos; + } catch (Exception e) { + if (conf.isFailOnError()) { + LOG.error("Error in fetchTableInfo()", e); + } else { + LOG.warn("Failed in fetchTableInfo()", e); + } + return Collections.emptyList(); + } + } + + /** + * Fetch the fields for passed object. + */ + private static MarketingCloudObjectInfo getTableMetaData(SourceObject object, String dataExtensionKey, + MarketingCloudClient client, boolean failOnError) { + try { + if (object == SourceObject.DATA_EXTENSION) { + return client.fetchDataExtensionSchema(dataExtensionKey); + } else { + return client.fetchObjectSchema(object); + } + } catch (Exception e) { + if (failOnError) { + LOG.error("Error in getTableMetaData()", e); + } else { + LOG.warn("Failed in getTableMetaData()", e); + } + return null; + } + } + + @Override + public List getSplits(JobContext jobContext) throws IOException, InterruptedException { + MarketingCloudJobConfiguration jobConfig = new MarketingCloudJobConfiguration(jobContext.getConfiguration()); + MarketingCloudSourceConfig pluginConf = jobConfig.getPluginConf(); + + List tableInfos = jobConfig.getTableInfos(); + List resultSplits = new ArrayList<>(); + + for (MarketingCloudObjectInfo tableInfo : tableInfos) { + String tableKey = tableInfo.getObject().name(); + String tableName = tableInfo.getTableName(); + resultSplits.add(new MarketingCloudInputSplit(tableKey, tableName)); + } + + LOG.debug("# of split = {}", resultSplits.size()); + + return resultSplits; + } + + @Override + public RecordReader createRecordReader(InputSplit inputSplit, + TaskAttemptContext taskAttemptContext) + throws IOException, InterruptedException { + MarketingCloudJobConfiguration jobConfig = new MarketingCloudJobConfiguration( + taskAttemptContext.getConfiguration()); + MarketingCloudSourceConfig pluginConf = jobConfig.getPluginConf(); + + return new MarketingCloudRecordReader(pluginConf); + } +} diff --git a/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudInputSplit.java b/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudInputSplit.java new file mode 100644 index 0000000..396409e --- /dev/null +++ b/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudInputSplit.java @@ -0,0 +1,78 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 io.cdap.plugin.sfmc.source; + +import org.apache.hadoop.io.Writable; +import org.apache.hadoop.mapreduce.InputSplit; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +/** + * Salesforce input split. + */ +public class MarketingCloudInputSplit extends InputSplit implements Writable { + private String objectName; + private String tableName; + + // used by mapreduce + public MarketingCloudInputSplit() { + } + + /** + * Constructor for MarketingCloudInputSplit. + * + * @param objectName The object name + * @param tableName The corresponding table name + */ + public MarketingCloudInputSplit(String objectName, String tableName) { + this.objectName = objectName; + this.tableName = tableName; + } + + public String getObjectName() { + return objectName; + } + + public String getTableName() { + return tableName; + } + + + @Override + public void write(DataOutput dataOutput) throws IOException { + dataOutput.writeUTF(this.objectName); + dataOutput.writeUTF(this.tableName); + } + + @Override + public void readFields(DataInput dataInput) throws IOException { + this.objectName = dataInput.readUTF(); + this.tableName = dataInput.readUTF(); + } + + @Override + public long getLength() throws IOException, InterruptedException { + return 0; + } + + @Override + public String[] getLocations() throws IOException, InterruptedException { + return new String[0]; + } +} diff --git a/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudJobConfiguration.java b/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudJobConfiguration.java new file mode 100644 index 0000000..42d42cf --- /dev/null +++ b/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudJobConfiguration.java @@ -0,0 +1,70 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 io.cdap.plugin.sfmc.source; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import io.cdap.plugin.sfmc.source.util.MarketingCloudObjectInfo; +import org.apache.hadoop.conf.Configuration; + +import java.lang.reflect.Type; +import java.util.List; + +/** + * Allows to specify and access connection configuration properties of {@link Configuration}. + */ +public class MarketingCloudJobConfiguration { + private static final String PLUGIN_CONF_FIELD = "salesforce.plugin.conf"; + private static final String INFO_FIELD = "salesforce.table.info"; + + private static final Type PLUGIN_CONF_TYPE = new TypeToken() { + }.getType(); + private static final Type INFO_TYPE = new TypeToken>() { + }.getType(); + + private static final Gson GSON = new Gson(); + + private Configuration conf; + + public MarketingCloudJobConfiguration(Configuration job) { + this.conf = job; + } + + public Configuration getConf() { + return this.conf; + } + + public void setPluginConfiguration(MarketingCloudSourceConfig conf) { + set(PLUGIN_CONF_FIELD, GSON.toJson(conf)); + } + + public MarketingCloudSourceConfig getPluginConf() { + return GSON.fromJson(getConf().get(PLUGIN_CONF_FIELD), PLUGIN_CONF_TYPE); + } + + public List getTableInfos() { + return GSON.fromJson(getConf().get(INFO_FIELD), INFO_TYPE); + } + + public void setTableInfos(List infoList) { + set(INFO_FIELD, GSON.toJson(infoList)); + } + + private void set(String key, String value) { + getConf().set(key, value); + } +} diff --git a/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudRecordReader.java b/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudRecordReader.java new file mode 100644 index 0000000..2b73105 --- /dev/null +++ b/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudRecordReader.java @@ -0,0 +1,319 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 io.cdap.plugin.sfmc.source; + +import com.exacttarget.fuelsdk.ETApiObject; +import com.exacttarget.fuelsdk.ETDataExtensionRow; +import com.google.common.base.Strings; +import io.cdap.cdap.api.data.format.StructuredRecord; +import io.cdap.cdap.api.data.schema.Schema; +import io.cdap.plugin.sfmc.source.util.MarketingCloudConstants; +import io.cdap.plugin.sfmc.source.util.MarketingCloudObjectInfo; +import io.cdap.plugin.sfmc.source.util.SourceObject; +import io.cdap.plugin.sfmc.source.util.SourceQueryMode; +import org.apache.hadoop.io.NullWritable; +import org.apache.hadoop.mapreduce.InputSplit; +import org.apache.hadoop.mapreduce.RecordReader; +import org.apache.hadoop.mapreduce.TaskAttemptContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Record reader that reads the entire contents of a Salesforce table. + */ +public class MarketingCloudRecordReader extends RecordReader { + private static final Logger LOG = LoggerFactory.getLogger(MarketingCloudRecordReader.class); + private final MarketingCloudSourceConfig pluginConf; + private MarketingCloudInputSplit split; + private int pos; + private List tableFields; + private MarketingCloudObjectInfo sfObjectMetaData; + private Schema schema; + + private SourceObject object; + private String dataExtensionKey = ""; + private String tableName; + private String formattedTableName; + private String tableNameField; + private List results; + private Iterator iterator; + private ETApiObject row; + + MarketingCloudRecordReader(MarketingCloudSourceConfig pluginConf) { + this.pluginConf = pluginConf; + } + + @Override + public void initialize(InputSplit split, TaskAttemptContext context) { + this.split = (MarketingCloudInputSplit) split; + this.pos = 0; + } + + @Override + public boolean nextKeyValue() throws IOException { + try { + if (results == null) { + fetchData(); + } + + if (!iterator.hasNext()) { + return false; + } + + row = iterator.next(); + + pos++; + } catch (Exception e) { + if (pluginConf.isFailOnError()) { + LOG.error("Error in nextKeyValue", e); + throw new IOException("Exception in nextKeyValue", e); + } else { + LOG.warn("Failed in nextKeyValue", e); + } + } + return true; + } + + @Override + public NullWritable getCurrentKey() { + return NullWritable.get(); + } + + @Override + public StructuredRecord getCurrentValue() throws IOException { + StructuredRecord.Builder recordBuilder = StructuredRecord.builder(schema); + + if (pluginConf.getQueryMode() == SourceQueryMode.MULTI_OBJECT) { + recordBuilder.set(tableNameField, formattedTableName); + } + + try { + + convertRecord(recordBuilder, row); + + } catch (Exception e) { + if (pluginConf.isFailOnError()) { + LOG.error(String.format("Error decoding row from table %s", tableName), e); + throw new IOException(String.format("Error decoding row from table %s", tableName), e); + } else { + LOG.warn(String.format("Failed decoding row from table %s", tableName), e); + } + } + return recordBuilder.build(); + } + + @Override + public float getProgress() throws IOException, InterruptedException { + return pos / (float) split.getLength(); + } + + @Override + public void close() throws IOException { + } + + private void fetchData() { + object = SourceObject.valueOf(split.getObjectName()); + tableName = split.getTableName(); + formattedTableName = tableName.replaceAll("-", "_"); + + if (object == SourceObject.DATA_EXTENSION) { + dataExtensionKey = tableName.replaceAll(MarketingCloudConstants.DATA_EXTENSION_PREFIX, ""); + } + tableNameField = pluginConf.getTableNameField(); + + //Get the table data + try { + MarketingCloudClient client = MarketingCloudClient.create(pluginConf.getClientId(), pluginConf.getClientSecret(), + pluginConf.getAuthEndpoint(), + pluginConf.getSoapEndpoint()); + + //Fetch data + if (object == SourceObject.DATA_EXTENSION) { + results = client.fetchDataExtensionRecords(dataExtensionKey, object.getFilter()); + } else { + results = client.fetchObjectRecords(object); + } + + LOG.debug("size={}", results.size()); + if (!results.isEmpty()) { + fetchSchema(client); + } + } catch (Exception e) { + results = Collections.emptyList(); + if (pluginConf.isFailOnError()) { + LOG.error("Error while fetching data", e); + } else { + LOG.warn("Failed while fetching data", e); + } + } + + iterator = results.iterator(); + } + + private void fetchSchema(MarketingCloudClient client) { + //Fetch the column definition + List schemaFields; + + try { + if (object == SourceObject.DATA_EXTENSION) { + sfObjectMetaData = client.fetchDataExtensionSchema(dataExtensionKey); + } else { + sfObjectMetaData = client.fetchObjectSchema(object); + } + + //Build schema + tableFields = sfObjectMetaData.getSchema().getFields(); + schemaFields = new ArrayList<>(tableFields); + + if (pluginConf.getQueryMode() == SourceQueryMode.MULTI_OBJECT) { + schemaFields.add(Schema.Field.of(tableNameField, Schema.of(Schema.Type.STRING))); + } + } catch (Exception e) { + schemaFields = Collections.emptyList(); + } + + schema = Schema.recordOf(tableName.replaceAll("-", "_"), schemaFields); + } + + /** + * Read data from ETApiObject and convert it to StructureRecord. + */ + private void convertRecord(StructuredRecord.Builder recordBuilder, ETApiObject row) { + for (Schema.Field field : tableFields) { + String fieldName = field.getName(); + Object rawFieldValue = null; + if (row instanceof ETDataExtensionRow) { + String apiFieldName = sfObjectMetaData.lookupFieldsMap(fieldName); + rawFieldValue = ((ETDataExtensionRow) row).getColumn(apiFieldName); + } else { + rawFieldValue = getFieldValue(row, fieldName); + } + + Object fieldValue = convertToValue(fieldName, field.getSchema(), rawFieldValue); + + recordBuilder.set(fieldName, fieldValue); + } + } + + /** + * Read data from ETApiObject using reflection for a given field name. + */ + private Object getFieldValue(ETApiObject row, String fieldName) { + try { + Method method = row.getClass().getMethod(createGetterName(fieldName)); + return method.invoke(row); + } catch (Exception e) { + if (pluginConf.isFailOnError()) { + LOG.error(String.format("Error while fetching %s.%s value", row.getClass().getSimpleName(), fieldName), e); + } else { + LOG.warn(String.format("Failed while fetching %s.%s value", row.getClass().getSimpleName(), fieldName), e); + } + return null; + } + } + + /** + * Constructs the get method name to be used in reflection call. + */ + private String createGetterName(String name) { + StringBuilder sb = new StringBuilder("get"); + sb.append(name.substring(0, 1).toUpperCase()); + sb.append(name.substring(1)); + return sb.toString(); + } + + /** + * Converts raw field value according to the schema field type. + */ + private Object convertToValue(String fieldName, Schema fieldSchema, Object fieldValue) { + Schema.Type fieldType = fieldSchema.getType(); + Schema.LogicalType logicalType = fieldSchema.getLogicalType(); + if (fieldSchema.getLogicalType() != null) { + return transformLogicalType(fieldName, logicalType, fieldValue); + } + + switch (fieldType) { + case STRING: + return convertToStringValue(fieldValue); + case DOUBLE: + return convertToDoubleValue(fieldValue); + case INT: + return convertToIntegerValue(fieldValue); + case BOOLEAN: + return convertToBooleanValue(fieldValue); + case UNION: + if (fieldSchema.isNullable()) { + return convertToValue(fieldName, fieldSchema.getNonNullable(), fieldValue); + } + throw new IllegalStateException( + String.format("Field '%s' is of unexpected type '%s'. Declared 'complex UNION' types: %s", + fieldName, fieldValue.getClass().getSimpleName(), fieldSchema.getUnionSchemas())); + default: + throw new IllegalStateException( + String.format("Record type '%s' is not supported for field '%s'", fieldType.name(), fieldName)); + } + } + + private String convertToStringValue(Object fieldValue) { + return String.valueOf(fieldValue); + } + + private Double convertToDoubleValue(Object fieldValue) { + if (fieldValue instanceof String && Strings.isNullOrEmpty(String.valueOf(fieldValue))) { + return null; + } + + return Double.parseDouble(String.valueOf(fieldValue)); + } + + private Integer convertToIntegerValue(Object fieldValue) { + if (fieldValue instanceof String && Strings.isNullOrEmpty(String.valueOf(fieldValue))) { + return null; + } + + return Integer.parseInt(String.valueOf(fieldValue)); + } + + private Boolean convertToBooleanValue(Object fieldValue) { + if (fieldValue instanceof String && Strings.isNullOrEmpty(String.valueOf(fieldValue))) { + return null; + } + + return Boolean.parseBoolean(String.valueOf(fieldValue)); + } + private Object transformLogicalType(String fieldName, Schema.LogicalType logicalType, Object value) { + switch (logicalType) { + case TIMESTAMP_MICROS: + if (value instanceof Date) { + return TimeUnit.MILLISECONDS.toMicros((((Date) value).getTime())); + } + return null; + default: + throw new IllegalArgumentException( + String.format("Field '%s' is of unsupported type '%s'", fieldName, logicalType.getToken())); + } + } +} diff --git a/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudSource.java b/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudSource.java new file mode 100644 index 0000000..85f2352 --- /dev/null +++ b/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudSource.java @@ -0,0 +1,148 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 io.cdap.plugin.sfmc.source; + +import io.cdap.cdap.api.annotation.Description; +import io.cdap.cdap.api.annotation.Name; +import io.cdap.cdap.api.annotation.Plugin; +import io.cdap.cdap.api.data.batch.Input; +import io.cdap.cdap.api.data.format.StructuredRecord; +import io.cdap.cdap.api.data.schema.Schema; +import io.cdap.cdap.api.dataset.lib.KeyValue; +import io.cdap.cdap.etl.api.Emitter; +import io.cdap.cdap.etl.api.Engine; +import io.cdap.cdap.etl.api.FailureCollector; +import io.cdap.cdap.etl.api.PipelineConfigurer; +import io.cdap.cdap.etl.api.StageConfigurer; +import io.cdap.cdap.etl.api.action.SettableArguments; +import io.cdap.cdap.etl.api.batch.BatchSource; +import io.cdap.cdap.etl.api.batch.BatchSourceContext; +import io.cdap.plugin.common.LineageRecorder; +import io.cdap.plugin.common.SourceInputFormatProvider; +import io.cdap.plugin.sfmc.source.util.MarketingCloudConstants; +import io.cdap.plugin.sfmc.source.util.MarketingCloudObjectInfo; +import io.cdap.plugin.sfmc.source.util.SourceQueryMode; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.io.NullWritable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * A {@link BatchSource} that reads data from multiple objects in Salesforce. + */ +@Plugin(type = BatchSource.PLUGIN_TYPE) +@Name(MarketingCloudConstants.PLUGIN_NAME) +@Description("Reads from multiple objects in Salesforce. " + + "Outputs one record for each row in each object, with the object name as a record field. " + + "Also sets a pipeline argument for each object read, which contains the object schema.") +public class MarketingCloudSource extends BatchSource { + private static final Logger LOG = LoggerFactory.getLogger(MarketingCloudSource.class); + + private final MarketingCloudSourceConfig conf; + + public MarketingCloudSource(MarketingCloudSourceConfig conf) { + this.conf = conf; + } + + @Override + public void configurePipeline(PipelineConfigurer pipelineConfigurer) { + super.configurePipeline(pipelineConfigurer); + + StageConfigurer stageConfigurer = pipelineConfigurer.getStageConfigurer(); + FailureCollector collector = stageConfigurer.getFailureCollector(); + + conf.validate(collector); + // Since we have validated all the properties, throw an exception if there are any + // errors in the collector. This is to avoid adding same validation errors again in + // getSchema method call + collector.getOrThrowException(); + + //Get Schema + stageConfigurer.setOutputSchema(getSchema(conf.getQueryMode())); + + if (conf.isFailOnError()) { + if (pipelineConfigurer.getEngine() == Engine.SPARK) { + pipelineConfigurer.setPipelineProperties(Collections.singletonMap("spark.task.maxFailures", "1")); + } else if (pipelineConfigurer.getEngine() == Engine.MAPREDUCE) { + Map properties = new HashMap<>(); + properties.put("mapreduce.reduce.maxattempts", "1"); + properties.put("mapreduce.map.maxattempts", "1"); + pipelineConfigurer.setPipelineProperties(properties); + } + } + } + + @Override + public void prepareRun(BatchSourceContext context) throws Exception { + FailureCollector collector = context.getFailureCollector(); + conf.validate(collector); + collector.getOrThrowException(); + + SourceQueryMode mode = conf.getQueryMode(collector); + + Configuration hConf = new Configuration(); + Collection tables = MarketingCloudInputFormat.setInput(hConf, mode, conf); + SettableArguments arguments = context.getArguments(); + for (MarketingCloudObjectInfo tableInfo : tables) { + arguments.set(MarketingCloudConstants.TABLE_PREFIX + tableInfo.getFormattedTableName(), + tableInfo.getSchema().toString()); + recordLineage(context, tableInfo); + } + + context.setInput(Input.of(conf.getReferenceName(), + new SourceInputFormatProvider(MarketingCloudInputFormat.class, hConf))); + } + + @Override + public void transform(KeyValue input, Emitter emitter) { + emitter.emit(input.getValue()); + } + + private Schema getSchema(SourceQueryMode mode) { + Schema schema = null; + if (mode == SourceQueryMode.SINGLE_OBJECT) { + Configuration hConf = new Configuration(); + Collection tables = MarketingCloudInputFormat.setInput(hConf, mode, conf); + if (tables != null && !tables.isEmpty()) { + schema = tables.iterator().next().getSchema(); + } + } + return schema; + } + + private void recordLineage(BatchSourceContext context, MarketingCloudObjectInfo tableInfo) { + String tableName = tableInfo.getFormattedTableName(); + String outputName = String.format("%s-%s", conf.getReferenceName(), tableName); + Schema schema = tableInfo.getSchema(); + LineageRecorder lineageRecorder = new LineageRecorder(context, outputName); + lineageRecorder.createExternalDataset(schema); + List fields = Objects.requireNonNull(schema).getFields(); + if (fields != null && !fields.isEmpty()) { + lineageRecorder.recordRead("Read", + String.format("Read from '%s' Marketing Cloud object.", tableName), + fields.stream().map(Schema.Field::getName).collect(Collectors.toList())); + } + } +} diff --git a/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudSourceConfig.java b/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudSourceConfig.java new file mode 100644 index 0000000..80c4a72 --- /dev/null +++ b/src/main/java/io/cdap/plugin/sfmc/source/MarketingCloudSourceConfig.java @@ -0,0 +1,487 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 io.cdap.plugin.sfmc.source; + +import com.exacttarget.fuelsdk.ETSdkException; +import com.google.common.annotations.VisibleForTesting; +import io.cdap.cdap.api.annotation.Description; +import io.cdap.cdap.api.annotation.Macro; +import io.cdap.cdap.api.annotation.Name; +import io.cdap.cdap.api.plugin.PluginConfig; +import io.cdap.cdap.etl.api.FailureCollector; +import io.cdap.plugin.common.Constants; +import io.cdap.plugin.common.IdUtils; +import io.cdap.plugin.sfmc.source.util.MarketingCloudConstants; +import io.cdap.plugin.sfmc.source.util.SourceObject; +import io.cdap.plugin.sfmc.source.util.SourceQueryMode; +import io.cdap.plugin.sfmc.source.util.Util; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import javax.annotation.Nullable; + +/** + * Configuration for the {@link MarketingCloudSource}. + */ +public class MarketingCloudSourceConfig extends PluginConfig { + private static final Logger LOG = LoggerFactory.getLogger(MarketingCloudSourceConfig.class); + + @Name(Constants.Reference.REFERENCE_NAME) + @Description("This will be used to uniquely identify this source for lineage, annotating metadata, etc.") + private String referenceName; + + @Name(MarketingCloudConstants.PROPERTY_QUERY_MODE) + @Macro + @Description("Mode of data retrieval. The mode can be one of two values: " + + "`Multi Object` - will allow user to fetch data for multiple data extensions, " + + "`Single Object` - will allow user to fetch data for single data extension.") + private String queryMode; + + @Name(MarketingCloudConstants.PROPERTY_OBJECT_NAME) + @Macro + @Nullable + @Description("Specify the object for which data to be fetched. This can be one of following values: " + + "`Data Extension` - will allow user to fetch data for a single Data Extension object, " + + "`Campaign` - will allow user to fetch data for Campaign object, " + + "`Email` - will allow user to fetch data for Email object, " + + "`Mailing List` - will allow user to fetch data for Mailing List object. " + + "Note, this value will be ignored if the Mode is set to `Multi Object`.") + private String objectName; + + @Name(MarketingCloudConstants.PROPERTY_DATA_EXTENSION_KEY) + @Macro + @Nullable + @Description("Specify the data extension key from which data to be fetched. Note, this value will be ignored in " + + "following two cases: 1. If the Mode is set to `Multi Object`, 2. If the selected object name is other than " + + "`Data Extension`.") + private String dataExtensionKey; + + @Name(MarketingCloudConstants.PROPERTY_OBJECT_LIST) + @Macro + @Nullable + @Description("Specify the comma-separated list of objects for which data to be fetched; for example: " + + "'Object1,Object2'. This can be one or more values from following possible values: " + + "`Data Extension` - will allow user to fetch data for a single Data Extension object, " + + "`Campaign` - will allow user to fetch data for Campaign object, " + + "`Email` - will allow user to fetch data for Email object, " + + "`Mailing List` - will allow user to fetch data for Mailing List object. " + + "Note, this value will be ignored if the Mode is set to `Single Object`.") + private String objectList; + + @Name(MarketingCloudConstants.PROPERTY_DATA_EXTENSION_KEY_LIST) + @Macro + @Nullable + @Description("Specify the data extension keys from which data to be fetched; for example: 'Key1,Key2'. " + + "Note, this value will be ignored in following two cases: 1. If the Mode is set to `Single Object`, " + + "2. If the selected object list does not contain `Data Extension` as one of the objects.") + private String dataExtensionKeys; + + @Name(MarketingCloudConstants.PROPERTY_TABLE_NAME_FIELD) + @Macro + @Nullable + @Description("The name of the field that holds the object name to which the data belongs to. Must not be the name " + + "of any column for any of the objects that will be read. Defaults to `tablename`. In case of `Data Extension` " + + "object, this field will have value in `dataextension_[Data Extension Key]` format. Note, the Table name field " + + "value will be ignored if the Mode is set to `Single Object`.") + private String tableNameField; + + @Name(MarketingCloudConstants.PROPERTY_FILTER) + @Macro + @Nullable + @Description("The WHERE clause used to filter data from Marketing cloud objects.") + private String filter; + + @Name(MarketingCloudConstants.PROPERTY_CLIENT_ID) + @Macro + @Description("OAuth2 client ID associated with an installed package in the Salesforce Marketing Cloud.") + private String clientId; + + @Name(MarketingCloudConstants.PROPERTY_CLIENT_SECRET) + @Macro + @Description("OAuth2 client secret associated with an installed package in the Salesforce Marketing Cloud.") + private String clientSecret; + + @Name(MarketingCloudConstants.PROPERTY_API_ENDPOINT) + @Macro + @Description("The REST API Base URL associated for the Server-to-Server API integration. " + + "For example, https://instance.rest.marketingcloudapis.com/") + private String restEndpoint; + + @Name(MarketingCloudConstants.PROPERTY_AUTH_API_ENDPOINT) + @Macro + @Description("Authentication Base URL associated for the Server-to-Server API integration. " + + "For example, https://instance.auth.marketingcloudapis.com/") + private String authEndpoint; + + @Name(MarketingCloudConstants.PROPERTY_SOAP_API_ENDPOINT) + @Macro + @Description("The SOAP Endpoint URL associated for the Server-to-Server API integration. " + + "For example, https://instance.soap.marketingcloudapis.com/Service.asmx") + private String soapEndpoint; + + @Macro + @Nullable + @Name(MarketingCloudConstants.PROPERTY_FAIL_ON_ERROR) + @Description("Whether to fail the pipeline if it fails while reading.") + private Boolean failOnError; + + /** + * Constructor for MarketingCloudSourceConfig object. + * + * @param referenceName The reference name + * @param queryMode The query mode + * @param objectName The object name to be fetched from Salesforce Marketing Cloud + * @param dataExtensionKey The data extension key to be fetched from Salesforce Marketing Cloud + * @param objectList The list of objects to be fetched from Salesforce Marketing Cloud + * @param dataExtensionKeys The list of data extension keys to be fetched from Salesforce Marketing Cloud + * @param tableNameField The field name to hold the table name value + * @param clientId The Salesforce Marketing Cloud Client Id + * @param clientSecret The Salesforce Marketing Cloud Client Secret + * @param restEndpoint The REST API endpoint for Salesforce Marketing Cloud + * @param authEndpoint The AUTH API endpoint for Salesforce Marketing Cloud + * @param soapEndpoint The SOAP API endpoint for Salesforce Marketing Cloud + * @param failOnError A flag to decide what is to be done to the pipeline execution in case of errors + */ + public MarketingCloudSourceConfig(String referenceName, String queryMode, @Nullable String objectName, + @Nullable String dataExtensionKey, @Nullable String objectList, + @Nullable String dataExtensionKeys, @Nullable String tableNameField, + @Nullable String filter, String clientId, String clientSecret, + String restEndpoint, String authEndpoint, String soapEndpoint, + Boolean failOnError) { + this.referenceName = referenceName; + this.queryMode = queryMode; + this.objectName = objectName; + this.dataExtensionKey = dataExtensionKey; + this.objectList = objectList; + this.dataExtensionKeys = dataExtensionKeys; + this.tableNameField = tableNameField; + this.filter = filter; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.restEndpoint = restEndpoint; + this.authEndpoint = authEndpoint; + this.soapEndpoint = soapEndpoint; + this.failOnError = failOnError; + } + + public String getReferenceName() { + return referenceName; + } + + /** + * Returns the query mode chosen. + * + * @param collector The failure collector to collect the errors + * @return An instance of SourceQueryMode + */ + public SourceQueryMode getQueryMode(FailureCollector collector) { + SourceQueryMode mode = getQueryMode(); + if (mode != null) { + return mode; + } + + collector.addFailure("Unsupported query mode value: " + queryMode, + String.format("Supported modes are: %s", SourceQueryMode.getSupportedModes())) + .withConfigProperty(MarketingCloudConstants.PROPERTY_QUERY_MODE); + collector.getOrThrowException(); + return null; + } + + /** + * Returns the query mode chosen. + * + * @return An instance of SourceQueryMode + */ + public SourceQueryMode getQueryMode() { + Optional sourceQueryMode = SourceQueryMode.fromValue(queryMode); + + return sourceQueryMode.isPresent() ? sourceQueryMode.get() : null; + } + + /** + * Returns selected object. + * + * @param collector The failure collector to collect the errors + * @return An instance of SourceObject + */ + public SourceObject getObject(FailureCollector collector) { + SourceObject sourceObject = getObject(); + if (sourceObject != null) { + return sourceObject; + } + + collector.addFailure("Unsupported object value: " + objectName, + String.format("Supported objects are: %s", SourceObject.getSupportedObjects())) + .withConfigProperty(MarketingCloudConstants.PROPERTY_OBJECT_NAME); + collector.getOrThrowException(); + return null; + } + + @Nullable + public SourceObject getObject() { + return getSourceObject(objectName, filter); + } + + @Nullable + public String getDataExtensionKey() { + return dataExtensionKey; + } + + /** + * Returns list of selected objects. + * + * @param collector The failure collector to collect the errors + * @return The list of SourceObject + */ + public List getObjectList(FailureCollector collector) { + List objects = Util.splitToList(objectList, ','); + List sourceObjects = new ArrayList<>(); + + for (String object : objects) { + SourceObject sourceObject = getSourceObject(object, filter); + if (sourceObject == null) { + collector.addFailure("Unsupported object value: " + object, + String.format("Supported objects are: %s", SourceObject.getSupportedObjects())) + .withConfigProperty(MarketingCloudConstants.PROPERTY_OBJECT_LIST); + break; + } + sourceObjects.add(sourceObject); + } + + return sourceObjects; + } + + /** + * Returns list of selected objects. + * + * @return The list of SourceObject + */ + @Nullable + public List getObjectList() { + List objects = Util.splitToList(objectList, ','); + List sourceObjects = new ArrayList<>(); + + for (String object : objects) { + SourceObject sourceObject = getSourceObject(object, filter); + if (sourceObject == null) { + continue; + } + sourceObjects.add(sourceObject); + } + + return sourceObjects; + } + + @Nullable + public String getDataExtensionKeys() { + return dataExtensionKeys; + } + + @Nullable + public String getTableNameField() { + return tableNameField; + } + + @Nullable + public String getFilter() { + return filter; + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public String getRestEndpoint() { + return restEndpoint; + } + + public String getAuthEndpoint() { + return authEndpoint; + } + + public String getSoapEndpoint() { + return soapEndpoint; + } + + public Boolean isFailOnError() { + return failOnError == null ? false : failOnError; + } + + /** + * Validates {@link MarketingCloudSourceConfig} instance. + */ + public void validate(FailureCollector collector) { + //Validates the given referenceName to consists of characters allowed to represent a dataset. + IdUtils.validateReferenceName(referenceName, collector); + + validateCredentials(collector); + validateQueryMode(collector); + validateFilter(collector); + } + + private SourceObject getSourceObject(String objectName, String filter) { + Optional sourceObject = SourceObject.fromValue(objectName); + if (sourceObject.isPresent()) { + SourceObject obj = sourceObject.get(); + obj.setFilter(filter); + return obj; + } else { + return null; + } + } + + private void validateCredentials(FailureCollector collector) { + if (!shouldConnect()) { + return; + } + + if (Util.isNullOrEmpty(clientId)) { + collector.addFailure("Client ID must be specified.", null) + .withConfigProperty(MarketingCloudConstants.PROPERTY_CLIENT_ID); + } + + if (Util.isNullOrEmpty(clientSecret)) { + collector.addFailure("Client Secret must be specified.", null) + .withConfigProperty(MarketingCloudConstants.PROPERTY_CLIENT_SECRET); + } + + if (Util.isNullOrEmpty(restEndpoint)) { + collector.addFailure(" REST Endpoint must be specified.", null) + .withConfigProperty(MarketingCloudConstants.PROPERTY_API_ENDPOINT); + } + + if (Util.isNullOrEmpty(authEndpoint)) { + collector.addFailure("Auth Endpoint must be specified.", null) + .withConfigProperty(MarketingCloudConstants.PROPERTY_AUTH_API_ENDPOINT); + } + + if (Util.isNullOrEmpty(soapEndpoint)) { + collector.addFailure("Soap Endpoint must be specified.", null) + .withConfigProperty(MarketingCloudConstants.PROPERTY_SOAP_API_ENDPOINT); + } + + collector.getOrThrowException(); + validateSalesforceConnection(collector); + } + + @VisibleForTesting + void validateSalesforceConnection(FailureCollector collector) { + try { + MarketingCloudClient.create(clientId, clientSecret, authEndpoint, soapEndpoint); + } catch (ETSdkException e) { + collector.addFailure("Unable to connect to Salesforce Instance.", + "Ensure properties like Client ID, Client Secret, API Endpoint " + + ", Soap Endpoint, Auth Endpoint are correct.") + .withConfigProperty(MarketingCloudConstants.PROPERTY_CLIENT_ID) + .withConfigProperty(MarketingCloudConstants.PROPERTY_CLIENT_SECRET) + .withConfigProperty(MarketingCloudConstants.PROPERTY_API_ENDPOINT) + .withConfigProperty(MarketingCloudConstants.PROPERTY_AUTH_API_ENDPOINT) + .withConfigProperty(MarketingCloudConstants.PROPERTY_SOAP_API_ENDPOINT) + .withStacktrace(e.getStackTrace()); + } + } + + private void validateQueryMode(FailureCollector collector) { + //according to query mode check if either object name / object list exists or not + if (containsMacro(MarketingCloudConstants.PROPERTY_QUERY_MODE)) { + return; + } + + SourceQueryMode mode = getQueryMode(collector); + + if (mode == SourceQueryMode.MULTI_OBJECT) { + validateMultiObjectQueryMode(collector); + } else { + validateSingleObjectQueryMode(collector); + } + } + + private void validateMultiObjectQueryMode(FailureCollector collector) { + if (containsMacro(MarketingCloudConstants.PROPERTY_OBJECT_LIST) + || containsMacro(MarketingCloudConstants.PROPERTY_DATA_EXTENSION_KEY_LIST) + || containsMacro(MarketingCloudConstants.PROPERTY_TABLE_NAME_FIELD)) { + return; + } + + List objects = getObjectList(collector); + collector.getOrThrowException(); + + if (objects.isEmpty()) { + collector.addFailure("At least 1 Object must be specified.", null) + .withConfigProperty(MarketingCloudConstants.PROPERTY_OBJECT_LIST); + } + + if (objects.contains(SourceObject.DATA_EXTENSION)) { + List dataExtensionKeyList = Util.splitToList(getDataExtensionKeys(), ','); + if (dataExtensionKeyList.isEmpty()) { + collector.addFailure("At least 1 Data Extension Key must be specified.", null) + .withConfigProperty(MarketingCloudConstants.PROPERTY_DATA_EXTENSION_KEY_LIST); + } + } + + if (Util.isNullOrEmpty(tableNameField)) { + collector.addFailure("Table name field must be specified.", null) + .withConfigProperty(MarketingCloudConstants.PROPERTY_TABLE_NAME_FIELD); + } + } + + private void validateSingleObjectQueryMode(FailureCollector collector) { + if (containsMacro(MarketingCloudConstants.PROPERTY_OBJECT_NAME) + || containsMacro(MarketingCloudConstants.PROPERTY_DATA_EXTENSION_KEY)) { + return; + } + + SourceObject object = getObject(collector); + + if (object == SourceObject.DATA_EXTENSION && Util.isNullOrEmpty(dataExtensionKey)) { + collector.addFailure("Data Extension Key must be specified.", null) + .withConfigProperty(MarketingCloudConstants.PROPERTY_DATA_EXTENSION_KEY); + } + } + + private void validateFilter(FailureCollector collector) { + if (containsMacro(MarketingCloudConstants.PROPERTY_FILTER) || Util.isNullOrEmpty(filter)) { + return; + } + try { + MarketingCloudClient.validateFilter(filter); + } catch (ETSdkException e) { + collector.addFailure("Filter string is not valid.", + "Check syntax to confirm.") + .withConfigProperty(MarketingCloudConstants.PROPERTY_FILTER) + .withStacktrace(e.getStackTrace()); + } + } + + /** + * Returns true if Salesforce can be connected to. + */ + public boolean shouldConnect() { + return !containsMacro(MarketingCloudConstants.PROPERTY_CLIENT_ID) && + !containsMacro(MarketingCloudConstants.PROPERTY_CLIENT_SECRET) && + !containsMacro(MarketingCloudConstants.PROPERTY_API_ENDPOINT) && + !containsMacro(MarketingCloudConstants.PROPERTY_AUTH_API_ENDPOINT) && + !containsMacro(MarketingCloudConstants.PROPERTY_SOAP_API_ENDPOINT); + } +} diff --git a/src/main/java/io/cdap/plugin/sfmc/source/util/MarketingCloudColumn.java b/src/main/java/io/cdap/plugin/sfmc/source/util/MarketingCloudColumn.java new file mode 100644 index 0000000..627c29b --- /dev/null +++ b/src/main/java/io/cdap/plugin/sfmc/source/util/MarketingCloudColumn.java @@ -0,0 +1,63 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 io.cdap.plugin.sfmc.source.util; + +import com.google.common.base.Strings; + +/** + * Information about a MarketingCloud table column. + */ +public class MarketingCloudColumn { + private String fieldName; + private String typeName; + private String mandatory; + + public MarketingCloudColumn() { + } + + /** + * Constructor for MarketingCloudColumn. + * + * @param fieldName The column name + * @param typeName The data type name + */ + public MarketingCloudColumn(String fieldName, String typeName) { + this.fieldName = fieldName; + this.typeName = typeName; + this.mandatory = "false"; + } + + public String getFieldName() { + return fieldName; + } + + /** + * Replaces all space characters in column name with double underscores + * @return field name with all characters replaced. + */ + public String getFormattedFieldName() { + return fieldName.replaceAll(" ", "__"); + } + + public String getTypeName() { + return typeName; + } + + public boolean isMandatory() { + return !Strings.isNullOrEmpty(mandatory) && "true".equalsIgnoreCase(mandatory); + } +} diff --git a/src/main/java/io/cdap/plugin/sfmc/source/util/MarketingCloudConstants.java b/src/main/java/io/cdap/plugin/sfmc/source/util/MarketingCloudConstants.java new file mode 100644 index 0000000..3b5f953 --- /dev/null +++ b/src/main/java/io/cdap/plugin/sfmc/source/util/MarketingCloudConstants.java @@ -0,0 +1,104 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 io.cdap.plugin.sfmc.source.util; + +/** + * Salesforce constants. + */ +public interface MarketingCloudConstants { + + /** + * Salesforce plugin name. + */ + String PLUGIN_NAME = "MarketingCloud"; + + /** + * Configuration property name used to specify the query mode. + */ + String PROPERTY_QUERY_MODE = "queryMode"; + + /** + * Configuration property name used to specify the object name. + */ + String PROPERTY_OBJECT_NAME = "objectName"; + + /** + * Configuration property name used to specify data extension key. + */ + String PROPERTY_DATA_EXTENSION_KEY = "dataExtensionKey"; + + /** + * Configuration property name used to specify the list of objects. + */ + String PROPERTY_OBJECT_LIST = "objectList"; + + /** + * Configuration property name used to specify the comma-separated list of data extension keys. + */ + String PROPERTY_DATA_EXTENSION_KEY_LIST = "dataExtensionKeyList"; + + /** + * Configuration property name used to specify the table name field. + */ + String PROPERTY_TABLE_NAME_FIELD = "tableNameField"; + + /** + * Configuration property name used to specify client id. + */ + String PROPERTY_CLIENT_ID = "clientId"; + + /** + * Configuration property name used to specify client secret. + */ + String PROPERTY_CLIENT_SECRET = "clientSecret"; + + /** + * Configuration property name used to specify REST API endpoint. + */ + String PROPERTY_API_ENDPOINT = "restEndpoint"; + + /** + * Configuration property name used to specify Auth API endpoint. + */ + String PROPERTY_AUTH_API_ENDPOINT = "authEndpoint"; + + /** + * Configuration property name used to specify SOAP endpoint. + */ + String PROPERTY_SOAP_API_ENDPOINT = "soapEndpoint"; + + /** + * Configuration property name used to specify Fail on Error flag. + */ + String PROPERTY_FAIL_ON_ERROR = "failOnError"; + + /** + * Table prefix to be used in case of Multi-Object support. + */ + String TABLE_PREFIX = "multisink."; + + /** + * Table name prefix to be used for data extensions. + */ + String DATA_EXTENSION_PREFIX = "dataextension_"; + + /** + * Filter to be used when retrieving data from Salesforce Marketing Cloud. + */ + String PROPERTY_FILTER = "filter"; + +} diff --git a/src/main/java/io/cdap/plugin/sfmc/source/util/MarketingCloudObjectInfo.java b/src/main/java/io/cdap/plugin/sfmc/source/util/MarketingCloudObjectInfo.java new file mode 100644 index 0000000..20d5846 --- /dev/null +++ b/src/main/java/io/cdap/plugin/sfmc/source/util/MarketingCloudObjectInfo.java @@ -0,0 +1,102 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 io.cdap.plugin.sfmc.source.util; + +import io.cdap.cdap.api.data.schema.Schema; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Information about a Salesforce table. + */ +public class MarketingCloudObjectInfo { + private final SourceObject object; + private final String dataExtensionKey; + private final Schema schema; + private final Map mapSchemaFieldToSFMCField; + + + /** + * Constructor for MarketingCloudObjectInfo for non-dataextension object. + * @param object The Salesforce Marketing Cloud object + * @param columns The list of columns + */ + public MarketingCloudObjectInfo(SourceObject object, List columns) { + this(object, null, columns); + } + + /** + * Constructor for MarketingCloudObjectInfo for dataextension object. + * + * @param object The Salesforce Marketing Cloud object as DataExtension + * @param dataExtensionKey The data extension key + * @param columns The list of columns + */ + public MarketingCloudObjectInfo(SourceObject object, String dataExtensionKey, List columns + ) { + this.object = object; + this.dataExtensionKey = dataExtensionKey; + SchemaBuilder schemaBuilder = new SchemaBuilder(); + this.schema = schemaBuilder.constructSchema(getFormattedTableName(), columns); + this.mapSchemaFieldToSFMCField = columns.stream() + .collect(Collectors.toMap(MarketingCloudColumn::getFormattedFieldName, MarketingCloudColumn::getFieldName)); + + } + + public SourceObject getObject() { + return object; + } + + /** + * Returns the table name for the object. + * + * @return In case of Data Extension, it returns name in `dataextension-[data extension key]` format + * Otherwise, it returns object name + */ + public String getTableName() { + if (getObject() == SourceObject.DATA_EXTENSION) { + return String.format("%s%s", MarketingCloudConstants.DATA_EXTENSION_PREFIX, dataExtensionKey); + } else { + return getObject().getTableName(); + } + } + + /** + * Replaces all hyphen (-) characters in column name with single underscore + * @return table name with all characters replaced. + */ + public String getFormattedTableName() { + return getTableName().replaceAll("-", "_"); + } + + public Schema getSchema() { + return schema; + } + + + + public String lookupFieldsMap(String schemaFieldName) { + if (this.mapSchemaFieldToSFMCField == null) { + return schemaFieldName; + } + + String sfmcFieldName = this.mapSchemaFieldToSFMCField.get(schemaFieldName); + return sfmcFieldName == null ? schemaFieldName : sfmcFieldName; + } +} diff --git a/src/main/java/io/cdap/plugin/sfmc/source/util/SchemaBuilder.java b/src/main/java/io/cdap/plugin/sfmc/source/util/SchemaBuilder.java new file mode 100644 index 0000000..cd68890 --- /dev/null +++ b/src/main/java/io/cdap/plugin/sfmc/source/util/SchemaBuilder.java @@ -0,0 +1,87 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 io.cdap.plugin.sfmc.source.util; + +import com.google.common.base.Strings; +import io.cdap.cdap.api.data.schema.Schema; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Utility class to build schema. + */ +public class SchemaBuilder { + /** + * Constructs Schema object using input parameters. + * + * @param tableName The table name to be used in Schema object + * @param columns The list of SalesforceColumn objects that will be added as Schema.Field + * @return The instance of Schema object + */ + public Schema constructSchema(String tableName, List columns) { + SchemaBuilder schemaBuilder = new SchemaBuilder(); + List fields = schemaBuilder.constructSchemaFields(columns); + + return Schema.recordOf(tableName, fields); + } + + private List constructSchemaFields(List columns) { + return columns.stream() + .map(o -> transformToField(o)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + private Schema.Field transformToField(MarketingCloudColumn column) { + String name = column.getFormattedFieldName(); + if (Strings.isNullOrEmpty(name)) { + return null; + } + + Schema schema = createSchema(column); + if (schema == null) { + return null; + } + + return Schema.Type.NULL == schema.getType() + ? Schema.Field.of(name, schema) + : Schema.Field.of(name, Schema.nullableOf(schema)); + } + + private Schema createSchema(MarketingCloudColumn column) { + switch (column.getTypeName().toLowerCase()) { + case "decimal": + case "double": + return Schema.of(Schema.Type.DOUBLE); + case "number": + case "integer": + return Schema.of(Schema.Type.INT); + case "boolean": + return Schema.of(Schema.Type.BOOLEAN); + case "date": + return Schema.of(Schema.LogicalType.TIMESTAMP_MICROS); + case "phone": + case "email_address": + case "locale": + case "string": + default: + return Schema.of(Schema.Type.STRING); + } + } +} diff --git a/src/main/java/io/cdap/plugin/sfmc/source/util/SourceObject.java b/src/main/java/io/cdap/plugin/sfmc/source/util/SourceObject.java new file mode 100644 index 0000000..cf07501 --- /dev/null +++ b/src/main/java/io/cdap/plugin/sfmc/source/util/SourceObject.java @@ -0,0 +1,138 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 io.cdap.plugin.sfmc.source.util; + +import com.custom.fuelsdk.ETNotSentEvent; +import com.exacttarget.fuelsdk.ETBounceEvent; +import com.exacttarget.fuelsdk.ETDataExtensionRow; +import com.exacttarget.fuelsdk.ETEmail; +import com.exacttarget.fuelsdk.ETList; +import com.exacttarget.fuelsdk.ETOpenEvent; +import com.exacttarget.fuelsdk.ETSentEvent; +import com.exacttarget.fuelsdk.ETSoapObject; +import com.exacttarget.fuelsdk.ETUnsubEvent; + +import java.util.Arrays; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Indicates the object for which data to be fetched from MarketingCloud. + */ +public enum SourceObject { + + /** + * This indicates data to be fetched from Data Extension. + */ + DATA_EXTENSION("Data Extension", "dataextension", ETDataExtensionRow.class), + + /** + * This indicates data to be fetched from Campaign. + */ +// CAMPAIGN("Campaign", "campaign", ETCampaign.class), + + /** + * This indicates data to be fetched from Email. + */ + EMAIL("Email", "email", ETEmail.class), + + /** + * This indicates data to be fetched from Mailing List. + */ + MAILING_LIST("Mailing List", "mailinglist", ETList.class), + + /** + * This indicates data to be fetched from Tracking Bounce Events. + */ + TRACKING_BOUNCE_EVENT("Bounce Event", "bounce", ETBounceEvent.class), + + /** + * This indicates data to be fetched from Tracking Open Events. + */ + TRACKING_OPEN_EVENT("Open Event", "open", ETOpenEvent.class), + + /** + * This indicates data to be fetched from Tracking Click Events. + */ + //TRACKING_CLICK_EVENT("Click Event", "click", ETClickEvent.class), + + /** + * This indicates data to be fetched from Tracking UnSub Events. + */ + TRACKING_UNSUB_EVENT("Unsub Event", "unsub", ETUnsubEvent.class), + + /** + * This indicates data to be fetched from Tracking Sent Events. + */ + TRACKING_SENT_EVENT("Sent Event", "sent", ETSentEvent.class), + + /** + * This indicates data to be fetched from Tracking Notsent Events. + */ + TRACKING_NOTSENT_EVENT("Notsent Event", "notsent", ETNotSentEvent.class); + + private final String value; + private final String tableName; + private final Class classRef; + private String filter = ""; + + + SourceObject(String value, String tableName, Class classRef) { + this.value = value; + this.tableName = tableName; + this.classRef = classRef; + } + + /** + * Converts object string value into {@link SourceObject} enum. + * + * @param stringValue object string value + * @return source object in optional container + */ + public static Optional fromValue(String stringValue) { + return Stream.of(values()) + .filter(keyType -> keyType.value.equalsIgnoreCase(stringValue)) + .findAny(); + } + + public static String getSupportedObjects() { + return Arrays.stream(SourceObject.values()).map(SourceObject::getValue) + .collect(Collectors.joining(", ")); + } + + public String getValue() { + return value; + } + + public String getTableName() { + return tableName; + } + + public Class getClassRef() { + return classRef; + } + + public String getFilter() { + return filter; + } + + public void setFilter(String filter) { + this.filter = filter; + } + +} diff --git a/src/main/java/io/cdap/plugin/sfmc/source/util/SourceQueryMode.java b/src/main/java/io/cdap/plugin/sfmc/source/util/SourceQueryMode.java new file mode 100644 index 0000000..f3f228a --- /dev/null +++ b/src/main/java/io/cdap/plugin/sfmc/source/util/SourceQueryMode.java @@ -0,0 +1,65 @@ +/* + * Copyright © 2020 Cask Data, Inc. + * + * Licensed 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 io.cdap.plugin.sfmc.source.util; + +import java.util.Arrays; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Indicates query mode which will be used when fetching MarketingCloud data. + */ +public enum SourceQueryMode { + + /** + * Mode will be used to fetch data for multiple objects. + */ + MULTI_OBJECT("Multi Object"), + + /** + * Mode will be used to fetch data for single object. + */ + SINGLE_OBJECT("Single Object"); + + private final String value; + + SourceQueryMode(String value) { + this.value = value; + } + + /** + * Converts mode string value into {@link SourceQueryMode} enum. + * + * @param stringValue mode string value + * @return source query mode in optional container + */ + public static Optional fromValue(String stringValue) { + return Stream.of(values()) + .filter(keyType -> keyType.value.equalsIgnoreCase(stringValue)) + .findAny(); + } + + public static String getSupportedModes() { + return Arrays.stream(SourceQueryMode.values()).map(SourceQueryMode::getValue) + .collect(Collectors.joining(", ")); + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/io/cdap/plugin/sfmc/source/util/Util.java b/src/main/java/io/cdap/plugin/sfmc/source/util/Util.java new file mode 100644 index 0000000..b01af08 --- /dev/null +++ b/src/main/java/io/cdap/plugin/sfmc/source/util/Util.java @@ -0,0 +1,53 @@ +/* + * Copyright © 2019 Cask Data, Inc. + * + * Licensed 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 io.cdap.plugin.sfmc.source.util; + +import com.google.common.base.Splitter; +import com.google.common.base.Strings; + +import java.util.Collections; +import java.util.List; + +/** + * Utility class. + */ +public class Util { + /** + * Utility function to check if incoming string is empty or not. + * + * @param string The value to be checked for emptyness + * @return true if string is empty otherwise false + */ + public static boolean isNullOrEmpty(String string) { + return Strings.isNullOrEmpty(Strings.nullToEmpty(string).trim()); + } + + /** + * Utility function to split incoming string using a delimiter. + * + * @param value The value to be split + * @param delimiter The delimiter to be used for splitting + * @return The list of strings + */ + public static List splitToList(String value, char delimiter) { + if (isNullOrEmpty(value)) { + return Collections.emptyList(); + } + + return Splitter.on(delimiter).trimResults().splitToList(value); + } +} diff --git a/src/test/java/io/cdap/plugin/sfmc/source/SalesforceSourceConfigHelper.java b/src/test/java/io/cdap/plugin/sfmc/source/SalesforceSourceConfigHelper.java new file mode 100644 index 0000000..101ac51 --- /dev/null +++ b/src/test/java/io/cdap/plugin/sfmc/source/SalesforceSourceConfigHelper.java @@ -0,0 +1,124 @@ +/* + * Copyright © 2019 Cask Data, Inc. + * + * Licensed 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 io.cdap.plugin.sfmc.source; + +/** + * Utility class that provides handy methods to construct Salesforce Source Config for testing + */ +public class SalesforceSourceConfigHelper { + + public static final String TEST_REF_NAME = "TestRefName"; + public static final String TEST_CLIENT_ID = "test-client-id"; + public static final String TEST_CLIENT_SECRET = "test-client-secret"; + public static final String TEST_REST_ENDPOINT = "TestRestEndpoint"; + public static final String TEST_AUTH_ENDPOINT = "TestAuthEndpoint"; + public static final String TEST_SOAP_ENDPOINT = "TestSoapEndpoint"; + public static final String TEST_OBJECT_NAME = "Data Extension"; + public static final String TEST_OBJECT_LIST = "Data Extension"; + private static final String TEST_FILTER = ""; + + public static ConfigBuilder newConfigBuilder() { + return new ConfigBuilder(); + } + + public static class ConfigBuilder { + private String referenceName = TEST_REF_NAME; + private String clientId = TEST_CLIENT_ID; + private String clientSecret = TEST_CLIENT_SECRET; + private String restEndpoint = TEST_REST_ENDPOINT; + private String authEndpoint = TEST_AUTH_ENDPOINT; + private String soapEndpoint = TEST_SOAP_ENDPOINT; + private String queryMode; + private String objectName = TEST_OBJECT_NAME; + private String filter = TEST_FILTER; + private String dataExtensionKey; + private String objectList = TEST_OBJECT_LIST; + private String dataExtensionKeys; + private String tableNameField = "tablename"; + + public ConfigBuilder setReferenceName(String referenceName) { + this.referenceName = referenceName; + return this; + } + + public ConfigBuilder setQueryMode(String queryMode) { + this.queryMode = queryMode; + return this; + } + + public ConfigBuilder setObjectName(String objectName) { + this.objectName = objectName; + return this; + } + + public ConfigBuilder setFilter(String filter) { + this.filter = filter; + return this; + } + + public ConfigBuilder setDataExtensionKey(String dataExtensionKey) { + this.dataExtensionKey = dataExtensionKey; + return this; + } + + public ConfigBuilder setObjectList(String objectList) { + this.objectList = objectList; + return this; + } + + public ConfigBuilder setDataExtensionKeys(String dataExtensionKeys) { + this.dataExtensionKeys = dataExtensionKeys; + return this; + } + + public ConfigBuilder setTableNameField(String tableNameField) { + this.tableNameField = tableNameField; + return this; + } + + public ConfigBuilder setClientId(String clientId) { + this.clientId = clientId; + return this; + } + + public ConfigBuilder setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + public ConfigBuilder setRestEndpoint(String restEndpoint) { + this.restEndpoint = restEndpoint; + return this; + } + + public ConfigBuilder setAuthEndpoint(String authEndpoint) { + this.authEndpoint = authEndpoint; + return this; + } + + public ConfigBuilder setSoapEndpoint(String soapEndpoint) { + this.soapEndpoint = soapEndpoint; + return this; + } + + public MarketingCloudSourceConfig build() { + return new MarketingCloudSourceConfig(referenceName, queryMode, objectName, dataExtensionKey, objectList, + dataExtensionKeys, tableNameField, filter, clientId, clientSecret, + restEndpoint, authEndpoint, soapEndpoint, false); + } + } +} diff --git a/src/test/java/io/cdap/plugin/sfmc/source/SalesforceSourceConfigTest.java b/src/test/java/io/cdap/plugin/sfmc/source/SalesforceSourceConfigTest.java new file mode 100644 index 0000000..f4d732f --- /dev/null +++ b/src/test/java/io/cdap/plugin/sfmc/source/SalesforceSourceConfigTest.java @@ -0,0 +1,274 @@ +/* + * Copyright © 2019 Cask Data, Inc. + * + * Licensed 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 io.cdap.plugin.sfmc.source; + +import io.cdap.cdap.etl.api.FailureCollector; +import io.cdap.cdap.etl.api.validation.CauseAttributes; +import io.cdap.cdap.etl.api.validation.ValidationException; +import io.cdap.cdap.etl.mock.validation.MockFailureCollector; +import io.cdap.plugin.sfmc.source.util.SourceQueryMode; +import org.junit.Assert; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.Mockito; + +import static io.cdap.plugin.sfmc.source.SalesforceSourceConfigHelper.TEST_AUTH_ENDPOINT; +import static io.cdap.plugin.sfmc.source.SalesforceSourceConfigHelper.TEST_CLIENT_ID; +import static io.cdap.plugin.sfmc.source.SalesforceSourceConfigHelper.TEST_CLIENT_SECRET; +import static io.cdap.plugin.sfmc.source.SalesforceSourceConfigHelper.TEST_REST_ENDPOINT; +import static io.cdap.plugin.sfmc.source.SalesforceSourceConfigHelper.TEST_SOAP_ENDPOINT; +import static io.cdap.plugin.sfmc.source.util.MarketingCloudConstants.PROPERTY_AUTH_API_ENDPOINT; +import static io.cdap.plugin.sfmc.source.util.MarketingCloudConstants.PROPERTY_CLIENT_ID; +import static io.cdap.plugin.sfmc.source.util.MarketingCloudConstants.PROPERTY_CLIENT_SECRET; +import static io.cdap.plugin.sfmc.source.util.MarketingCloudConstants.PROPERTY_DATA_EXTENSION_KEY; +import static io.cdap.plugin.sfmc.source.util.MarketingCloudConstants.PROPERTY_DATA_EXTENSION_KEY_LIST; +import static io.cdap.plugin.sfmc.source.util.MarketingCloudConstants.PROPERTY_QUERY_MODE; +import static io.cdap.plugin.sfmc.source.util.MarketingCloudConstants.PROPERTY_SOAP_API_ENDPOINT; +import static io.cdap.plugin.sfmc.source.util.MarketingCloudConstants.PROPERTY_TABLE_NAME_FIELD; + +/** + * Tests for {@link MarketingCloudSourceConfig}. + */ +public class SalesforceSourceConfigTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Test + public void testQueryModeSingleObject() { + SourceQueryMode queryMode = SourceQueryMode.SINGLE_OBJECT; + MarketingCloudSourceConfig config = SalesforceSourceConfigHelper.newConfigBuilder() + .setQueryMode("Single Object") + .build(); + + MockFailureCollector collector = new MockFailureCollector(); + Assert.assertEquals(queryMode, config.getQueryMode(collector)); + Assert.assertEquals(0, collector.getValidationFailures().size()); + } + + @Test + public void testQueryModeMultiObject() { + SourceQueryMode queryMode = SourceQueryMode.MULTI_OBJECT; + MarketingCloudSourceConfig config = SalesforceSourceConfigHelper.newConfigBuilder() + .setQueryMode("Multi Object") + .build(); + + MockFailureCollector collector = new MockFailureCollector(); + Assert.assertEquals(queryMode, config.getQueryMode(collector)); + Assert.assertEquals(0, collector.getValidationFailures().size()); + } + + @Test + public void testQueryModeInvalid() { + MarketingCloudSourceConfig config = SalesforceSourceConfigHelper.newConfigBuilder() + .setQueryMode(null) + .build(); + + try { + MockFailureCollector collector = new MockFailureCollector(); + config.getQueryMode(collector); + } catch (ValidationException e) { + Assert.assertEquals(1, e.getFailures().size()); + Assert.assertEquals(PROPERTY_QUERY_MODE, e.getFailures().get(0).getCauses().get(0) + .getAttribute(CauseAttributes.STAGE_CONFIG)); + } + } + + @Test + public void testValidateClientIdNull() { + MockFailureCollector collector = new MockFailureCollector(); + MarketingCloudSourceConfig config = withSalesforceValidationMock(SalesforceSourceConfigHelper.newConfigBuilder() + .setQueryMode("Single Object") + .setClientId(null) + .setClientSecret(TEST_CLIENT_SECRET) + .setRestEndpoint(TEST_REST_ENDPOINT) + .setAuthEndpoint(TEST_AUTH_ENDPOINT) + .setSoapEndpoint(TEST_SOAP_ENDPOINT) + .build(), collector); + + try { + config.validate(collector); + collector.getOrThrowException(); + } catch (ValidationException e) { + Assert.assertEquals(1, e.getFailures().size()); + Assert.assertEquals(PROPERTY_CLIENT_ID, e.getFailures().get(0).getCauses().get(0) + .getAttribute(CauseAttributes.STAGE_CONFIG)); + } + } + + @Test + public void testValidateClientSecretNull() { + MockFailureCollector collector = new MockFailureCollector(); + MarketingCloudSourceConfig config = withSalesforceValidationMock(SalesforceSourceConfigHelper.newConfigBuilder() + .setQueryMode("Single Object") + .setClientId(TEST_CLIENT_ID) + .setClientSecret(null) + .setRestEndpoint(TEST_REST_ENDPOINT) + .setAuthEndpoint(TEST_AUTH_ENDPOINT) + .setSoapEndpoint(TEST_SOAP_ENDPOINT) + .build(), collector); + + try { + config.validate(collector); + collector.getOrThrowException(); + } catch (ValidationException e) { + Assert.assertEquals(1, e.getFailures().size()); + Assert.assertEquals(PROPERTY_CLIENT_SECRET, e.getFailures().get(0).getCauses().get(0) + .getAttribute(CauseAttributes.STAGE_CONFIG)); + } + } + + @Test + public void testValidateAuthEndpointNull() { + MockFailureCollector collector = new MockFailureCollector(); + MarketingCloudSourceConfig config = withSalesforceValidationMock(SalesforceSourceConfigHelper.newConfigBuilder() + .setQueryMode("Single Object") + .setClientId(TEST_CLIENT_ID) + .setClientSecret(TEST_CLIENT_SECRET) + .setRestEndpoint(TEST_REST_ENDPOINT) + .setAuthEndpoint(null) + .setSoapEndpoint(TEST_SOAP_ENDPOINT) + .build(), collector); + + try { + config.validate(collector); + collector.getOrThrowException(); + } catch (ValidationException e) { + Assert.assertEquals(1, e.getFailures().size()); + Assert.assertEquals(PROPERTY_AUTH_API_ENDPOINT, e.getFailures().get(0).getCauses().get(0) + .getAttribute(CauseAttributes.STAGE_CONFIG)); + } + } + + @Test + public void testValidateSoapEndpointNull() { + MockFailureCollector collector = new MockFailureCollector(); + MarketingCloudSourceConfig config = withSalesforceValidationMock(SalesforceSourceConfigHelper.newConfigBuilder() + .setQueryMode("Single Object") + .setClientId(TEST_CLIENT_ID) + .setClientSecret(TEST_CLIENT_SECRET) + .setRestEndpoint(TEST_REST_ENDPOINT) + .setAuthEndpoint(TEST_AUTH_ENDPOINT) + .setSoapEndpoint(null) + .build(), collector); + + try { + config.validate(collector); + collector.getOrThrowException(); + } catch (ValidationException e) { + Assert.assertEquals(1, e.getFailures().size()); + Assert.assertEquals(PROPERTY_SOAP_API_ENDPOINT, e.getFailures().get(0).getCauses().get(0) + .getAttribute(CauseAttributes.STAGE_CONFIG)); + } + } + + @Test + @Ignore + public void testValidCredentials() { + MockFailureCollector collector = new MockFailureCollector(); + MarketingCloudSourceConfig config = withSalesforceValidationMock(SalesforceSourceConfigHelper.newConfigBuilder() + .setQueryMode("Single Object") + .setClientId(TEST_CLIENT_ID) + .setClientSecret(TEST_CLIENT_SECRET) + .setRestEndpoint(TEST_REST_ENDPOINT) + .setAuthEndpoint(TEST_AUTH_ENDPOINT) + .setSoapEndpoint(TEST_SOAP_ENDPOINT) + .setDataExtensionKey("test-dataextension-key") + .build(), collector); + + config.validate(collector); + + Assert.assertEquals(0, collector.getValidationFailures().size()); + } + + @Test + public void testSingleObjectModeMissingDataExtensionKey() { + MockFailureCollector collector = new MockFailureCollector(); + MarketingCloudSourceConfig config = withSalesforceValidationMock(SalesforceSourceConfigHelper.newConfigBuilder() + .setQueryMode("Single Object") + .setRestEndpoint(TEST_REST_ENDPOINT) + .setAuthEndpoint(TEST_AUTH_ENDPOINT) + .setSoapEndpoint(TEST_SOAP_ENDPOINT) + .setDataExtensionKey(null) + .build(), collector); + + try { + config.validate(collector); + collector.getOrThrowException(); + } catch (ValidationException e) { + Assert.assertEquals(1, e.getFailures().size()); + Assert.assertEquals(PROPERTY_DATA_EXTENSION_KEY, e.getFailures().get(0).getCauses().get(0) + .getAttribute(CauseAttributes.STAGE_CONFIG)); + } + } + + @Test + public void testMultiObjectModeMissingDataExtensionKeys() { + MockFailureCollector collector = new MockFailureCollector(); + MarketingCloudSourceConfig config = withSalesforceValidationMock(SalesforceSourceConfigHelper.newConfigBuilder() + .setQueryMode("Multi Object") + .setClientId(TEST_CLIENT_ID) + .setClientSecret(TEST_CLIENT_SECRET) + .setRestEndpoint(TEST_REST_ENDPOINT) + .setAuthEndpoint(TEST_AUTH_ENDPOINT) + .setSoapEndpoint(TEST_SOAP_ENDPOINT) + .setDataExtensionKeys(null) + .build(), collector); + + try { + config.validate(collector); + collector.getOrThrowException(); + } catch (ValidationException e) { + Assert.assertEquals(1, e.getFailures().size()); + Assert.assertEquals(PROPERTY_DATA_EXTENSION_KEY_LIST, e.getFailures().get(0).getCauses().get(0) + .getAttribute(CauseAttributes.STAGE_CONFIG)); + } + } + + @Test + public void testMultiObjectModeMissingTableNameField() { + MockFailureCollector collector = new MockFailureCollector(); + MarketingCloudSourceConfig config = withSalesforceValidationMock(SalesforceSourceConfigHelper.newConfigBuilder() + .setQueryMode("Multi Object") + .setClientId(TEST_CLIENT_ID) + .setClientSecret(TEST_CLIENT_SECRET) + .setRestEndpoint(TEST_REST_ENDPOINT) + .setAuthEndpoint(TEST_AUTH_ENDPOINT) + .setSoapEndpoint(TEST_SOAP_ENDPOINT) + .setDataExtensionKeys("Test-DataExtension-Key1") + .setTableNameField(null) + .build(), collector); + + try { + config.validate(collector); + collector.getOrThrowException(); + } catch (ValidationException e) { + Assert.assertEquals(1, e.getFailures().size()); + Assert.assertEquals(PROPERTY_TABLE_NAME_FIELD, e.getFailures().get(0).getCauses().get(0) + .getAttribute(CauseAttributes.STAGE_CONFIG)); + } + } + + private MarketingCloudSourceConfig withSalesforceValidationMock(MarketingCloudSourceConfig config, + FailureCollector collector) { + MarketingCloudSourceConfig spy = Mockito.spy(config); + Mockito.doNothing().when(spy).validateSalesforceConnection(collector); + return spy; + } +} diff --git a/widgets/MarketingCloud-batchsource.json b/widgets/MarketingCloud-batchsource.json new file mode 100644 index 0000000..358e53f --- /dev/null +++ b/widgets/MarketingCloud-batchsource.json @@ -0,0 +1,284 @@ +{ + "metadata": { + "spec-version": "1.0" + }, + "display-name": "Salesforce Marketing", + "configuration-groups": [ + { + "label": "Basic", + "properties": [ + { + "widget-type": "textbox", + "label": "Reference Name", + "name": "referenceName", + "widget-attributes": { + "placeholder": "Name used to identify this source for lineage" + } + }, + { + "widget-type": "select", + "label": "Data Retrieval Mode", + "name": "queryMode", + "widget-attributes": { + "values": [ + "Multi Object", + "Single Object" + ], + "default": "Single Object" + } + } + ] + }, + { + "label": "Single Object Retrieval", + "properties": [ + { + "widget-type": "select", + "label": "Object", + "name": "objectName", + "widget-attributes": { + "values": [ + "Bounce Event", + "Data Extension", + "Email", + "Mailing List", + "Notsent Event", + "Open Event", + "Sent Event", + "Unsub Event" + ], + "default": "Data Extension" + }, + "plugin-function": { + "label": "Get Schema", + "widget": "outputSchema", + "output-property": "schema", + "omit-properties": [ + { + "name": "schema" + } + ] + } + }, + { + "widget-type": "textbox", + "label": "Data Extension External Key", + "name": "dataExtensionKey", + "widget-attributes": { + "placeholder": "External key of the Data Extension from which data to be read." + } + } + ] + }, + { + "label": "Multi Object Retrieval", + "properties": [ + { + "widget-type": "multi-select", + "label": "Object List", + "name": "objectList", + "widget-attributes": { + "options": [ + { + "id": "Bounce Event", + "label": "Bounce Event" + }, + { + "id": "Data Extension", + "label": "Data Extension" + }, + { + "id": "Email", + "label": "Email" + }, + { + "id": "Mailing List", + "label": "Mailing List" + }, + { + "id": "Notsent Event", + "label": "Notsent Event" + }, + { + "id": "Open Event", + "label": "Open Event" + }, + { + "id": "Sent Event", + "label": "Sent Event" + }, + { + "id": "Unsub Event", + "label": "Unsub Event" + } + ], + "delimiter": ",", + "default": "Data Extension" + } + }, + { + "widget-type": "csv", + "label": "Data Extension External Keys", + "name": "dataExtensionKeyList", + "widget-attributes": { + "placeholder": "External keys of the Data Extensions from which data to be read." + } + }, + { + "widget-type": "textbox", + "label": "Table Name Field", + "name": "tableNameField", + "widget-attributes": { + "placeholder": "The name of the field that holds the table name.", + "default": "tablename" + } + } + ] + }, + { + "label": "Filter", + "properties": [ + { + "widget-type": "textbox", + "label": "filter", + "name": "filter", + "widget-attributes": { + "placeholder": "data filter criteria" + } + } + ] + }, + { + "label": "Authentication", + "properties": [ + { + "widget-type": "textbox", + "label": "Client ID", + "name": "clientId", + "widget-attributes" : { + "placeholder": "Salesforce Marketing Cloud Client ID" + } + }, + { + "widget-type": "password", + "label": "Client Secret", + "name": "clientSecret", + "widget-attributes" : { + "placeholder": "Salesforce Marketing Cloud Client Secret" + } + }, + { + "widget-type": "textbox", + "label": "Authentication Base URI", + "name": "authEndpoint", + "widget-attributes" : { + "placeholder": "Salesforce Auth API Endpoint e.g. https://instance.auth.marketingcloudapis.com" + } + }, + { + "widget-type": "textbox", + "label": "SOAP API Endpoint", + "name": "soapEndpoint", + "widget-attributes" : { + "placeholder": "Salesforce SOAP API Endpoint e.g. https://instance.Salesforce.soap.marketingcloudapis.com/Service.asmx" + } + }, + { + "widget-type": "textbox", + "label": "REST API Base URI", + "name": "restEndpoint", + "widget-attributes" : { + "placeholder": "Salesforce REST API Endpoint e.g. https://instance.rest.marketingcloudapis.com" + } + } + ] + }, + { + "label": "Advanced", + "properties": [ + { + "widget-type": "radio-group", + "name" : "failOnError", + "label" : "Error handling", + "widget-attributes": { + "layout": "inline", + "default": "false", + "options": [ + { + "id": "true", + "label": "Stop on error" + }, + { + "id": "false", + "label": "Skip on error" + } + ] + } + } + ] + } + ], + "outputs": [ + { + "name": "schema", + "widget-type": "schema" + } + ], + "jump-config": { + "datasets": [ + { + "ref-property-name": "referenceName" + } + ] + }, + "filters": [ + { + "name": "showSingleObject", + "condition": { + "expression": "queryMode == 'Single Object'" + }, + "show": [ + { + "type": "property", + "name": "objectName" + }, + { + "type": "property", + "name": "dataExtensionKey" + } + ] + }, + { + "name": "showMultiObject", + "condition": { + "expression": "queryMode == 'Multi Object'" + }, + "show": [ + { + "type": "property", + "name": "objectList" + }, + { + "type": "property", + "name": "dataExtensionKeyList" + }, + { + "type": "property", + "name": "tableNameField" + } + ] + }, + { + "name": "showSingleObjectDataExtKey", + "condition": { + "expression": "objectName == 'Data Extension'" + }, + "show": [ + { + "type": "property", + "name": "dataExtensionKey" + } + ] + } + ] +}