gobblin.writer.http.SalesforceRestWriter.java Source code

Java tutorial

Introduction

Here is the source code for gobblin.writer.http.SalesforceRestWriter.java

Source

/*
 * 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;
    }
}