All Downloads are FREE. Search and download functionalities are using the official Maven repository.

sap.ai.st.cm.plugins.ciintegration.odataclient.CMODataSolmanClient Maven / Gradle / Ivy

The newest version!
package sap.ai.st.cm.plugins.ciintegration.odataclient;

import static com.google.common.base.Preconditions.checkArgument;
import static java.lang.Boolean.parseBoolean;
import static java.lang.String.format;
import static org.apache.commons.lang3.StringUtils.isBlank;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.lang.reflect.Field;
import java.net.URI;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import javax.xml.namespace.QName;

import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.olingo.client.api.ODataClient;
import org.apache.olingo.client.api.communication.ODataClientErrorException;
import org.apache.olingo.client.api.communication.request.ODataPayloadManager;
import org.apache.olingo.client.api.communication.request.invoke.ODataInvokeRequest;
import org.apache.olingo.client.api.communication.request.retrieve.ODataEntityRequest;
import org.apache.olingo.client.api.communication.request.retrieve.ODataEntitySetIteratorRequest;
import org.apache.olingo.client.api.communication.request.streamed.ODataMediaEntityUpdateRequest;
import org.apache.olingo.client.api.communication.response.ODataInvokeResponse;
import org.apache.olingo.client.api.communication.response.ODataResponse;
import org.apache.olingo.client.api.communication.response.ODataRetrieveResponse;
import org.apache.olingo.client.api.domain.ClientEntity;
import org.apache.olingo.client.api.domain.ClientEntitySet;
import org.apache.olingo.client.api.domain.ClientEntitySetIterator;
import org.apache.olingo.client.api.domain.ClientProperty;
import org.apache.olingo.client.api.domain.ClientValue;
import org.apache.olingo.client.api.uri.URIBuilder;
import org.apache.olingo.client.core.ODataClientFactory;
import org.apache.olingo.client.core.serialization.AtomDeserializer;
import org.apache.olingo.commons.api.format.ContentType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import com.google.common.net.UrlEscapers;
import com.sap.cmclient.Transport;
import com.sap.cmclient.VersionHelper;

/**
 * OData client for handling connections to SAP solution manager.
 */
public class CMODataSolmanClient implements AutoCloseable {

    private final static Logger logger = LoggerFactory.getLogger(CMODataSolmanClient.class);

    private boolean isClosed = false;

    private final static ContentType contentType = ContentType.APPLICATION_ATOM_XML;

    private String serviceUrl; //REVISIT: uri instead of string?
    private final ODataClient client;

    private final static String REQ_PARAM_CHANGE_ID = "ChangeID",
                                REQ_PARAM_DEVELOPMENT_SYSTM_ID = "DevelopmentSystemID",
                                REQ_PARAM_OWNER = "Owner",
                                REQ_PARAM_DESCRIPTION = "Description";

    public CMODataSolmanClient(String serviceUrl, String serviceUser, String servicePassword) {

        checkArgument(!isBlank(serviceUrl), "Service url must not be blank.");
        checkArgument(!isBlank(serviceUser), "Service user must not be blank.");
        checkArgument(!isBlank(servicePassword), "Service password must not be blank.");
        this.serviceUrl = serviceUrl;
        this.client = ODataClientFactory.getClient();
        this.client.getConfiguration().setHttpClientFactory(
                new CMOdataHTTPFactory(serviceUser, servicePassword));
        logger.debug(format("CMClient instanciated for host '%s' with service user '%s'.", serviceUrl, serviceUser));
    }

    /**
     * Retrieves a change
     * @param changeID The identifier of the change which should be retrieved.
     * @return The change represented by changeID.
     * @throws ODataClientErrorException with status code 404 in case no matching change could be resolved.
     * @throws IllegalStateException in case the client has been closed.
     */
    public CMODataChange getChange(String changeID) {

        logger.trace(format("Entering 'getChange'. changeID: '%s'.", changeID));
        checkClosed();

        URI entityUri = this.client.newURIBuilder(serviceUrl).appendEntitySetSegment("Changes").appendKeySegment(changeID).build();

        ODataEntityRequest request = this.client.getRetrieveRequestFactory().getEntityRequest(entityUri);

        request.setAccept(contentType.toContentTypeString());

        ODataRetrieveResponse response = null;
        try {
            response = request.execute();
            ClientEntity body = response.getBody();
            String changeId = body.getProperty("ChangeID").getValue().toString();
            if(!changeID.equals(changeId))
                throw new RuntimeException(
                    format("ChangeId contained in server response ('%s') does not match request change (%s).", changeId, changeID));
            boolean isInDevelopment = Boolean.valueOf(body.getProperty("IsInDevelopment").getValue().toString());
            logger.debug(format("Change '%s' found. isInDevelopment: '%b'", changeID, isInDevelopment));
            return new CMODataChange(changeID, isInDevelopment);
        } catch(RuntimeException e) {
            logger.error(format("%s caught while getting change '%s'.", e.getClass().getName(), changeID), e);
            throw e;
        } finally {
            if(response != null) {
                response.close();
            }
            logger.trace(format("Exiting 'getChange'. changeID: '%s'.", changeID));
        }
    }

    /**
     * Retrieves all transports assigned to the change with id changeId
     * @throws IllegalStateException in case the client has been closed.
     * @throws IllegalArgumentException in case changeID is null or empty.
     * @throws ODataClientErrorException with status code 400 in case no matching 
     *   changeId does exist.
     */
    public List getChangeTransports(String changeID) {

        logger.trace(format("Entering 'getChangeTransports'. changeID: '%s'.", changeID));

        checkClosed();

        checkArgument(!isBlank(changeID), format("changeID was null or empty: '%s'.", changeID));

        URI entityUri = this.client.newURIBuilder(serviceUrl).appendEntitySetSegment("Changes")
            .appendKeySegment(changeID).appendNavigationSegment("Transports").build();

        logger.debug(format("Entity URI for getting transports for change id '%s': '%s'.", changeID, entityUri.toASCIIString()));
        ODataEntitySetIteratorRequest request = this.client.getRetrieveRequestFactory().getEntitySetIteratorRequest(entityUri);

        request.setAccept(contentType.toContentTypeString());

        ODataRetrieveResponse> response = null;

        try {
            response = request.execute();

            ClientEntitySetIterator iterator = response.getBody();

            ArrayList transportList = new ArrayList<>();

            while (iterator.hasNext()) {
                transportList.add(toTransport(changeID, iterator.next()));
            }

            if(transportList.isEmpty()) {
                logger.debug(format("No transports found for change document '%s'.", changeID));
            } else {
                logger.debug(format("%d transports found for change document '%s'.", transportList.size(), changeID));
            }
            return transportList;
        } catch(RuntimeException e) {
            logger.error(format("%s caught while retrieving transports for change document '%s'.",
                e.getClass().getName(), changeID));
            throw e;
        } finally {
            if(response != null) {
                response.close();
            }
            logger.trace(format("Exiting 'getChangeTransports'. changeID: '%s'.", changeID));
        }
    }

