com.sap.cloud.sdk.s4hana.connectivity.HttpRequestExecutor Maven / Gradle / Ivy
package com.sap.cloud.sdk.s4hana.connectivity;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.zip.GZIPOutputStream;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.RequestLine;
import org.apache.http.StatusLine;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpHead;
import org.apache.http.client.methods.HttpOptions;
import org.apache.http.client.methods.HttpPatch;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.entity.ByteArrayEntity;
import org.slf4j.Logger;
import com.google.common.base.Charsets;
import com.google.common.base.Strings;
import com.google.common.collect.Lists;
import com.sap.cloud.sdk.cloudplatform.auditlog.AccessedAttribute;
import com.sap.cloud.sdk.cloudplatform.auditlog.AuditLogger;
import com.sap.cloud.sdk.cloudplatform.auditlog.AuditedDataObject;
import com.sap.cloud.sdk.cloudplatform.auditlog.AuditedDataSubject;
import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
import com.sap.cloud.sdk.cloudplatform.connectivity.DestinationAccessor;
import com.sap.cloud.sdk.cloudplatform.connectivity.Header;
import com.sap.cloud.sdk.cloudplatform.connectivity.HttpClientAccessor;
import com.sap.cloud.sdk.cloudplatform.connectivity.HttpEntityUtil;
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationNotFoundException;
import com.sap.cloud.sdk.cloudplatform.exception.ShouldNotHappenException;
import com.sap.cloud.sdk.cloudplatform.logging.CloudLoggerFactory;
import com.sap.cloud.sdk.cloudplatform.security.Authorization;
import com.sap.cloud.sdk.s4hana.connectivity.exception.AccessDeniedException;
import com.sap.cloud.sdk.s4hana.connectivity.exception.CloudConnectorException;
import com.sap.cloud.sdk.s4hana.connectivity.exception.LogonErrorException;
import com.sap.cloud.sdk.s4hana.connectivity.exception.MissingConfigException;
import com.sap.cloud.sdk.s4hana.connectivity.exception.QueryExecutionException;
import com.sap.cloud.sdk.s4hana.connectivity.exception.QuerySerializationException;
import com.sap.cloud.sdk.s4hana.serialization.SapClient;
import lombok.Getter;
import lombok.Value;
/**
* A collection of methods which are commonly called during executions of a query.
*
* @param
* The type of the query to execute.
* @param
* The type of the result to return.
*/
public class HttpRequestExecutor, QueryResultT extends QueryResult>
{
private static final Logger logger = CloudLoggerFactory.getLogger(HttpRequestExecutor.class);
@Getter
private final QueryExecutionMeasurements measurements = new QueryExecutionMeasurements();
private static class ErpAuthorization extends Authorization
{
ErpAuthorization( @Nonnull final String missingAuthorization )
{
super(missingAuthorization);
}
}
/**
* A helper class to wrap request body and headers.
*/
@Value
static final class RequestBodyWithHeader
{
private final List headers;
private final String body;
}
private void assertValidConfigContext( final ErpConfigContext configContext )
throws MissingConfigException
{
if( configContext.getDestinationName() == null ) {
throw new MissingConfigException(
"No destination name configured (is null). "
+ "Please provide a destination name in "
+ ErpConfigContext.class.getSimpleName()
+ ".");
}
if( configContext.getSapClient() == null ) {
throw new MissingConfigException(
"No "
+ SapClient.class.getSimpleName()
+ " configured (is null). "
+ "Please set a "
+ SapClient.class.getSimpleName()
+ " in property \""
+ ErpConfigContext.DEFAULT_SAP_CLIENT_PROPERTY
+ "\" of destination \""
+ configContext.getDestinationName()
+ "\" or provide the "
+ SapClient.class.getSimpleName()
+ " as an explicit argument of "
+ ErpConfigContext.class.getSimpleName()
+ ".");
}
if( configContext.getLocale() == null ) {
throw new MissingConfigException(
"No "
+ Locale.class.getSimpleName()
+ " configured (is null). "
+ "Please set a "
+ Locale.class.getSimpleName()
+ " in property \""
+ ErpConfigContext.DEFAULT_LOCALE_PROPERTY
+ "\" of destination \""
+ configContext.getDestinationName()
+ "\" or provide the "
+ Locale.class.getSimpleName()
+ " as an explicit argument of "
+ ErpConfigContext.class.getSimpleName()
+ ".");
}
}
private static final int MAX_UNCOMPRESSED_PAYLOAD_LENGTH = 1400;
@Nonnull
private ByteArrayEntity getBodyAsCompressedEntity( @Nonnull final String body )
throws QuerySerializationException
{
final ByteArrayEntity entity;
final byte[] content;
try {
content = body.getBytes(Charsets.UTF_8.toString());
}
catch( final UnsupportedEncodingException e ) {
throw new QuerySerializationException("Failed to to convert payload from String to UTF8 byte[].", e);
}
if( content.length > MAX_UNCOMPRESSED_PAYLOAD_LENGTH ) {
final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try( final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(outputStream) ) {
gzipOutputStream.write(content);
}
catch( final IOException e ) {
throw new QuerySerializationException("Failed to write to GZIP-compressed stream.", e);
}
entity = new ByteArrayEntity(outputStream.toByteArray());
entity.setContentEncoding("gzip");
if( logger.isInfoEnabled() ) {
logger.info(
"Compressed length of ERP query body: "
+ entity.getContentLength()
+ " bytes, was "
+ content.length
+ " bytes.");
}
} else {
entity = new ByteArrayEntity(content);
entity.setContentEncoding(Charsets.UTF_8.toString());
if( logger.isInfoEnabled() ) {
logger.info("Length of ERP query body: " + entity.getContentLength() + " bytes.");
}
}
return entity;
}
@Nonnull
private URI appendQueryParam(
@Nonnull final URI uri,
@Nullable final String queryParamKey,
@Nullable final String queryParamValue )
throws QuerySerializationException
{
final String query = uri.getQuery();
final String queryParam = queryParamKey + "=" + queryParamValue;
final String newQuery;
if( Strings.isNullOrEmpty(query) ) {
newQuery = queryParam;
} else {
newQuery = query + "&" + queryParam;
}
try {
return new URI(
uri.getScheme(),
uri.getUserInfo(),
uri.getHost(),
uri.getPort(),
uri.getPath(),
newQuery,
uri.getFragment());
}
catch( final URISyntaxException e ) {
throw new QuerySerializationException(e);
}
}
private void handleHttpStatus(
@Nonnull final ErpConfigContext configContext,
final int statusCode,
@Nullable final String responseBody,
@Nonnull final List responseHeaders )
throws QueryExecutionException
{
if( statusCode == HttpStatus.SC_OK ) {
if( logger.isTraceEnabled() ) {
logger.trace(
"Query execution finished successfully. Response body: "
+ responseBody
+ " Headers: "
+ getNonSensitiveHeadersAsString(responseHeaders)
+ ".");
}
} else {
handleHttpError(configContext, statusCode, responseBody, responseHeaders);
}
}
private void handleHttpError(
@Nonnull final ErpConfigContext configContext,
final int statusCode,
@Nullable final String responseBody,
@Nonnull final List responseHeaders )
throws QueryExecutionException
{
switch( statusCode ) {
case HttpStatus.SC_UNAUTHORIZED:
handleUnauthorized(responseBody, responseHeaders);
return;
case HttpStatus.SC_FORBIDDEN:
handleForbidden(responseBody, responseHeaders);
return;
case HttpStatus.SC_INTERNAL_SERVER_ERROR:
handleInternalServerError(responseBody, responseHeaders);
return;
case HttpStatus.SC_SERVICE_UNAVAILABLE:
handleServiceUnavailableError(configContext, responseBody, responseHeaders);
return;
case HttpStatus.SC_BAD_GATEWAY:
handleBadGateway(responseBody, responseHeaders);
return;
default: {
final String message =
"Query execution failed with status code "
+ statusCode
+ ". Response body: "
+ responseBody
+ " Headers: "
+ getNonSensitiveHeadersAsString(responseHeaders)
+ ".";
throw new QueryExecutionException(message);
}
}
}
private void handleUnauthorized( @Nullable final String responseBody, @Nonnull final List responseHeaders )
throws LogonErrorException
{
final String message =
HttpStatus.SC_UNAUTHORIZED
+ " Unauthorized. The connection attempt was refused. Response body: "
+ responseBody
+ " Headers: "
+ getNonSensitiveHeadersAsString(responseHeaders)
+ ".";
throw new LogonErrorException(message);
}
@Nullable
private String getMissingAuthorization( @Nonnull final List responseHeaders )
{
for( final Header header : responseHeaders ) {
if( header.getName().equals("failed-authorization-object") ) {
return header.getValue();
}
}
return null;
}
private void handleForbidden( @Nullable final String responseBody, @Nonnull final List responseHeaders )
throws AccessDeniedException
{
final String prefix = HttpStatus.SC_FORBIDDEN + " Forbidden. ";
if( responseBody != null && responseBody.startsWith("CX_FINS_MAP_NO_AUTH_QUERY_EXEC") ) {
@Nullable
final String missingAuthorization = getMissingAuthorization(responseHeaders);
throw AccessDeniedException.raiseMissingAuthorizations(
null,
missingAuthorization != null
? Collections.singleton(new ErpAuthorization(missingAuthorization))
: null);
}
final String message =
prefix
+ "Failed to establish a trusted connection to the ERP. This may be caused by "
+ "a misconfiguration of the SAP Cloud Connector or a misconfiguration "
+ "of the trust certificate. Response body: "
+ responseBody
+ " Headers: "
+ getNonSensitiveHeadersAsString(responseHeaders)
+ ".";
throw new AccessDeniedException(message);
}
private
void
handleInternalServerError( @Nullable final String responseBody, @Nonnull final List responseHeaders )
throws QueryExecutionException
{
final String prefix = HttpStatus.SC_INTERNAL_SERVER_ERROR + " Internal Server Error. ";
if( responseBody != null && responseBody.contains("ICF") && responseBody.contains("HCPMAPBM") ) {
final String message =
prefix
+ "Failed to invoke ICF service. Does the user have authorization HCPMAPBM? "
+ "Response body: "
+ responseBody
+ " Headers: "
+ getNonSensitiveHeadersAsString(responseHeaders)
+ ".";
throw new AccessDeniedException(message);
}
final String message =
prefix
+ "Query execution failed with unexpected error. Response body: "
+ responseBody
+ " Headers: "
+ getNonSensitiveHeadersAsString(responseHeaders)
+ ".";
throw new QueryExecutionException(message);
}
private void handleServiceUnavailableError(
@Nonnull final ErpConfigContext configContext,
@Nullable final String responseBody,
@Nonnull final List responseHeaders )
throws QueryExecutionException
{
if( responseBody != null && responseBody.contains("No tunnels subscribed for tunnelId") ) {
final String message =
HttpStatus.SC_SERVICE_UNAVAILABLE
+ " Service Unavailable. Failed to connect to ERP system. "
+ "Please check the configuration of destination '"
+ configContext.getDestinationName()
+ "'. In an on-premise setup, ensure that the cloud connector is connected.";
throw new CloudConnectorException(message);
} else {
handleInternalServerError(responseBody, responseHeaders);
}
}
private void handleBadGateway( @Nullable final String responseBody, @Nonnull final List responseHeaders )
throws QueryExecutionException
{
if( responseBody != null && responseBody.contains("Unable to open connection to backend system") ) {
final String message =
HttpStatus.SC_BAD_GATEWAY
+ " Bad Gateway. Cloud connector failed to open connection to backend system. "
+ "Is the internal host configured correctly? Response body: "
+ responseBody
+ " Headers: "
+ getNonSensitiveHeadersAsString(responseHeaders)
+ ".";
throw new CloudConnectorException(message);
} else {
handleInternalServerError(responseBody, responseHeaders);
}
}
/**
* Converts the given headers to a String while omitting sensitive headers to avoid leaking them to logs.
*/
@Nonnull
private String getNonSensitiveHeadersAsString( @Nonnull final List headers )
{
final StringBuilder sb = new StringBuilder();
final Iterator headerIt = headers.iterator();
while( headerIt.hasNext() ) {
final Header header = headerIt.next();
final String name = header.getName();
String value = header.getValue();
if( "set-cookie".equalsIgnoreCase(name) || "authorization".equalsIgnoreCase(name) ) {
value = "(hidden)";
}
sb.append(name).append(": ").append(value).append(headerIt.hasNext() ? ", " : "");
}
return sb.toString();
}
/**
* Serializes the given query, executes it, and the deserializes the response.
*
* @param configContext
* The {@code ErpConfigContext} of this call.
* @param query
* The {@code Query} to be executed.
* @param querySerializer
* The {@code QuerySerializer} to be used to write the query and read the response.
* @return The body of the response received by the given query.
* @throws QuerySerializationException
* If the query could not be serialized
* @throws QueryExecutionException
* If any Exception occured during execution of the query.
* @throws DestinationNotFoundException
* If the Destination cannot be found.
* @throws DestinationAccessException
* If the destination is not of type DestinationType.HTTP or there is an issue while accessing
* destination information.
*/
@Nonnull
public QueryResultT execute(
@Nonnull final ErpConfigContext configContext,
@Nonnull final QueryT query,
@Nonnull final QuerySerializer querySerializer )
throws QuerySerializationException,
QueryExecutionException,
DestinationNotFoundException,
DestinationAccessException
{
assertValidConfigContext(configContext);
measurements.resetMeasurements();
measurements.setBeginTotal(System.nanoTime());
try {
final SerializedQuery serializedQuery = serializeQuery(configContext, query, querySerializer);
final String responseBody = execute(configContext, serializedQuery);
return deserializeQuery(configContext, query, querySerializer, responseBody);
}
finally {
measurements.setEndTotal(System.nanoTime());
}
}
@Nonnull
private SerializedQuery serializeQuery(
@Nonnull final ErpConfigContext configContext,
@Nonnull final QueryT query,
@Nonnull final QuerySerializer querySerializer )
throws QuerySerializationException,
DestinationNotFoundException,
DestinationAccessException
{
final long beginBuildReq = System.nanoTime();
try {
return querySerializer.serialize(query);
}
finally {
final long endBuildReq = System.nanoTime();
measurements.addBuildRequestDuration(Duration.ofNanos(endBuildReq - beginBuildReq));
}
}
@Nonnull
private QueryResultT deserializeQuery(
@Nonnull final ErpConfigContext configContext,
@Nonnull final QueryT query,
@Nonnull final QuerySerializer querySerializer,
@Nonnull final String responseBody )
throws QuerySerializationException,
DestinationNotFoundException,
DestinationAccessException
{
final long beginParseResp = System.nanoTime();
try {
final SerializedQueryResult serializedQueryResult =
new SerializedQueryResult<>(query, responseBody);
return querySerializer.deserialize(serializedQueryResult);
}
finally {
final long endParseResp = System.nanoTime();
measurements.addParseResponseDuration(Duration.ofNanos(endParseResp - beginParseResp));
}
}
@Nonnull
private RequestMethod getRequestMethod( @Nonnull final SerializedQuery serializedQuery )
{
return serializedQuery.getRequestMethod();
}
@Nonnull
protected URI getRequestUri(
@Nonnull final ErpConfigContext configContext,
@Nonnull final Destination destination,
@Nonnull final SerializedQuery serializedQuery )
{
return new ServiceUriBuilder().build(destination.getUri(), serializedQuery.getRequestPath());
}
@Nonnull
private List getRequestHeaders(
final ErpConfigContext configContext,
@Nonnull final SerializedQuery serializedQuery )
{
final List requestHeaders = Lists.newArrayList(serializedQuery.getRequestHeaders());
if( configContext != null ) {
// add HTTP header "sap-client" if a SAP client is defined
final SapClient sapClient = configContext.getSapClient();
if( sapClient != null && !sapClient.isDefault() && !sapClient.isEmpty() ) {
requestHeaders.add(new Header(ErpConfigContext.DEFAULT_SAP_CLIENT_PROPERTY, sapClient.getValue()));
}
// add HTTP header "sap-language" if a language is defined
final Locale locale = configContext.getLocale();
if( locale != null && !Strings.isNullOrEmpty(locale.getLanguage()) ) {
requestHeaders.add(new Header(ErpConfigContext.DEFAULT_LOCALE_PROPERTY, locale.getLanguage()));
}
}
return requestHeaders;
}
private HttpUriRequest newRequest( final RequestMethod requestMethod, final URI requestUri )
{
switch( requestMethod ) {
case GET:
return new HttpGet(requestUri);
case HEAD:
return new HttpHead(requestUri);
case POST:
return new HttpPost(requestUri);
case PUT:
return new HttpPut(requestUri);
case PATCH:
return new HttpPatch(requestUri);
case DELETE:
return new HttpDelete(requestUri);
case OPTIONS:
return new HttpOptions(requestUri);
default:
throw new ShouldNotHappenException("Unsupported request method: " + requestMethod + ".");
}
}
private HttpUriRequest newRequest(
@Nonnull final RequestMethod requestMethod,
@Nonnull final URI requestUri,
@Nonnull final RequestBodyWithHeader bodyWithHeader )
throws QuerySerializationException
{
final long beginBuildRequest = System.nanoTime();
try {
final HttpUriRequest request = newRequest(requestMethod, requestUri);
if( request instanceof HttpEntityEnclosingRequest ) {
((HttpEntityEnclosingRequest) request).setEntity(getBodyAsCompressedEntity(bodyWithHeader.body));
}
request.setHeader(HttpHeaders.USER_AGENT, "ErpEndpointSCP");
request.setHeader(HttpHeaders.ACCEPT_ENCODING, "gzip");
for( final Header header : bodyWithHeader.headers ) {
request.setHeader(header.getName(), header.getValue());
}
if( logger.isTraceEnabled() ) {
final Thread currentThread = Thread.currentThread();
logger.trace(
"Successfully prepared HTTP request for query execution (thread: "
+ currentThread
+ ", threat id: "
+ currentThread.getId()
+ ") URI: "
+ requestUri
+ " Body: "
+ bodyWithHeader.body
+ " Headers: "
+ getNonSensitiveHeadersAsString(bodyWithHeader.headers)
+ ".");
}
return request;
}
finally {
measurements.addBuildRequestDuration(Duration.ofNanos(System.nanoTime() - beginBuildRequest));
}
}
private void logReadAccessAttempt( final Query, ?> query, ErpConfigContext context )
{
@Nullable
final String readAccessData = query.getReadAccessData();
if( readAccessData != null ) {
AuditLogger.logDataReadAttempt(
new AuditedDataObject(query.getClass().getSimpleName()),
new AuditedDataSubject(context.getDestinationName(), context.getSapClient().getValue()),
Lists.newArrayList(new AccessedAttribute(readAccessData, AccessedAttribute.Operation.READ)));
}
}
private String getQueryExecutionFailedMessage( final Query, ?> query )
{
return query.getClass().getSimpleName()
+ " "
+ query.getConstructedByMethod()
+ " failed ["
+ measurements.getMeasurementsString()
+ "]";
}
private static final Duration DEFAULT_LONG_RUNNING_REQUEST_THRESHOLD = Duration.ofMillis(2000);
private void recordExecutionDuration(
final SerializedQuery extends Query, ?>> serializedQuery,
final RequestLine requestLine,
final StatusLine statusLine,
final List responseHeaders,
final String responseBody )
{
final Duration executeRequestDuration = measurements.getExecuteRequestDuration();
final Duration longRunningRequestThreshold = serializedQuery.getQuery().getLongRunningRequestThreshold();
final Duration currLongRunningRequestThreshold =
longRunningRequestThreshold != null ? longRunningRequestThreshold : DEFAULT_LONG_RUNNING_REQUEST_THRESHOLD;
if( executeRequestDuration != null && currLongRunningRequestThreshold.compareTo(executeRequestDuration) < 0 ) {
ErpEndpointMonitor.getInstance().trackLongRunningRequest(
measurements.getExecuteRequestDuration(),
requestLine.toString(),
serializedQuery.getRequestBody(),
statusLine.toString(),
responseHeaders,
responseBody.length());
}
}
/**
* Executes the given {@code serializedQuery} as a {@code HttpUriRequest}, returning the body of the
* {@code HttpResponse} received.
*
* @param configContext
* The {@code ErpConfigContext} of this call.
* @param serializedQuery
* The {@code SerializedQuery} to execute.
*
* @return The body of the response received by the given query.
*
* @throws QuerySerializationException
* If the query could not be serialized.
* @throws QueryExecutionException
* If any Exception occured during execution of the query.
* @throws DestinationNotFoundException
* If the Destination cannot be found.
* @throws DestinationAccessException
* If the destination is not of type DestinationType.HTTP or there is an issue while accessing
* destination information.
*/
@Nonnull
public
String
execute( @Nonnull final ErpConfigContext configContext, @Nonnull final SerializedQuery serializedQuery )
throws QuerySerializationException,
QueryExecutionException,
DestinationNotFoundException,
DestinationAccessException
{
final QueryT query = serializedQuery.getQuery();
ErpEndpointMonitor.getInstance().incrementErpQueryCount(query);
final Destination destination = DestinationAccessor.getDestination(configContext.getDestinationName());
final HttpClient httpClient = HttpClientAccessor.getHttpClient(destination);
final RequestMethod requestMethod = getRequestMethod(serializedQuery);
final URI requestUri = getRequestUri(configContext, destination, serializedQuery);
// resolve request body and header for potentially signed request body
final RequestBodyWithHeader bodyWithHeader = getRequestBodyWithHeader(configContext, serializedQuery);
final HttpUriRequest request = newRequest(requestMethod, requestUri, bodyWithHeader);
HttpResponse response;
final List responseHeaders = new ArrayList<>();
final String responseBody;
final long beginExecute = System.nanoTime();
try {
if( logger.isDebugEnabled() ) {
logger.debug(
"Executing "
+ query.getClass().getSimpleName()
+ " constructed by: "
+ query.getConstructedByMethod()
+ ".");
}
logReadAccessAttempt(query, configContext);
response = httpClient.execute(request);
for( final org.apache.http.Header header : response.getAllHeaders() ) {
responseHeaders.add(new Header(header.getName(), header.getValue()));
}
responseBody = HttpEntityUtil.getResponseBody(response);
}
catch( final QuerySerializationException e ) {
if( logger.isDebugEnabled() ) {
logger.debug(getQueryExecutionFailedMessage(query), e);
}
throw e;
}
catch( final Exception e ) {
final String message = getQueryExecutionFailedMessage(query);
throw new QueryExecutionException(message, e);
}
finally {
measurements.addExecuteRequestDuration(Duration.ofNanos(System.nanoTime() - beginExecute));
}
if( responseBody == null ) {
throw new QueryExecutionException("Failed to execute query: no body returned in response.");
}
recordExecutionDuration(
serializedQuery,
request.getRequestLine(),
response.getStatusLine(),
responseHeaders,
responseBody);
handleHttpStatus(configContext, response.getStatusLine().getStatusCode(), responseBody, responseHeaders);
return responseBody;
}
/**
* Returns a wrapper object which encapsulates the HTTP request body and headers. This method can be overridden to
* manipulate the request before submitting, e.g. signing queries, adding timestamps.
*
* @param configContext
* The {@code ErpConfigContext} of this call.
* @param query
* The {@code Query} to be executed.
*/
@Nonnull
protected
RequestBodyWithHeader
getRequestBodyWithHeader( final ErpConfigContext configContext, @Nonnull final SerializedQuery query )
{
return new RequestBodyWithHeader(getRequestHeaders(configContext, query), query.getRequestBody());
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy