/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package gobblin.writer.http;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpHeaders;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.methods.RequestBuilder;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.util.EntityUtils;
import org.apache.oltu.oauth2.client.OAuthClient;
import org.apache.oltu.oauth2.client.URLConnectionClient;
import org.apache.oltu.oauth2.client.request.OAuthClientRequest;
import org.apache.oltu.oauth2.client.response.OAuthJSONAccessTokenResponse;
import org.apache.oltu.oauth2.common.OAuth;
import org.apache.oltu.oauth2.common.exception.OAuthProblemException;
import org.apache.oltu.oauth2.common.exception.OAuthSystemException;
import org.apache.oltu.oauth2.common.message.types.GrantType;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import gobblin.converter.http.RestEntry;
import gobblin.writer.exception.NonTransientException;
/**
* Writes to Salesforce via RESTful API, supporting INSERT_ONLY_NOT_EXIST, and UPSERT.
*
*/
public class SalesforceRestWriter extends RestJsonWriter {
public static enum Operation {
INSERT_ONLY_NOT_EXIST,
UPSERT
}
static final String DUPLICATE_VALUE_ERR_CODE = "DUPLICATE_VALUE";
private String accessToken;
private final URI oauthEndPoint;
private final String clientId;
private final String clientSecret;
private final String userId;
private final String password;
private final String securityToken;
private final Operation operation;
private final int batchSize;
private final Optional<String> batchResourcePath;
private Optional<JsonArray> batchRecords = Optional.absent();
private long numRecordsWritten = 0L;
public SalesforceRestWriter(SalesForceRestWriterBuilder builder) {
super(builder);
this.oauthEndPoint = builder.getSvcEndpoint().get(); //Set oauth end point
this.clientId = builder.getClientId();
this.clientSecret = builder.getClientSecret();
this.userId = builder.getUserId();
this.password = builder.getPassword();
this.securityToken = builder.getSecurityToken();
this.operation = builder.getOperation();
this.batchSize = builder.getBatchSize();
this.batchResourcePath = builder.getBatchResourcePath();
Preconditions.checkArgument(batchSize == 1 || batchResourcePath.isPresent(), "Batch resource path is missing");
if (batchSize > 1) {
getLog().info("Batch api will be used with batch size " + batchSize);
}
try {
onConnect(oauthEndPoint);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@VisibleForTesting
SalesforceRestWriter(SalesForceRestWriterBuilder builder, String accessToken) {
super(builder);
this.oauthEndPoint = builder.getSvcEndpoint().get(); //Set oauth end point
this.clientId = builder.getClientId();
this.clientSecret = builder.getClientSecret();
this.userId = builder.getUserId();
this.password = builder.getPassword();
this.securityToken = builder.getSecurityToken();
this.operation = builder.getOperation();
this.batchSize = builder.getBatchSize();
this.batchResourcePath = builder.getBatchResourcePath();
Preconditions.checkArgument(batchSize == 1 || batchResourcePath.isPresent(), "Batch resource path is missing");
this.accessToken = accessToken;
}
/**
* Retrieve access token, if needed, retrieve instance url, and set server host URL
* {@inheritDoc}
* @see gobblin.writer.http.HttpWriter#onConnect(org.apache.http.HttpHost)
*/
@Override
public void onConnect(URI serverHost) throws IOException {
if (!StringUtils.isEmpty(accessToken)) {
return; //No need to be called if accessToken is active.
}
try {
getLog().info("Getting Oauth2 access token.");
OAuthClientRequest request = OAuthClientRequest.tokenLocation(serverHost.toString())
.setGrantType(GrantType.PASSWORD)
.setClientId(clientId)
.setClientSecret(clientSecret)
.setUsername(userId)
.setPassword(password + securityToken).buildQueryMessage();
OAuthClient client = new OAuthClient(new URLConnectionClient());
OAuthJSONAccessTokenResponse response = client.accessToken(request, OAuth.HttpMethod.POST);
accessToken = response.getAccessToken();
setCurServerHost(new URI(response.getParam("instance_url")));
} catch (OAuthProblemException e) {
throw new NonTransientException("Error while authenticating with Oauth2", e);
} catch (OAuthSystemException e) {
throw new RuntimeException("Failed getting access token", e);
} catch (URISyntaxException e) {
throw new RuntimeException("Failed due to invalid instance url", e);
}
}
/**
* For single request, creates HttpUriRequest and decides post/patch operation based on input parameter.
*
* For batch request, add the record into JsonArray as a subrequest and only creates HttpUriRequest with POST method if it filled the batch size.
* {@inheritDoc}
* @see gobblin.writer.http.RestJsonWriter#onNewRecord(gobblin.converter.rest.RestEntry)
*/
@Override
public Optional<HttpUriRequest> onNewRecord(RestEntry<JsonObject> record) {
Preconditions.checkArgument(!StringUtils.isEmpty(accessToken), "Access token has not been acquired.");
Preconditions.checkNotNull(record, "Record should not be null");
RequestBuilder builder = null;
JsonObject payload = null;
if (batchSize > 1) {
if (!batchRecords.isPresent()) {
batchRecords = Optional.of(new JsonArray());
}
batchRecords.get().add(newSubrequest(record));
if (batchRecords.get().size() < batchSize) { //No need to send. Return absent.
return Optional.absent();
}
payload = newPayloadForBatch();
builder = RequestBuilder.post().setUri(combineUrl(getCurServerHost(), batchResourcePath));
} else {
switch (operation) {
case INSERT_ONLY_NOT_EXIST:
builder = RequestBuilder.post();
break;
case UPSERT:
builder = RequestBuilder.patch();
break;
default:
throw new IllegalArgumentException(operation + " is not supported.");
}
builder.setUri(combineUrl(getCurServerHost(), record.getResourcePath()));
payload = record.getRestEntryVal();
}
return Optional.of(newRequest(builder, payload));
}
/**
* Create batch subrequest. For more detail @link https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/requests_composite_batch.htm
*
* @param record
* @return
*/
private JsonObject newSubrequest(RestEntry<JsonObject> record) {
Preconditions.checkArgument(record.getResourcePath().isPresent(), "Resource path is not defined");
JsonObject subReq = new JsonObject();
subReq.addProperty("url", record.getResourcePath().get());
subReq.add("richInput", record.getRestEntryVal());
switch (operation) {
case INSERT_ONLY_NOT_EXIST:
subReq.addProperty("method", "POST");
break;
case UPSERT:
subReq.addProperty("method", "PATCH");
break;
default:
throw new IllegalArgumentException(operation + " is not supported.");
}
return subReq;
}
/**
* @return JsonObject contains batch records
*/
private JsonObject newPayloadForBatch() {
JsonObject payload = new JsonObject();
payload.add("batchRequests", batchRecords.get());
return payload;
}
private HttpUriRequest newRequest(RequestBuilder builder, JsonElement payload) {
try {
builder.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.getMimeType())
.addHeader(HttpHeaders.AUTHORIZATION, "OAuth " + accessToken)
.setEntity(new StringEntity(payload.toString(), ContentType.APPLICATION_JSON));
} catch (Exception e) {
throw new RuntimeException(e);
}
if (getLog().isDebugEnabled()) {
getLog().debug("Request builder: " + ToStringBuilder.reflectionToString(builder, ToStringStyle.SHORT_PREFIX_STYLE));
}
return builder.build();
}
@Override
public void flush() {
try {
if (isRetry()) {
//flushing failed and it should be retried.
super.writeImpl(null);
return;
}
if (batchRecords.isPresent() && batchRecords.get().size() > 0) {
getLog().info("Flusing remaining subrequest of batch. # of subrequests: " + batchRecords.get().size());
curRequest = Optional.of(newRequest(RequestBuilder.post().setUri(combineUrl(getCurServerHost(), batchResourcePath)),
newPayloadForBatch()));
super.writeImpl(null);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/**
* Make it fail (throw exception) if status code is greater or equal to 400 except,
* the status code is 400 and error code is duplicate value, regard it as success(do not throw exception).
*
* If status code is 401 or 403, re-acquire access token before make it fail -- retry will take care of rest.
*
* {@inheritDoc}
* @see gobblin.writer.http.HttpWriter#processResponse(org.apache.http.HttpResponse)
*/
@Override
public void processResponse(CloseableHttpResponse response) throws IOException, UnexpectedResponseException {
if (getLog().isDebugEnabled()) {
getLog().debug("Received response " + ToStringBuilder.reflectionToString(response, ToStringStyle.SHORT_PREFIX_STYLE));
}
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode == 401 || statusCode == 403) {
getLog().info("Reacquiring access token.");
accessToken = null;
onConnect(oauthEndPoint);
throw new RuntimeException("Access denied. Access token has been reacquired and retry may solve the problem. "
+ ToStringBuilder.reflectionToString(response, ToStringStyle.SHORT_PREFIX_STYLE));
}
if (batchSize > 1) {
processBatchRequestResponse(response);
numRecordsWritten += batchRecords.get().size();
batchRecords = Optional.absent();
} else {
processSingleRequestResponse(response);
numRecordsWritten++;
}
}
private void processSingleRequestResponse(CloseableHttpResponse response) throws IOException,
UnexpectedResponseException {
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode < 400) {
return;
}
String entityStr = EntityUtils.toString(response.getEntity());
if (statusCode == 400
&& Operation.INSERT_ONLY_NOT_EXIST.equals(operation)
&& entityStr != null) { //Ignore if it's duplicate entry error code
JsonArray jsonArray = new JsonParser().parse(entityStr).getAsJsonArray();
JsonObject jsonObject = jsonArray.get(0).getAsJsonObject();
if (isDuplicate(jsonObject, statusCode)) {
return;
}
}
throw new RuntimeException("Failed due to " + entityStr + " (Detail: "
+ ToStringBuilder.reflectionToString(response, ToStringStyle.SHORT_PREFIX_STYLE) + " )");
}
/**
* Check results from batch response, if any of the results is failure throw exception.
* @param response
* @throws IOException
* @throws UnexpectedResponseException
*/
private void processBatchRequestResponse(CloseableHttpResponse response) throws IOException,
UnexpectedResponseException {
String entityStr = EntityUtils.toString(response.getEntity());
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode >= 400) {
throw new RuntimeException("Failed due to " + entityStr + " (Detail: "
+ ToStringBuilder.reflectionToString(response, ToStringStyle.SHORT_PREFIX_STYLE) + " )");
}
JsonObject jsonBody = new JsonParser().parse(entityStr).getAsJsonObject();
if (!jsonBody.get("hasErrors").getAsBoolean()) {
return;
}
JsonArray results = jsonBody.get("results").getAsJsonArray();
for (JsonElement jsonElem : results) {
JsonObject json = jsonElem.getAsJsonObject();
int subStatusCode = json.get("statusCode").getAsInt();
if (subStatusCode < 400) {
continue;
} else if (subStatusCode == 400
&& Operation.INSERT_ONLY_NOT_EXIST.equals(operation)) {
JsonElement resultJsonElem = json.get("result");
Preconditions.checkNotNull(resultJsonElem, "Error response should contain result property");
JsonObject resultJsonObject = resultJsonElem.getAsJsonArray().get(0).getAsJsonObject();
if (isDuplicate(resultJsonObject, subStatusCode)) {
continue;
}
}
throw new RuntimeException("Failed due to " + jsonBody + " (Detail: "
+ ToStringBuilder.reflectionToString(response, ToStringStyle.SHORT_PREFIX_STYLE) + " )");
}
}
private boolean isDuplicate(JsonObject responseJsonObject, int statusCode) {
return statusCode == 400
&& Operation.INSERT_ONLY_NOT_EXIST.equals(operation)
&& DUPLICATE_VALUE_ERR_CODE.equals(responseJsonObject.get("errorCode").getAsString());
}
/**
* {@inheritDoc}
*/
@Override
public long recordsWritten() {
return this.numRecordsWritten;
}
}