    /**
     * Uploads a file into a transport
     * @param changeID The identifier of the change to that the transport request is assigned to.
     * @param transportID The identifier of the transport receiving the file denoted by filePath.
     * @param filePath An absolute or relative path denoting the file which should be uploaded.
     * @param applicationID An identifier used in the backend in order to decide how the upload 
     *          is processed.
     * @throws IllegalStateException in case the client has been closed.
     * @throws CMODataClientException In case the file denoted by filePath cannot be resolved.
     * @throws IOException In case of problems with reading the file denoted by filePath.
     */
    public void uploadFileToTransport(String changeID, String transportID, String filePath, String applicationID) throws IOException, CMODataClientException {
        logger.trace(format("Entering 'uploadFileToTransport'. ChangeID: '%s', TransportId: '%s', FilePath: '%s', ApplicationId: '%s'.",
                changeID, transportID, filePath, applicationID));
        checkClosed();

        File file = new File(filePath);

        if(!file.canRead()) {
            throw new CMODataClientException(format("Cannot upload file '%s' to transport '%s'. File cannot be read.", file.getAbsolutePath(), transportID));
        }

        URIBuilder uribuilder = this.client.newURIBuilder(serviceUrl).appendEntitySetSegment("Files");

        URI fileStreamUri = uribuilder.build();

        fileStreamUri = URI.create(fileStreamUri.toString() + "(ChangeID='" + changeID + "',TransportID='" + transportID + "',FileID='" + file.getName() + "',ApplicationID='" + applicationID + "')");

        logger.debug(format("File stream URI for uploading file '%s' to transport '%s' for change id '%s': '%s'.",
            file.getAbsolutePath(), changeID, transportID,  fileStreamUri.toASCIIString()));

        ODataResponse createMediaResponse = null;
        try (FileInputStream fileStream = new FileInputStream(file)) {

            ODataMediaEntityUpdateRequest createMediaRequest = this.client.getCUDRequestFactory().getMediaEntityUpdateRequest(fileStreamUri, fileStream);

            createMediaRequest.addCustomHeader("x-csrf-token", getCSRFToken());
            createMediaRequest.setFormat(contentType);

            String mimeType = URLConnection.guessContentTypeFromName(file.getName());

            if (! Strings.isNullOrEmpty(mimeType)) {

                createMediaRequest.setContentType(mimeType);
                logger.debug(format("Assuming mime type '%s' for file '%s'.", mimeType, file.getName()));

            } else {
                logger.warn(format("Cannot derive mimetype from file name '%s'", file.getName()));
            }

            ODataPayloadManager streamManager = createMediaRequest.payloadManager();

            createMediaResponse = streamManager.getResponse();

            checkStatus(createMediaResponse, 204);

            logger.debug(format("File '%s' uploaded to transport '%s' for change id '%s' with application id '%s'.",
                    filePath, transportID, changeID, applicationID));

        } catch(IOException | CMODataClientException | RuntimeException e) {
            logger.error(format("%s caught while uploading file '%s' to transport '%s' for change id '%s' with application id '%s'.",
                    e.getClass().getName(), filePath, transportID, changeID, applicationID));
            throw e;
        } finally {
            if(createMediaResponse != null) {
                createMediaResponse.close();
            }
            logger.trace(format("Exiting 'uploadFileToTransport'. changeID: '%s', transportId: '%s', filePath: '%s', applicationId: '%s'.",
                    changeID, transportID, filePath, applicationID));

        }
    }

    /**
     * Releases a transport.
     * @param changeID The identifier of the change to that the transport is assigned to.
     * @param transportID The identfier of the transport that is released.
     * @throws IllegalStateException in case the client has been closed.
     * @throws IllegalArgumentException in case either changeId or transportId
     *           or both are null or empty.
     * @throws CMODataClientException In case the transport could not be releases successfully. 
     */
    public void releaseDevelopmentTransport(String changeID, String transportID) throws RuntimeException, CMODataClientException {

        logger.trace(format("Entering 'releaseDevelopmentTransport'. changeID: '%s', transportId: '%s'.",
                changeID, transportID));

        checkClosed();

        if(StringUtils.isBlank(changeID)) throw new IllegalArgumentException(format("ChangeID is null or blank: '%s'.", changeID));
        if(StringUtils.isBlank(transportID)) throw new IllegalArgumentException(format("TransportID is null or blank: '%s'.", transportID));

        URI functionUri = getFunctionURI("releaseTransport", "?ChangeID='" + changeID + "'" + "&TransportID='" + transportID + "'");

        logger.debug(format("Function URI for releassing transport '%s' for change id '%s': '%s'.",
           transportID, changeID, functionUri.toASCIIString()));

        ODataInvokeRequest functionInvokeRequest = this.client.getInvokeRequestFactory().getFunctionInvokeRequest(functionUri, ClientEntity.class);

        ODataInvokeResponse response = null;

        try {
            response = executeRequest(functionInvokeRequest, 200);
            logger.debug(format("Transport request '%s' for change document '%s' released.",
                    transportID, changeID));
        } catch(CMODataClientException | RuntimeException e) {
            logger.error(format("%s caught while releasing transport '%s' for change document '%s'.",
                e.getClass().getName(), transportID, changeID));
            throw e;
        } finally {
            if(response != null) {
                response.close();
                logger.trace(format("Exiting 'releaseDevelopmentTransport'. changeID: '%s', transportId: '%s'.",
                        changeID, transportID));
            }
        }
    }

    /**
     * Creates a transport without description and owner.
     * @param changeID The identifier of the change for that the transport is created.
     * @return An instance representing the transport.
     * @throws CMODataClientException In case the transport could not be created.
     */
    public CMODataTransport createDevelopmentTransport(String changeID, String developmentSystemId) throws CMODataClientException {
        return _createDevelopmentTransport(changeID, "createTransport",
                getQueryString(new ImmutableMap.Builder()
                    .put(REQ_PARAM_CHANGE_ID, changeID)
                    .put(REQ_PARAM_DEVELOPMENT_SYSTM_ID, developmentSystemId)
                    .build()));
    }

    /**
     * Creates a transport with description and owner.
     * @param changeID The identifier of the change for that the transport is created.
     * @return An instance representing the transport.
     * @throws CMODataClientException In case the transport could not be created.
     */
    public CMODataTransport createDevelopmentTransportAdvanced(String changeID, String developmentSystemId, String description, String owner) throws CMODataClientException {

        return _createDevelopmentTransport(changeID, "createTransportAdvanced",
                getQueryString(new ImmutableMap.Builder()
                    .put(REQ_PARAM_CHANGE_ID, changeID)
                    .put(REQ_PARAM_DEVELOPMENT_SYSTM_ID, developmentSystemId)
                    .put(REQ_PARAM_DESCRIPTION, description)
                    .put(REQ_PARAM_OWNER, owner).build()));
    }

    private static String getQueryString(Map params) {
        StringBuffer queryString = new StringBuffer("?");
        boolean first = true;
        for (Map.Entry e : params.entrySet()) {
            if(first) {
                first = false;
            } else {
                queryString.append("&");
            }
            queryString.append(format("%s='%s'", e.getKey(), e.getValue()));
        }

        return queryString.toString();
    }

    /**
     * Releases any resources attached to this client.
     * Was introduced due to issues with hanging threads as some kind of workaround.
     * Should not be used in case of server environments.
     * The synchronization mechanism is not perfect since the executor service might be shutdown
     * while the client is used by other threads. Would be better to synchronize the methods in total.
     * But this would sequentialize all the requests, which is not acceptable in case of server environments.
     */
    public void close() {
        synchronized(this.client) {
            if(!isClosed) {
                this.isClosed = true;
                this.client.getConfiguration().getExecutor().shutdown();
            }
        }
        //not sure if there are other resources ...
    }

    /**
     * Checks if the client has been closed.
     * @return true if the client has been closed. false otherwise.
     */
    public boolean isClosed() {
        synchronized (this.client) {
            return isClosed;
        }
    }

    static CMODataTransport toTransport(String changeID, ClientEntity transportEntity) {
        return toTransport(changeID, transportEntity, true);
    }

    static CMODataTransport toTransport(String changeID, ClientEntity transportEntity, boolean failOnMissingProperty) {

        String transportId = getValueAsString("TransportID", transportEntity);
        checkState(!isBlank(transportId), format("Transport id found to be null or empty when retrieving transports for change '%s'.", changeID), failOnMissingProperty);

        String developmentSystemId = getValueAsString("DevelopmentSystemID", transportEntity);
        checkState( developmentSystemId != null, format("DevelopmentSystemID was null when retrieving transports for change '%s'.", changeID), failOnMissingProperty);

        String bModifiable = getValueAsString("IsModifiable", transportEntity);
        checkState(!isBlank(bModifiable), format("Modifiable flag found to be null or empty when retrieving transports for change '%s'.", changeID), failOnMissingProperty);

        String description = getValueAsString("Description", transportEntity);
        String owner = getValueAsString("Owner", transportEntity);

        logger.debug(format("Transport '%s' retrieved for change document '%s'. isModifiable: '%s', Owner: '%s' ,Description: '%s'.",
            transportId, changeID, bModifiable, owner, description));

        return new CMODataTransport(
                transportId,
                developmentSystemId,
                parseBoolean(bModifiable),
                description,
                owner);
    }

    static void checkState(boolean state, String message, boolean failOnMissingProperty) {
        if (!state) {
            if(failOnMissingProperty) throw new IllegalStateException(message);
            logger.warn(message);
        }
    }

    private void checkClosed() {
        if(isClosed()) {
            IllegalStateException e = new IllegalStateException(format("This instance (%s) has been closed.",
                   getClass().getName() + "@" + Integer.toHexString(System.identityHashCode(this))));
            logger.warn("Client has already been closed.", e);
            throw e;
        }
    }

    private CMODataTransport _createDevelopmentTransport(String changeID, String segment, String query) throws CMODataClientException {

        logger.trace(format("Entering '_createDevelopmentTransport'. ChangeID: '%s', Segment: '%s', Query: '%s'.",
                changeID, segment, query));

        checkClosed();

        URI functionUri = getFunctionURI(segment, query);

        ODataInvokeRequest functionInvokeRequest = this.client.getInvokeRequestFactory().getFunctionInvokeRequest(functionUri, ClientEntity.class);

        ODataInvokeResponse response = null;
        try {

            response = executeRequest(functionInvokeRequest, 200);

            // failOnMissingProperty in toTransport below is false, since we do not want to
            // fail if the transport has been created, but a property is missing in the response.
            // NB: the transportId itself is checked / should be checked in the constructor of the
            // transport object.
            CMODataTransport transport = toTransport(changeID, response.getBody(), false);

            logger.debug(format("Transport '%s' created for change document '%s'. isModifiable: '%b', Description: '%s', Owner: '%s'.",
                    transport.getTransportID(), changeID, transport.isModifiable(), transport.getDescription(), transport.getOwner()));

            return transport;
        } catch(CMODataClientException | RuntimeException e) {
            logger.error(format("%s caught while creating transport for change '%s'.", e.getClass().getName(), changeID));
            throw e;
        } finally {
            if(response != null) {
                response.close();
            }
            logger.trace(format("Exiting '_createDevelopmentTransport'. ChangeID: '%s', Segment: '%s', Query: '%s'.",
                    changeID, segment, query));
        }
    }

    private ODataInvokeResponse executeRequest(ODataInvokeRequest functionInvokeRequest, int returnCode) throws CMODataClientException {
        functionInvokeRequest.setAccept(ContentType.APPLICATION_ATOM_XML.toContentTypeString());
        ODataInvokeResponse response = functionInvokeRequest.execute();
        checkStatus(response, returnCode);
        return response;
    }

    private String getCSRFToken() {

        URI metadataUri = this.client.newURIBuilder(serviceUrl).appendMetadataSegment().build();

        ODataEntitySetIteratorRequest request = this.client.getRetrieveRequestFactory().getEntitySetIteratorRequest(metadataUri);

        request.addCustomHeader("X-CSRF-Token", "Fetch");

        // here we have in fact xml rather than atom+xml
        request.setAccept(ContentType.APPLICATION_XML.toContentTypeString());

        ODataRetrieveResponse> response = request.execute();

        return response.getHeader("X-CSRF-Token").iterator().next();

    }

    private URI getFunctionURI(String segment, String query) {
        URI functionUri = this.client.newURIBuilder(serviceUrl).appendActionCallSegment(segment).build();
        return URI.create(functionUri.toString() + UrlEscapers.urlFragmentEscaper().escape(query));
    }

    private void checkStatus(ODataResponse response, int expectedStatusCode) throws CMODataClientException {

        if (response.getStatusCode() != expectedStatusCode) {

            IOException suspressed = null;

            String bodyContent = "";

            try {
                /*
                 *  Does not work for http-4xx and http.5xx since Olingo prefers to answer
                 *  with an exception in this case rather with an response entity. But it might
                 *  be helpful also for inexpected http responses outside that range. 
                 */
                bodyContent = IOUtils.toString(response.getRawResponse());
            } catch(IOException e) {
                suspressed = e;
                logger.warn("Cannot read response body content.", e);
            }

            CMODataClientException e = new CMODataClientException(
                format("Response status code '%d' does not match expected status code '%d'. Response body: '%s'.",
                    response.getStatusCode(), expectedStatusCode, bodyContent));
            if(suspressed != null) e.addSuppressed(suspressed);
            throw e;
        }
    }

    private static String getValueAsString(String key, ClientEntity transport) {
        ClientProperty property = transport.getProperty(key);
        if(property == null) return null;
        ClientValue value = property.getValue();
        if(value == null) return null;
        return value.toString();
    }

    /**
     * 
     * @return A string denoting the project version.
     */
    public static String getShortVersion() {
        return VersionHelper.getShortVersion();
    }

    /**
     * 
     * @return A string denoting the project version and the identifier of the git commit which was
     *         the basis for the build.
     */
    public static String getLongVersion() {
        return VersionHelper.getLongVersion();
    }

    private static void BAD_HACK_setErrorMessageNameSpace() {

        /*
         * [Q] Why do we have that bad hack?
         * [A] Without that hack we do not get the error message issued by the server.
         *     Within the AtomDeserializer the error response is parsed and marshaled
         *     into an ODataError instance. The 'message' tag which encapsulates the error
         *     message is exptected to have a certain namespace. For whatever reason the
         *     server provides the 'message' tag with a different namespace. Hence the
         *     namespace is updated below accordingly.
         *
         *     Other option would be to switch from xml response format to json response format.
         *     But that also does not work. The json response for getting the transports is expected
         *     to have a 'value' node. But the server provides the transport inside a node named
         *     'd'. Hence nothing is marshaled from json into the corresponding odata like
         *     data transfer entities.
         */
        String ourNamespace = "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata";

        logger.debug(format("Setting namespace of message qname to '%s'.", ourNamespace));

        try {

            Exception caught = null;

            String errorMessageQNameFieldName = "errorMessageQName",
                   nameSpaceURIFieldName = "namespaceURI";

            final Field errorMessageQNameField = AtomDeserializer.class.getDeclaredField(errorMessageQNameFieldName),
                        namespaceURI = QName.class.getDeclaredField(nameSpaceURIFieldName);

            try {
                namespaceURI.setAccessible(true);
                errorMessageQNameField.setAccessible(true);
                namespaceURI.set(errorMessageQNameField.get(null), ourNamespace);
                logger.info(format("Namespace of message qname set to '%s'.", ourNamespace));

            } catch(Exception e) {
                caught = e;
            } finally {
                try {
                    errorMessageQNameField.setAccessible(false);
                } catch(RuntimeException e) {
                    logger.error(format("Cannot reset accessibility flag for '%s'.", errorMessageQNameFieldName), e);
                    if(caught == null) throw e; else caught.addSuppressed(e);
                }
                try {
                    namespaceURI.setAccessible(false);
                } catch(RuntimeException e) {
                    logger.error(format("Cannot reset accessibility flag for '%s'.", nameSpaceURIFieldName), e);
                    if(caught == null) throw e; else caught.addSuppressed(e);
                }
            }

            if(caught != null) throw caught;

        } catch(Exception e) {

            logger.warn(format("%s caught while setting name space of message qname to '%s'. "
                    + "Retrieving error messages from server is not possible.", e.getClass().getName(), ourNamespace), e);
        }
    }

    static {
        BAD_HACK_setErrorMessageNameSpace();
    }

}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy