diff --git a/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ApacheHttpClient5Wrapper.java b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ApacheHttpClient5Wrapper.java index e7e48783e..45026addd 100644 --- a/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ApacheHttpClient5Wrapper.java +++ b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ApacheHttpClient5Wrapper.java @@ -4,6 +4,8 @@ import java.net.URI; import java.net.URISyntaxException; +import javax.annotation.Nonnull; + import org.apache.hc.client5.http.config.Configurable; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; @@ -27,7 +29,7 @@ * and it will append the url configured in the destination. */ @Slf4j -class ApacheHttpClient5Wrapper extends CloseableHttpClient implements Configurable +class ApacheHttpClient5Wrapper extends CloseableHttpClient implements Configurable, UriQueryMerger { private final CloseableHttpClient httpClient; @Getter( AccessLevel.PACKAGE ) @@ -127,4 +129,15 @@ public RequestConfig getConfig() { return requestConfig; } + + @Nonnull + @Override + public URI mergeRequestUri( @Nonnull final URI requestUri ) + { + final UriPathMerger merger = new UriPathMerger(); + URI merged; + merged = merger.merge(destination.getUri(), requestUri); + final String queryString = String.join("&", QueryParamGetter.getQueryParameters(destination)); + return merger.merge(merged, URI.create("/?" + queryString)); + } } diff --git a/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/CsrfTokenInterceptor.java b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/CsrfTokenInterceptor.java new file mode 100644 index 000000000..424226211 --- /dev/null +++ b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/CsrfTokenInterceptor.java @@ -0,0 +1,127 @@ +package com.sap.cloud.sdk.cloudplatform.connectivity; + +import java.io.IOException; +import java.net.URI; +import java.util.Set; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpHead; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpRequestInterceptor; +import org.apache.hc.core5.http.protocol.HttpContext; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +class CsrfTokenInterceptor implements HttpRequestInterceptor +{ + static final String X_CSRF_TOKEN_HEADER_KEY = "x-csrf-token"; + private static final String X_CSRF_TOKEN_FETCH_VALUE = "fetch"; + + private static final Set MUTATING_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE"); + private static final String NON_PRINTABLE_CHARS = "[^ -~]"; + + @Nonnull + private final HttpClient httpClient; + + @Override + public + void + process( @Nonnull final HttpRequest request, final EntityDetails entityDetails, final HttpContext context ) + throws HttpException, + IOException + { + if( !MUTATING_METHODS.contains(request.getMethod().toUpperCase()) ) { + return; + } + + if( request.containsHeader(X_CSRF_TOKEN_HEADER_KEY) ) { + log.debug("CSRF token already present in request, skipping retrieval."); + return; + } + + final URI requestUri; + try { + requestUri = request.getUri(); + } + catch( final Exception e ) { + log.debug("Failed to determine request URI for CSRF token fetch, skipping.", e); + return; + } + + final URI csrfFetchUri = deriveServiceRootUri(requestUri); + final HttpHead headRequest = new HttpHead(csrfFetchUri); + headRequest.addHeader(X_CSRF_TOKEN_HEADER_KEY, X_CSRF_TOKEN_FETCH_VALUE); + + try { + final String token = httpClient.execute(headRequest, response -> { + final Header header = response.getFirstHeader(X_CSRF_TOKEN_HEADER_KEY); + if( header == null || header.getValue() == null ) { + log + .debug( + "Target system did not respond with a {} header. " + + "The subsequent request may fail if a CSRF token is required.", + X_CSRF_TOKEN_HEADER_KEY); + return null; + } + return header.getValue().replaceAll(NON_PRINTABLE_CHARS, ""); + }); + + if( token != null ) { + log.debug("Successfully retrieved CSRF token, adding to request."); + request.addHeader(X_CSRF_TOKEN_HEADER_KEY, token); + } + } + catch( final Exception e ) { + log + .debug( + "CSRF token retrieval failed: the HEAD request was not successful. " + + "The subsequent request may fail if a CSRF token is required.", + e); + } + } + + /** + * Returns the request methods for which this interceptor will attempt to fetch a CSRF token. Used for testing. + */ + static Set getMutatingMethods() + { + return MUTATING_METHODS; + } + + /** + * Derives the service root URI from the full request URI by truncating the path at the first OData resource + * segment. This matches the HC4 behavior where the CSRF token HEAD request was always sent to the service path root + * rather than the specific resource path. + *

+ * The service root is identified as the path up to and including the trailing slash before the first resource + * segment. Example: {@code http://host/service/$batch} → {@code http://host/service/}, + * {@code http://host/service/Entity} → {@code http://host/service/} + */ + @Nonnull + static URI deriveServiceRootUri( @Nonnull final URI requestUri ) + { + final String path = requestUri.getRawPath(); + // Service root is everything up to and including the trailing slash before the first resource segment. + // Find the last '/' that is followed by at least one more character (i.e., there is a resource segment). + final int lastSlash = path.lastIndexOf('/'); + // If the path ends with '/' already (e.g. "/service/"), use it as-is. + // Otherwise, strip the last segment (e.g. "/service/Entity" -> "/service/", "/service/$batch" -> "/service/"). + final String servicePath = + (lastSlash >= 0 && lastSlash < path.length() - 1) ? path.substring(0, lastSlash + 1) : path; + try { + return new URI(requestUri.getScheme(), requestUri.getAuthority(), servicePath, null, null); + } + catch( final Exception e ) { + log.debug("Failed to derive service root URI, falling back to full request URI.", e); + return requestUri; + } + } +} diff --git a/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultApacheHttpClient5Factory.java b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultApacheHttpClient5Factory.java index df3547db3..c40fb048e 100644 --- a/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultApacheHttpClient5Factory.java +++ b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultApacheHttpClient5Factory.java @@ -104,7 +104,12 @@ private CloseableHttpClient buildHttpClient( builder.addRequestInterceptorFirst(requestInterceptor); } - return builder.build(); + final CloseableHttpClient[] holder = new CloseableHttpClient[1]; + builder + .addRequestInterceptorLast( + ( req, entity, ctx ) -> new CsrfTokenInterceptor(holder[0]).process(req, entity, ctx)); + holder[0] = builder.build(); + return holder[0]; } @Nonnull diff --git a/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/UriQueryMerger.java b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/UriQueryMerger.java new file mode 100644 index 000000000..32b9716fa --- /dev/null +++ b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/UriQueryMerger.java @@ -0,0 +1,24 @@ +package com.sap.cloud.sdk.cloudplatform.connectivity; + +import java.net.URI; + +import javax.annotation.Nonnull; + +/** + * Interface to resolve the request URI for a given request. Used to determine the destination-contributed query + * parameters so that next-link pagination can strip duplicate parameters. + */ +@FunctionalInterface +public interface UriQueryMerger +{ + /** + * Returns the fully-merged request URI for the given relative request URI. Merges the destination base URL, + * destination URL query parameters, and destination property query parameters into the URI. + * + * @param requestUri + * The relative request URI to merge. + * @return The merged request URI. + */ + @Nonnull + URI mergeRequestUri( @Nonnull URI requestUri ); +} diff --git a/cloudplatform/connectivity-apache-httpclient5/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/CsrfTokenInterceptorTest.java b/cloudplatform/connectivity-apache-httpclient5/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/CsrfTokenInterceptorTest.java new file mode 100644 index 000000000..a8bcbaaef --- /dev/null +++ b/cloudplatform/connectivity-apache-httpclient5/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/CsrfTokenInterceptorTest.java @@ -0,0 +1,172 @@ +package com.sap.cloud.sdk.cloudplatform.connectivity; + +import static com.github.tomakehurst.wiremock.client.WireMock.head; +import static com.github.tomakehurst.wiremock.client.WireMock.headRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpDelete; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpHead; +import org.apache.hc.client5.http.classic.methods.HttpPatch; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.classic.methods.HttpPut; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.io.HttpClientResponseHandler; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.apache.hc.core5.http.message.BasicHeader; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; + +import lombok.SneakyThrows; + +@WireMockTest +@SuppressWarnings( "unchecked" ) +class CsrfTokenInterceptorTest +{ + private static final String CSRF_TOKEN = "test-csrf-token"; + private static final String SERVICE_ROOT = "/service/"; + private static final String REQUEST_PATH = "/service/entity"; + + private HttpClient mockHttpClient; + private CsrfTokenInterceptor sut; + + @BeforeEach + void setup() + { + mockHttpClient = mock(HttpClient.class); + sut = new CsrfTokenInterceptor(mockHttpClient); + } + + @ParameterizedTest + @MethodSource( "mutatingMethods" ) + @SneakyThrows + void tokenIsFetchedAndAddedForMutatingMethods( final HttpRequest request ) + { + final ClassicHttpResponse headResponse = new BasicClassicHttpResponse(200); + headResponse.addHeader(new BasicHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY, CSRF_TOKEN)); + + when(mockHttpClient.execute(any(HttpHead.class), any(HttpClientResponseHandler.class))) + .thenAnswer(inv -> ((HttpClientResponseHandler) inv.getArgument(1)).handleResponse(headResponse)); + + sut.process(request, null, null); + + assertThat(request.getFirstHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY).getValue()) + .isEqualTo(CSRF_TOKEN); + } + + static HttpRequest[] mutatingMethods() + { + return new HttpRequest[] { + new HttpPost(REQUEST_PATH), + new HttpPut(REQUEST_PATH), + new HttpPatch(REQUEST_PATH), + new HttpDelete(REQUEST_PATH) }; + } + + @Test + @SneakyThrows + void tokenIsNotFetchedForGetRequest() + { + final HttpGet request = new HttpGet(REQUEST_PATH); + + sut.process(request, null, null); + + verify(mockHttpClient, never()).execute(any(), any(HttpClientResponseHandler.class)); + assertThat(request.getFirstHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY)).isNull(); + } + + @Test + @SneakyThrows + void tokenIsNotFetchedForHeadRequest() + { + final HttpHead request = new HttpHead(REQUEST_PATH); + + sut.process(request, null, null); + + verify(mockHttpClient, never()).execute(any(), any(HttpClientResponseHandler.class)); + assertThat(request.getFirstHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY)).isNull(); + } + + @Test + @SneakyThrows + void tokenIsNotFetchedWhenAlreadyPresent() + { + final HttpPost request = new HttpPost(REQUEST_PATH); + request.addHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY, "existing-token"); + + sut.process(request, null, null); + + verify(mockHttpClient, never()).execute(any(), any(HttpClientResponseHandler.class)); + assertThat(request.getFirstHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY).getValue()) + .isEqualTo("existing-token"); + } + + @Test + @SneakyThrows + void requestProceedsWithoutTokenWhenServerReturnsNoHeader( final WireMockRuntimeInfo wm ) + { + wm.getWireMock().register(head(urlEqualTo(SERVICE_ROOT)).willReturn(ok())); + + final DefaultHttpDestination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + final HttpClient realClient = new ApacheHttpClient5FactoryBuilder().build().createHttpClient(destination); + final CsrfTokenInterceptor interceptor = new CsrfTokenInterceptor(realClient); + + final HttpPost request = new HttpPost(REQUEST_PATH); + + assertThatCode(() -> interceptor.process(request, null, null)).doesNotThrowAnyException(); + assertThat(request.getFirstHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY)).isNull(); + + wm.getWireMock().verifyThat(headRequestedFor(urlEqualTo(SERVICE_ROOT))); + } + + @Test + @SneakyThrows + void requestProceedsWithoutTokenWhenHeadThrowsIOException() + { + when(mockHttpClient.execute(any(HttpHead.class), any(HttpClientResponseHandler.class))) + .thenThrow(new IOException("Connection refused")); + + final HttpPost request = new HttpPost(REQUEST_PATH); + + assertThatCode(() -> sut.process(request, null, null)).doesNotThrowAnyException(); + assertThat(request.getFirstHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY)).isNull(); + } + + @Test + @SneakyThrows + void tokenIsFetchedViaRealHttpClientWithWireMock( final WireMockRuntimeInfo wm ) + { + wm + .getWireMock() + .register( + head(urlEqualTo(SERVICE_ROOT)) + .willReturn(ok().withHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY, CSRF_TOKEN))); + + final DefaultHttpDestination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + final HttpClient realClient = new ApacheHttpClient5FactoryBuilder().build().createHttpClient(destination); + final CsrfTokenInterceptor interceptor = new CsrfTokenInterceptor(realClient); + + final HttpPost request = new HttpPost(REQUEST_PATH); + interceptor.process(request, null, null); + + assertThat(request.getFirstHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY).getValue()) + .isEqualTo(CSRF_TOKEN); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/pom.xml b/datamodel/odata-client-apache-httpclient5/pom.xml new file mode 100644 index 000000000..767c1d091 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/pom.xml @@ -0,0 +1,131 @@ + + + 4.0.0 + + com.sap.cloud.sdk.datamodel + datamodel-parent + 5.31.0-SNAPSHOT + + odata-client-apache-httpclient5 + jar + Data Model - OData Services - Client (HttpClient 5) + OData Client for OData 2.0 and 4.0 services using Apache HttpClient 5 + https://sap.github.io/cloud-sdk/docs/java/getting-started + + SAP SE + https://www.sap.com + + + + The Apache Software License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + SAP + cloudsdk@sap.com + SAP SE + https://www.sap.com + + + + + + com.sap.cloud.sdk.datamodel + fluent-result + + + com.sap.cloud.sdk.cloudplatform + connectivity-apache-httpclient5 + + + io.vavr + vavr + + + com.google.guava + guava + + + org.apache.httpcomponents.client5 + httpclient5 + + + org.apache.httpcomponents.core5 + httpcore5 + + + org.slf4j + slf4j-api + + + com.google.code.gson + gson + + + org.apache.commons + commons-lang3 + + + + org.projectlombok + lombok + provided + + + + com.sap.cloud.sdk.cloudplatform + cloudplatform-core + test + + + com.sap.cloud.sdk.cloudplatform + cloudplatform-connectivity + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.assertj + assertj-core + test + + + org.wiremock + wiremock + test + + + org.mockito + mockito-core + test + + + com.fasterxml.jackson.core + jackson-annotations + test + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + commons-beanutils:commons-beanutils + + + + + + diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/JsonLookup.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/JsonLookup.java new file mode 100644 index 000000000..2cd891da0 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/JsonLookup.java @@ -0,0 +1,48 @@ +package com.sap.cloud.sdk.datamodel.odata.client; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import javax.annotation.Nonnull; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Value; + +/** + * An ordered collection of {@link JsonPath}. + */ +@Getter +@Value +@RequiredArgsConstructor( access = AccessLevel.PRIVATE ) +public class JsonLookup +{ + @Nonnull + List paths; + + /** + * Static factory method for {@link JsonLookup}. + * + * @param paths + * An ordered enumeration of {@link JsonPath}, that is used for JSON lookup. + * @return A new instance. + */ + @Nonnull + public static JsonLookup of( @Nonnull final JsonPath... paths ) + { + return new JsonLookup(Arrays.asList(paths)); + } + + /** + * Static factory method for an empty {@link JsonLookup}. + * + * @return A new instance. + */ + @Nonnull + public static JsonLookup empty() + { + return new JsonLookup(Collections.emptyList()); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/JsonPath.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/JsonPath.java new file mode 100644 index 000000000..126cd6004 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/JsonPath.java @@ -0,0 +1,48 @@ +package com.sap.cloud.sdk.datamodel.odata.client; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import javax.annotation.Nonnull; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.Value; + +/** + * A simple JSON Path. + */ +@RequiredArgsConstructor( access = AccessLevel.PRIVATE ) +@Value +public class JsonPath +{ + static String WILDCARD = "*"; + + @Nonnull + List nodes; + + /** + * Static factory method for {@link JsonPath}. + * + * @param nodes + * An ordered enumeration of node names to form a JSON path. + * @return A new instance. + */ + @Nonnull + public static JsonPath of( @Nonnull final String... nodes ) + { + return new JsonPath(Arrays.asList(nodes)); + } + + /** + * Static factory method for an empty {@link JsonPath}. Pointing at the root JSON tree. + * + * @return A new instance. + */ + @Nonnull + public static JsonPath ofRoot() + { + return new JsonPath(Collections.emptyList()); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/ODataLiteralSerializer.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/ODataLiteralSerializer.java new file mode 100644 index 000000000..f11eb9c8b --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/ODataLiteralSerializer.java @@ -0,0 +1,60 @@ +package com.sap.cloud.sdk.datamodel.odata.client; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.util.UUID; +import java.util.function.Function; + +import javax.annotation.Nonnull; + +/** + * Descriptor for protocol specific information on serializing type literals for filter expressions and entity keys. + */ +public interface ODataLiteralSerializer +{ + /** + * Returns a function to convert a given {@link Number} into a {@link String} that conforms with the protocol + * specification. + * + * @return A serializer for {@link Number}s. + */ + @Nonnull + Function getNumberSerializer(); + + /** + * Returns a function to convert a given {@link UUID} into a {@link String} that conforms with the protocol + * specification. + * + * @return A serializer for {@link UUID}s. + */ + @Nonnull + Function getUUIDSerializer(); + + /** + * Returns a function to convert a given {@link OffsetDateTime} into a {@link String} that conforms with the + * protocol specification. + * + * @return A serializer for {@link OffsetDateTime}s. + */ + @Nonnull + Function getDateTimeOffsetSerializer(); + + /** + * Returns a function to convert a given {@link LocalTime} into a {@link String} that conforms with the protocol + * specification. + * + * @return A serializer for {@link LocalTime}s. + */ + @Nonnull + Function getTimeOfDaySerializer(); + + /** + * Returns a function to convert a given {@link LocalDateTime} into a {@link String} that conforms with the protocol + * specification. + * + * @return A serializer for {@link LocalDateTime}s. + */ + @Nonnull + Function getDateTimeSerializer(); +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/ODataProtocol.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/ODataProtocol.java new file mode 100644 index 000000000..f540596ec --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/ODataProtocol.java @@ -0,0 +1,206 @@ +package com.sap.cloud.sdk.datamodel.odata.client; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.ChronoField; +import java.util.AbstractMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; + +import javax.annotation.Nonnull; + +import lombok.Getter; + +/** + * The {@code ODataProtocol} defines all necessary information that is needed in order to differentiate between + * different OData protocol versions. + */ +public interface ODataProtocol extends ODataResponseDescriptor, ODataLiteralSerializer +{ + /** + * Version 2.0 of the OData protocol. + */ + ODataProtocol V2 = new ODataProtocolV2(); + + /** + * Version 4.0 of the OData protocol. + */ + ODataProtocol V4 = new ODataProtocolV4(); + + /** + * The version number of this protocol. + * + * @return A string representing the OData version, e.g. "4.0" + */ + @Nonnull + String getProtocolVersion(); + + /** + * Build the (inline) count query option for this protocol version. + * + * @param optionEnabled + * Determines the value of the query option. + * @return An entry to add to the URL query. + */ + @Nonnull + Map.Entry getQueryOptionInlineCount( boolean optionEnabled ); + + /** + * OData protocol v2. + */ + final class ODataProtocolV2 implements ODataProtocol + { + private static final DateTimeFormatter EDM_DATE_TIME_FORMATTER = + new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .append(DateTimeFormatter.ISO_LOCAL_DATE) + .appendLiteral('T') + .append( + new DateTimeFormatterBuilder() + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendLiteral(':') + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .optionalStart() + .appendLiteral(':') + .appendValue(ChronoField.SECOND_OF_MINUTE, 2) + .optionalStart() + .appendFraction(ChronoField.NANO_OF_SECOND, 0, 7, true) // <-- the OData V2 protocol defines that the nanoseconds must only contain at maximum 7 digits + .toFormatter()) + .toFormatter(); + + @Getter + private final String protocolVersion = "2.0"; + @Getter + private final JsonLookup pathToResultSet = JsonLookup.of(JsonPath.of("d", "results")); + @Getter + private final JsonLookup pathToResultSingle = JsonLookup.of(JsonPath.of("d")); + // The * is the function import name, but not passed through since it does not really matter. + @Getter + private final JsonLookup pathToResultPrimitive = JsonLookup.of(JsonPath.of("d", JsonPath.WILDCARD)); + @Getter + private final JsonLookup pathToInlineCount = JsonLookup.of(JsonPath.of("__count")); + @Getter + private final JsonLookup pathToNextLink = JsonLookup.of(JsonPath.of("__next")); + @Getter + private final JsonLookup pathToDeltaLink = JsonLookup.empty(); + + @Getter + private final Function numberSerializer = ODataProtocolV2::numberToString; + @Getter + private final Function UUIDSerializer = v -> String.format("guid'%s'", v); + @Getter + private final Function dateTimeOffsetSerializer = + v -> String.format("datetimeoffset'%s'", v.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + @Getter + private final Function timeOfDaySerializer = + v -> String.format("time'%s'", Duration.ofNanos(v.toNanoOfDay())); + @Getter + private final Function dateTimeSerializer = + v -> String.format("datetime'%s'", v.format(EDM_DATE_TIME_FORMATTER)); + + private static String numberToString( final Number n ) + { + if( n instanceof Integer ) { + return Integer.toString(n.intValue()); + } else if( n instanceof Short ) { + return Short.toString(n.shortValue()); + } else if( n instanceof Byte ) { + return Byte.toString(n.byteValue()); + } else if( n instanceof Long ) { + return n.longValue() + "L"; + } else if( n instanceof Float ) { + return n.floatValue() + "f"; + } else if( n instanceof Double ) { + return n.doubleValue() + "d"; + } else if( n instanceof BigDecimal ) { + return ((BigDecimal) n).toPlainString() + "M"; + } + final String message = + "Unrecognized number type: %s. Should be one of: Integer, Long, Float, Double, BigDecimal."; + throw new IllegalStateException(String.format(message, n.getClass())); + } + + @Override + @Nonnull + public Map.Entry getQueryOptionInlineCount( final boolean optionEnabled ) + { + return new AbstractMap.SimpleEntry<>("$inlinecount", optionEnabled ? "allpages" : "none"); + } + + @Override + @Nonnull + public String toString() + { + return "OData " + protocolVersion; + } + } + + /** + * OData protocol v4. + */ + final class ODataProtocolV4 implements ODataProtocol + { + @Getter + private final String protocolVersion = "4.0"; + @Getter + private final JsonLookup pathToResultSet = JsonLookup.of(JsonPath.of("value")); + @Getter + private final JsonLookup pathToResultSingle = JsonLookup.of(JsonPath.ofRoot()); + @Getter + private final JsonLookup pathToResultPrimitive = JsonLookup.of(JsonPath.of("value")); + @Getter + private final JsonLookup pathToInlineCount = JsonLookup.of(JsonPath.of("@odata.count"), JsonPath.of("@count")); + @Getter + private final JsonLookup pathToNextLink = + JsonLookup.of(JsonPath.of("@odata.nextLink"), JsonPath.of("@nextLink")); + @Getter + private final JsonLookup pathToDeltaLink = + JsonLookup.of(JsonPath.of("@odata.deltaLink"), JsonPath.of("@deltaLink")); + + @Getter + private final Function numberSerializer = Number::toString; + @Getter + private final Function UUIDSerializer = UUID::toString; + @Getter + private final Function dateTimeOffsetSerializer = + v -> v.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + @Getter + private final Function timeOfDaySerializer = v -> v.format(DateTimeFormatter.ISO_LOCAL_TIME); + @Getter + private final Function dateTimeSerializer = + v -> dateTimeOffsetSerializer.apply(v.atOffset(ZoneOffset.UTC)); + + @Override + @Nonnull + public Map.Entry getQueryOptionInlineCount( final boolean optionEnabled ) + { + return new AbstractMap.SimpleEntry<>("$count", optionEnabled ? "true" : "false"); + } + + @Override + @Nonnull + public String toString() + { + return "OData " + protocolVersion; + } + } + + /** + * Compares this protocol with the given protocol based on their version identifiers. + * + * @param otherProtocol + * The protocol to compare to. + * @return True, if the protocols resemble the same OData version. + */ + default boolean isEqualTo( @Nonnull final ODataProtocol otherProtocol ) + { + return otherProtocol.getProtocolVersion().equals(getProtocolVersion()); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/ODataResponseDescriptor.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/ODataResponseDescriptor.java new file mode 100644 index 000000000..7db888cff --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/ODataResponseDescriptor.java @@ -0,0 +1,59 @@ +package com.sap.cloud.sdk.datamodel.odata.client; + +import javax.annotation.Nonnull; + +/** + * Descriptor for protocol specific information on deserializing OData responses. + */ +public interface ODataResponseDescriptor +{ + /** + * The JSON path(s) to a set of result elements. The last element of this array refers to the result array. + * + * @return The path(s) to result set. + */ + @Nonnull + JsonLookup getPathToResultSet(); + + /** + * The JSON path(s) to a result object. The last element of this array refers to the result object. + * + * @return The path(s) to single result. + */ + @Nonnull + JsonLookup getPathToResultSingle(); + + /** + * The JSON path(s) to a result primitive. The last element of this array refers to the result value. + * + * @return The path(s) to primitive result. + */ + @Nonnull + JsonLookup getPathToResultPrimitive(); + + /** + * The JSON path(s) to an inline count. The last element of this array refers to the result value. + * + * @return The path(s) to inline count value. + */ + @Nonnull + JsonLookup getPathToInlineCount(); + + /** + * The JSON path(s) to the next link of a multi page response. The last element of this array refers to the result + * value. + * + * @return The path(s) to inline count value. + */ + @Nonnull + JsonLookup getPathToNextLink(); + + /** + * The JSON path(s) to the delta link of a versioned response. The last element of this array refers to the result + * value. + * + * @return The path(s) to inline count value. + */ + @Nonnull + JsonLookup getPathToDeltaLink(); +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/ODataResponseDeserializer.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/ODataResponseDeserializer.java new file mode 100644 index 000000000..e7c316e43 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/ODataResponseDeserializer.java @@ -0,0 +1,146 @@ +package com.sap.cloud.sdk.datamodel.odata.client; + +import java.io.IOException; +import java.util.List; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; + +import io.vavr.control.Option; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Implementation to deserialize OData responses based on a given {@link ODataProtocol}. + */ +@Slf4j +@RequiredArgsConstructor +public class ODataResponseDeserializer +{ + @Nonnull + private final ODataProtocol protocol; + + /** + * Position the {@see JsonReader} to the response result set. + * + * @param reader + * The internal JsonReader instance. + * @throws IOException + * If response cannot be read. + */ + public void positionReaderToResultSet( @Nonnull final JsonReader reader ) + throws IOException + { + final List nodes = protocol.getPathToResultSet().getPaths().get(0).getNodes(); + reader.beginObject(); + for( int i = 0; i < nodes.size(); i++ ) { + while( reader.peek() == JsonToken.NAME && !nodes.get(i).equals(reader.nextName()) ) { + JsonParser.parseReader(reader); + } + if( i < nodes.size() - 1 ) { + reader.beginObject(); + } + } + reader.beginArray(); + } + + /** + * Get the element to the response result set. + * + * @param element + * The root element. + * @return The optional result as JsonArray. + */ + @Nonnull + public Option getElementToResultSet( @Nonnull final JsonElement element ) + { + final JsonElement resultElement = getResultJsonElement(element, protocol.getPathToResultSet()); + return Option.of(resultElement).filter(JsonElement::isJsonArray).map(JsonElement::getAsJsonArray); + } + + /** + * Get the element to the single response result item. + * + * @param element + * The root element. + * @return The optional result as JsonObject. + */ + @Nonnull + public Option getElementToResultSingle( @Nonnull final JsonElement element ) + { + final JsonElement resultElement = getResultJsonElement(element, protocol.getPathToResultSingle()); + return Option.of(resultElement).filter(JsonElement::isJsonObject).map(JsonElement::getAsJsonObject); + } + + /** + * Get the element to the response result set. + * + * @param element + * The root element. + * @return The optional result as JsonArray. + */ + @Nonnull + public Option getElementToResultPrimitiveSet( @Nonnull final JsonElement element ) + { + final JsonElement resultElement = getResultJsonElement(element, protocol.getPathToResultPrimitive()); + return Option.of(resultElement).filter(JsonElement::isJsonArray).map(JsonElement::getAsJsonArray); + } + + /** + * Get the element to the single response result item. + * + * @param element + * The root element. + * @return The optional result as JsonPrimitive. + */ + @Nonnull + public Option getElementToResultPrimitiveSingle( @Nonnull final JsonElement element ) + { + final JsonElement resultElement = getResultJsonElement(element, protocol.getPathToResultPrimitive()); + return Option.of(resultElement).filter(JsonElement::isJsonPrimitive).map(JsonElement::getAsJsonPrimitive); + } + + @Nullable + private JsonElement getResultJsonElement( @Nonnull final JsonElement element, @Nonnull final JsonLookup lookup ) + { + for( final JsonPath path : lookup.getPaths() ) { + final JsonElement result = getResultJsonElement(element, path); + if( result != null ) { + return result; + } + } + return null; + } + + @Nullable + private JsonElement getResultJsonElement( @Nonnull final JsonElement element, @Nonnull final JsonPath path ) + { + final List nodes = path.getNodes(); + JsonElement resultElement = element; + for( int i = 0; i < nodes.size(); i++ ) { + if( resultElement == null || !resultElement.isJsonObject() ) { + log.warn("JSON path {} could not be resolved for {} at position {}.", path, resultElement, i); + return null; + } + if( nodes.get(i).equals("*") ) { + if( !resultElement.getAsJsonObject().entrySet().isEmpty() ) { + resultElement = resultElement.getAsJsonObject().entrySet().iterator().next().getValue(); + } else { + log.warn("Wildcard in JSON path {} did not match anything for {}.", path, resultElement); + return null; + } + } else { + resultElement = resultElement.getAsJsonObject().get(nodes.get(i)); + } + } + return resultElement; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/BigDecimalAdapter.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/BigDecimalAdapter.java new file mode 100644 index 000000000..170bffc30 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/BigDecimalAdapter.java @@ -0,0 +1,47 @@ +package com.sap.cloud.sdk.datamodel.odata.client.adapter; + +import java.io.IOException; +import java.math.BigDecimal; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +/** + * GSON type adapter for parsing and serialization of {@link BigDecimal}. + */ +public class BigDecimalAdapter extends TypeAdapter +{ + /** + * For internal use only by data model classes + */ + @Override + @Nullable + public BigDecimal read( @Nonnull final JsonReader in ) + throws IOException + { + if( in.peek().equals(JsonToken.NULL) ) { + in.nextNull(); + return null; + } + + final String jsonStringValue = in.nextString(); + return new BigDecimal(jsonStringValue); + } + + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final BigDecimal entityValue ) + throws IOException + { + if( entityValue == null ) { + out.nullValue(); + } else { + out.value(entityValue.toPlainString()); + } + } + +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/BinaryTypeAdapter.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/BinaryTypeAdapter.java new file mode 100644 index 000000000..eb1347bc3 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/BinaryTypeAdapter.java @@ -0,0 +1,55 @@ +package com.sap.cloud.sdk.datamodel.odata.client.adapter; + +import java.io.IOException; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueBinary; + +import io.vavr.control.Try; +import lombok.extern.slf4j.Slf4j; + +/** + * GSON type adapter for parsing and serialization of {@code byte[]}. + */ +@Slf4j +public class BinaryTypeAdapter extends TypeAdapter +{ + /** + * For internal use only by data model classes + */ + @Override + @Nullable + public byte[] read( @Nonnull final JsonReader in ) + throws IOException + { + if( in.peek().equals(JsonToken.NULL) ) { + in.nextNull(); + return null; + } + + final String value = in.nextString(); + return Try + .of(() -> ValueBinary.DECODE_FROM_STRING.apply(value)) + .onFailure(e -> log.warn("Failed to deserialize binary value.", e)) + .getOrNull(); + } + + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final byte[] entityValue ) + throws IOException + { + if( entityValue == null ) { + out.nullValue(); + } else { + final String value = ValueBinary.ENCODE_TO_STRING.apply(entityValue); + out.value(value); + } + } + +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/DurationTypeAdapter.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/DurationTypeAdapter.java new file mode 100644 index 000000000..f84065e7a --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/DurationTypeAdapter.java @@ -0,0 +1,49 @@ +package com.sap.cloud.sdk.datamodel.odata.client.adapter; + +import java.io.IOException; +import java.time.Duration; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +import io.vavr.control.Try; + +/** + * GSON type adapter for parsing and serialization of {@link Duration}. + */ +public class DurationTypeAdapter extends TypeAdapter +{ + /** + * For internal use only by data model classes + */ + @Override + @Nullable + public Duration read( @Nonnull final JsonReader in ) + throws IOException + { + if( in.peek().equals(JsonToken.NULL) ) { + in.nextNull(); + return null; + } + + final String jsonDateValue = in.nextString(); + return Try.of(() -> Duration.parse(jsonDateValue)).getOrNull(); + } + + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final Duration entityValue ) + throws IOException + { + if( entityValue == null ) { + out.nullValue(); + } else { + out.value(entityValue.toString()); + } + } + +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/LocalDateTypeAdapter.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/LocalDateTypeAdapter.java new file mode 100644 index 000000000..d9e1ce54f --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/LocalDateTypeAdapter.java @@ -0,0 +1,49 @@ +package com.sap.cloud.sdk.datamodel.odata.client.adapter; + +import java.io.IOException; +import java.time.LocalDate; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +import io.vavr.control.Try; + +/** + * GSON type adapter for parsing and serialization of {@link LocalDate}. + */ +public class LocalDateTypeAdapter extends TypeAdapter +{ + /** + * For internal use only by data model classes + */ + @Nullable + @Override + public LocalDate read( @Nonnull final JsonReader in ) + throws IOException + { + if( in.peek().equals(JsonToken.NULL) ) { + in.nextNull(); + return null; + } + + final String jsonDateValue = in.nextString(); + return Try.of(() -> LocalDate.parse(jsonDateValue)).getOrNull(); + } + + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final LocalDate entityValue ) + throws IOException + { + if( entityValue == null ) { + out.nullValue(); + } else { + out.value(entityValue.toString()); + } + } + +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/LocalTimeTypeAdapter.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/LocalTimeTypeAdapter.java new file mode 100644 index 000000000..a7d886eb8 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/LocalTimeTypeAdapter.java @@ -0,0 +1,49 @@ +package com.sap.cloud.sdk.datamodel.odata.client.adapter; + +import java.io.IOException; +import java.time.LocalTime; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +import io.vavr.control.Try; + +/** + * GSON type adapter for parsing and serialization of {@link LocalTime}. + */ +public class LocalTimeTypeAdapter extends TypeAdapter +{ + /** + * For internal use only by data model classes + */ + @Override + @Nullable + public LocalTime read( @Nonnull final JsonReader in ) + throws IOException + { + if( in.peek().equals(JsonToken.NULL) ) { + in.nextNull(); + return null; + } + + final String jsonDateValue = in.nextString(); + return Try.of(() -> LocalTime.parse(jsonDateValue)).getOrNull(); + } + + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final LocalTime entityValue ) + throws IOException + { + if( entityValue == null ) { + out.nullValue(); + } else { + out.value(entityValue.toString()); + } + } + +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/OffsetDateTimeTypeAdapter.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/OffsetDateTimeTypeAdapter.java new file mode 100644 index 000000000..37b4b4cb1 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/OffsetDateTimeTypeAdapter.java @@ -0,0 +1,49 @@ +package com.sap.cloud.sdk.datamodel.odata.client.adapter; + +import java.io.IOException; +import java.time.OffsetDateTime; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +import io.vavr.control.Try; + +/** + * GSON type adapter for parsing and serialization of {@link OffsetDateTime}. + */ +public class OffsetDateTimeTypeAdapter extends TypeAdapter +{ + /** + * For internal use only by data model classes + */ + @Override + @Nullable + public OffsetDateTime read( @Nonnull final JsonReader in ) + throws IOException + { + if( in.peek().equals(JsonToken.NULL) ) { + in.nextNull(); + return null; + } + + final String jsonDateValue = in.nextString(); + return Try.of(() -> OffsetDateTime.parse(jsonDateValue)).getOrNull(); + } + + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final OffsetDateTime entityValue ) + throws IOException + { + if( entityValue == null ) { + out.nullValue(); + } else { + out.value(entityValue.toString()); + } + } + +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/UuidTypeAdapter.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/UuidTypeAdapter.java new file mode 100644 index 000000000..85a7c4f1f --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/adapter/UuidTypeAdapter.java @@ -0,0 +1,49 @@ +package com.sap.cloud.sdk.datamodel.odata.client.adapter; + +import java.io.IOException; +import java.util.UUID; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +import io.vavr.control.Try; + +/** + * GSON type adapter for parsing and serialization of {@link UUID}. + */ +public class UuidTypeAdapter extends TypeAdapter +{ + /** + * For internal use only by data model classes + */ + @Override + @Nullable + public UUID read( @Nonnull final JsonReader in ) + throws IOException + { + if( in.peek().equals(JsonToken.NULL) ) { + in.nextNull(); + return null; + } + + final String jsonDateValue = in.nextString(); + return Try.of(() -> UUID.fromString(jsonDateValue)).getOrNull(); + } + + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final UUID entityValue ) + throws IOException + { + if( entityValue == null ) { + out.nullValue(); + } else { + out.value(entityValue.toString()); + } + } + +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataConnectionException.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataConnectionException.java new file mode 100644 index 000000000..5a5753d3f --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataConnectionException.java @@ -0,0 +1,49 @@ +package com.sap.cloud.sdk.datamodel.odata.client.exception; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.methods.HttpUriRequest; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * OData connection exception indicating errors when trying to establish a service connection. + */ +@EqualsAndHashCode( callSuper = true ) +@Getter +public class ODataConnectionException extends ODataRequestException +{ + private static final long serialVersionUID = -1448569410628983663L; + + /** + * The {@link HttpUriRequest} that was attempted. + */ + @Nonnull + private final transient HttpUriRequest httpRequest; + + /** + * Default constructor. + * + * @param request + * The original OData request reference. + * @param httpRequest + * The original HTTP request which was sent. + * @param message + * The error message. + * @param cause + * The error cause. + */ + public ODataConnectionException( + @Nonnull final ODataRequestGeneric request, + @Nonnull final HttpUriRequest httpRequest, + @Nonnull final String message, + @Nullable final Throwable cause ) + { + super(request, message, cause); + this.httpRequest = httpRequest; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataDeserializationException.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataDeserializationException.java new file mode 100644 index 000000000..f5ac5e3ca --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataDeserializationException.java @@ -0,0 +1,41 @@ +package com.sap.cloud.sdk.datamodel.odata.client.exception; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpResponse; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; + +import lombok.EqualsAndHashCode; + +/** + * OData deserialization exception type to focus on deserialization errors when parsing the service response. + */ +@EqualsAndHashCode( callSuper = true ) +public class ODataDeserializationException extends ODataResponseException +{ + private static final long serialVersionUID = 8060933261779457372L; + + /** + * Default constructor. + * + * @param request + * The original OData request reference. + * @param httpResponse + * The {@link HttpResponse} that gave raise to this exception. + * @param message + * The error message. + * @param cause + * The error cause. + */ + public ODataDeserializationException( + @Nonnull final ODataRequestGeneric request, + @Nonnull final ClassicHttpResponse httpResponse, + @Nonnull final String message, + @Nullable final Throwable cause ) + { + super(request, httpResponse, message, cause); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataException.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataException.java new file mode 100644 index 000000000..8a804ccd6 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataException.java @@ -0,0 +1,59 @@ +package com.sap.cloud.sdk.datamodel.odata.client.exception; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * The generic OData exception.
+ * Its sub-types will be thrown in the following scenarios: + *

+ */ +@EqualsAndHashCode( callSuper = true ) +@Getter +public class ODataException extends IllegalStateException +{ + private static final long serialVersionUID = -1264994793328207269L; + + /** + * The OData request that was attempted while this exception occurred. + */ + @Nonnull + private final transient ODataRequestGeneric request; + + /** + * Default constructor. + * + * @param request + * The original OData request reference. + * @param message + * The error message. + * @param cause + * The error cause. + */ + public ODataException( + @Nonnull final ODataRequestGeneric request, + @Nonnull final String message, + @Nullable final Throwable cause ) + { + super(message, cause); + this.request = request; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataRequestException.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataRequestException.java new file mode 100644 index 000000000..d7613cfed --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataRequestException.java @@ -0,0 +1,35 @@ +package com.sap.cloud.sdk.datamodel.odata.client.exception; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; + +import lombok.EqualsAndHashCode; + +/** + * Generic OData request exception indicating errors while trying to request a service resource. + */ +@EqualsAndHashCode( callSuper = true ) +public class ODataRequestException extends ODataException +{ + private static final long serialVersionUID = 4615831202194546242L; + + /** + * Default constructor. + * + * @param request + * The original OData request reference. + * @param message + * The error message. + * @param cause + * The error cause. + */ + public ODataRequestException( + @Nonnull final ODataRequestGeneric request, + @Nonnull final String message, + @Nullable final Throwable cause ) + { + super(request, message, cause); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataResponseException.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataResponseException.java new file mode 100644 index 000000000..6d6291112 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataResponseException.java @@ -0,0 +1,80 @@ +package com.sap.cloud.sdk.datamodel.odata.client.exception; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.io.entity.EntityUtils; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; + +import io.vavr.control.Option; +import io.vavr.control.Try; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * A generic {@link ODataException} representing an erroneous service response. This exception class comprises details + * of the HTTP response. + */ +@EqualsAndHashCode( callSuper = true ) +@Slf4j +public class ODataResponseException extends ODataException +{ + private static final long serialVersionUID = 4615831202194546242L; + + /** + * The HTTP status code of the response received. + */ + @Getter + private final int httpCode; + + /** + * The HTTP headers returned with the response. + */ + @Getter + @Nonnull + private final transient Collection
httpHeaders; + + /** + * The content of the HTTP response body as plain text or null, if the response did not contain a body. + */ + @Getter + @Nonnull + private final Option httpBody; + + /** + * Default constructor. + * + * @param request + * The original OData request reference. + * @param httpResponse + * The {@link HttpResponse} that gave raise to this exception. + * @param message + * The error message. + * @param cause + * The error cause. + */ + public ODataResponseException( + @Nonnull final ODataRequestGeneric request, + @Nonnull final ClassicHttpResponse httpResponse, + @Nonnull final String message, + @Nullable final Throwable cause ) + { + super(request, message, cause); + httpCode = httpResponse.getCode(); + httpHeaders = Arrays.asList(httpResponse.getHeaders()); + httpBody = + Try + .of(() -> EntityUtils.toString(httpResponse.getEntity(), StandardCharsets.UTF_8)) + .onFailure(e -> log.debug("HTTP response could not be consumed.", e)) + .toOption(); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataSerializationException.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataSerializationException.java new file mode 100644 index 000000000..03af9f6f4 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataSerializationException.java @@ -0,0 +1,47 @@ +package com.sap.cloud.sdk.datamodel.odata.client.exception; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * OData serialization exception type to focus on serialization errors when creating the service request. + */ +@EqualsAndHashCode( callSuper = true ) +public class ODataSerializationException extends ODataRequestException +{ + private static final long serialVersionUID = -3620082691866789667L; + + /** + * The object for which serialization failed. + */ + @Nonnull + @Getter + private final transient Object nonSerializableObject; + + /** + * Default constructor. + * + * @param request + * The original OData request reference. + * @param nonSerializableObject + * The non serializable object. + * @param message + * The error message. + * @param cause + * The error cause. + */ + public ODataSerializationException( + @Nonnull final ODataRequestGeneric request, + @Nonnull final Object nonSerializableObject, + @Nonnull final String message, + @Nullable final Throwable cause ) + { + super(request, message, cause); + this.nonSerializableObject = nonSerializableObject; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataServiceError.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataServiceError.java new file mode 100644 index 000000000..1ad6914f8 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataServiceError.java @@ -0,0 +1,261 @@ +package com.sap.cloud.sdk.datamodel.odata.client.exception; + +import java.lang.reflect.Type; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.google.gson.reflect.TypeToken; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.result.ElementName; +import com.sap.cloud.sdk.result.ResultElement; +import com.sap.cloud.sdk.result.ResultObject; + +import io.vavr.control.Option; +import io.vavr.control.Try; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +/** + * OData error to serve the standard specification. + */ +@EqualsAndHashCode +@ToString +@RequiredArgsConstructor +@AllArgsConstructor +@Slf4j +public class ODataServiceError implements ODataServiceErrorDetails +{ + private static final String ERROR_DETAILS_FIELD = "errordetails"; + + @Nonnull + @Getter + @SerializedName( "code" ) + @ElementName( "code" ) + private final String oDataCode; + + @Nonnull + @Getter + @SerializedName( "message" ) + @ElementName( "message" ) + @JsonAdapter( MessageDeserializer.class ) + private final String oDataMessage; + + @Nullable + @SerializedName( "target" ) + @ElementName( "target" ) + private final String target; + + @Nullable + @SerializedName( "details" ) + @ElementName( "details" ) + @JsonAdapter( DetailsDeserializer.class ) + private List details; + + @Nullable + @Setter( AccessLevel.NONE ) + @SerializedName( "innererror" ) + @ElementName( "innererror" ) + private Map innerError; + + /** + * A list of all contained nested {@link ODataServiceErrorDetails ODataServiceErrors}. If none were found in the + * response this list is empty. In case of OData V2 this corresponds to the {@code errordetails} field of + * {@code innererror}. + * + * @return A potentially empty List of error details. + * + * @see #getInnerError() + */ + @Nonnull + public List getDetails() + { + return details != null ? details : Collections.emptyList(); + } + + /** + * The {@code innererror} field of the response as a key-value map. If this field was not present on the response + * this map will be empty. In case of OData V2 the nested field {@code errordetails} is available separately via + * {@link #getDetails()}. + * + * @return A potentially empty Map containing the contents of the {@code innererror} field. + * + * @see #getDetails() + */ + @Nonnull + public Map getInnerError() + { + return innerError != null ? innerError : Collections.emptyMap(); + } + + /** + * SDK internal method to construct an OData error from a {@link ResultObject}. + * + * @param resultObject + * The {@link ResultObject} that should be parsed. + * @param protocol + * The {@link ODataProtocol OData protocol version} that should be assumed. + * @return A new {@code ODataServiceError}. + * + * @throws UnsupportedOperationException + * if parsing the result object failed. + */ + @Nonnull + public static + ODataServiceError + fromResultObject( @Nonnull final ResultObject resultObject, @Nonnull final ODataProtocol protocol ) + throws UnsupportedOperationException + { + // OData V4 response are parsed normally by GSON + if( protocol == ODataProtocol.V4 ) { + return resultObject.as(ODataServiceError.class); + } + + // OData V2 response contains the details within the innererror field + // e.g. { "error" : { "innererror" : { "errordetails" : [...] } } } + final String preparedErrorMessage = + "Could not interpret the \"errordetails\" field of the " + + protocol + + " error as a list of OData errors. The list of details on the OData error will be empty."; + + final Option> maybeDetails = + Option + .of(resultObject.get("innererror")) + .filter(r -> r != null && r.isResultObject()) + .map(r -> r.getAsObject().get(ERROR_DETAILS_FIELD)) + .filter(r -> r != null && r.isResultCollection()) + .map(ResultElement::getAsCollection) + .flatMap( + details -> Try + .of(() -> details.asList(ODataServiceError.class)) + .onFailure(e -> log.debug(preparedErrorMessage, e)) + .toOption()); + + final ODataServiceError odataError = resultObject.as(ODataServiceError.class); + + if( maybeDetails.isEmpty() ) { + return odataError; + } + + // Move the content of errordetails into the details field if it is a list of OData errors + odataError.getInnerError().remove(ERROR_DETAILS_FIELD); + odataError.setDetails(maybeDetails.get()); + return odataError; + } + + @Nonnull + @Override + public Option getTarget() + { + return Option.of(target); + } + + // unfortunately we need this since otherwise the generics don't work + @SuppressWarnings( "unchecked" ) + private void setDetails( @Nullable final List details ) + { + this.details = (List) details; + } + + /** + * Custom adapter to deserialize an OData error "message" field. This deserializer handles the differences between + * OData V2 SAP specification and OData V4 official specification. + * + *

+ * OData V2 + *

+ * + *
+     * {
+     *   "error": {
+     *     "code": "UF0",
+     *     "message": {
+     *       "lang": "en",
+     *       "value": "Unsupported functionality"
+     *     },
+     *     ...
+     * }
+     * 
+     * 
+ *

+ * OData V4 + *

+ * + *
+     * {
+     *   "error": {
+     *     "code": "UF0",
+     *     "message": "Unsupported functionality",
+     *     ...
+     * }
+     * 
+     * 
+ */ + @SuppressWarnings( { + "PMD.NullAnnotationMissingOnPublicMethod", + "PMD.NullAnnotationMissingOnPublicMethodParameter" } ) + private static class MessageDeserializer implements JsonDeserializer + { + @Override + @Nullable + public String deserialize( + @Nonnull final JsonElement json, + @Nonnull final Type typeOfT, + @Nonnull final JsonDeserializationContext context ) + throws JsonParseException + { + if( json.isJsonPrimitive() ) { + return json.getAsString(); + } + if( json.isJsonObject() ) { + final JsonElement value = json.getAsJsonObject().get("value"); + if( value != null && value.isJsonPrimitive() ) { + return value.getAsString(); + } + log.warn("Unable to deserialize error value from \"message\": {}", value); + } else { + log.warn("Unable to deserialize a \"message\" value from JSON value: {}", json); + } + return null; + } + } + + /** + * Custom adapter to deserialize the {@code details} field for OData V4 responses. + */ + @SuppressWarnings( { + "PMD.NullAnnotationMissingOnPublicMethod", + "PMD.NullAnnotationMissingOnPublicMethodParameter" } ) + private static class DetailsDeserializer implements JsonDeserializer> + { + private static final Gson gson = new Gson(); + private static final Type listType = new TypeToken>() + { + }.getType(); + + @Override + public + List + deserialize( final JsonElement json, final Type typeOfT, final JsonDeserializationContext context ) + throws JsonParseException + { + return json.isJsonNull() ? Collections.emptyList() : gson.fromJson(json, listType); + } + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataServiceErrorDetails.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataServiceErrorDetails.java new file mode 100644 index 000000000..1300a16ca --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataServiceErrorDetails.java @@ -0,0 +1,36 @@ +package com.sap.cloud.sdk.datamodel.odata.client.exception; + +import javax.annotation.Nonnull; + +import io.vavr.control.Option; + +/** + * Interface that resembles which information OData errors must contain and which information is optional. + */ +public interface ODataServiceErrorDetails +{ + /** + * Language independent OData error response code. + * + * @return The OData error code. + */ + @Nonnull + String getODataCode(); + + /** + * Language dependent OData error message. The language used is reflected by the "Content-Language" header in the + * HTTP response. + * + * @return The OData error message. + */ + @Nonnull + String getODataMessage(); + + /** + * Optional OData service specific hint for origin of the error. + * + * @return An {@link Option optional} target. + */ + @Nonnull + Option getTarget(); +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataServiceErrorException.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataServiceErrorException.java new file mode 100644 index 000000000..058e05c4c --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataServiceErrorException.java @@ -0,0 +1,53 @@ +package com.sap.cloud.sdk.datamodel.odata.client.exception; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.core5.http.ClassicHttpResponse; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * An {@link ODataException} representing an erroneous response from the service where the payload contained detailed + * OData error information. + */ +@EqualsAndHashCode( callSuper = true ) +@Getter +public class ODataServiceErrorException extends ODataResponseException +{ + private static final long serialVersionUID = 8060933261779457372L; + + /** + * The parsed {@link ODataServiceError} that was found in the HTTP response body. + */ + @Nonnull + private final transient ODataServiceError odataError; + + /** + * Default constructor. + * + * @param request + * The original OData request reference. + * @param httpResponse + * The failing HTTP response reference. + * @param message + * The error message. + * @param cause + * The error cause, if any. + * @param odataError + * The parsed {@link ODataServiceError odata error} contained in the HTTP response. + */ + public ODataServiceErrorException( + @Nonnull final ODataRequestGeneric request, + @Nonnull final ClassicHttpResponse httpResponse, + @Nonnull final String message, + @Nullable final Throwable cause, + @Nonnull final ODataServiceError odataError ) + { + super(request, httpResponse, message, cause); + this.odataError = odataError; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/Expressions.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/Expressions.java new file mode 100644 index 000000000..c0cfc497b --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/Expressions.java @@ -0,0 +1,337 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.time.temporal.TemporalAccessor; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.common.collect.Lists; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Wrapper class for expression types. The types listed here are used to differentiate input and output parameters of + * functions used in expressions of OData filters. + */ +public class Expressions +{ + @RequiredArgsConstructor + private static class DefaultFilterExpression implements FilterExpression + { + @Nonnull + private final String format; + + @Nonnull + @Getter + private final String operator; + + @Nonnull + @Getter + private final List operands; + + @Nonnull + @Override + public String getExpression( + @Nonnull final ODataProtocol protocol, + @Nonnull final Map> prefixes ) + { + final List parts = + getOperands().stream().map(o -> o.getExpression(protocol, prefixes)).collect(Collectors.toList()); + parts.add(0, getOperator()); + return String.format(format, parts.toArray()); + } + } + + /** + * Filter function with a single parameter. Prefix notation without parentheses and a whitespace between operator + * and operand. + * + * @param operator + * The function operator. + * @param operand + * The operand of the function. + * @return The FilterExpression. + */ + static FilterExpression createOperatorPrefix( @Nonnull final String operator, @Nonnull final Operand operand ) + { + return new DefaultFilterExpression("(%s %s)", operator, Collections.singletonList(operand)); + } + + /** + * Filter function without parameter. Prefix notation. + * + * @param operator + * The function operator. + * @return The FilterExpression. + */ + static FilterExpression createFunctionPrefix( @Nonnull final String operator ) + { + return new DefaultFilterExpression("%s()", operator, Collections.emptyList()); + } + + /** + * Filter function with singular parameter. Prefix notation. + * + * @param operator + * The function operator. + * @param operand + * The first operand of the function. + * @return The FilterExpression. + */ + static FilterExpression createFunctionPrefix( @Nonnull final String operator, @Nonnull final Operand operand ) + { + final List operands = Lists.newArrayList(operand); + return new DefaultFilterExpression("%s(%s)", operator, operands); + } + + /** + * Filter function with two parameters. Prefix notation. + * + * @param operator + * The function operator. + * @param operand1 + * The first operand of the function. + * @param operand2 + * The second operand of the function. + * @return The FilterExpression. + */ + static FilterExpression createFunctionPrefix( + @Nonnull final String operator, + @Nonnull final Operand operand1, + @Nonnull final Operand operand2 ) + { + final List operands = Lists.newArrayList(operand1, operand2); + return new DefaultFilterExpression("%s(%s,%s)", operator, operands); + } + + /** + * Filter function with two parameters. Prefix notation. + * + * @param operator + * The function operator. + * @param operand1 + * The first operand of the function. + * @param operand2 + * The second operand of the function. + * @param operand3 + * The third operand of the function. + * @return The FilterExpression. + */ + static FilterExpression createFunctionPrefix( + @Nonnull final String operator, + @Nonnull final Operand operand1, + @Nonnull final Operand operand2, + @Nonnull final Operand operand3 ) + { + final List operands = Lists.newArrayList(operand1, operand2, operand3); + return new DefaultFilterExpression("%s(%s,%s,%s)", operator, operands); + } + + /** + * Filter function with two parameters. Infix notation. + * + * @param operator + * The function operator. + * @param operand1 + * The first operand of the function. + * @param operand2 + * The second operand of the function. + * @return The FilterExpression. + */ + static FilterExpression createFunctionInfix( + @Nonnull final String operator, + @Nonnull final Operand operand1, + @Nonnull final Operand operand2 ) + { + final List operands = Lists.newArrayList(operand1, operand2); + return new DefaultFilterExpression("(%2$s %1$s %3$s)", operator, operands); + } + + /** + * Filter function with two parameters. Infix notation. + * + * @param operator + * The function operator. + * @param operand1 + * The first operand of the function. + * @return The FilterExpression. + */ + static + FilterExpression + createFunctionLambda( @Nonnull final String operator, @Nonnull final OperandMultiple operand1 ) + { + final List operands = Lists.newArrayList(operand1); + return new DefaultFilterExpression("%2$s/%1$s()", operator, operands); + } + + /** + * Filter function with two parameters. Infix notation. + * + * @param operator + * The function operator. + * @param operand1 + * The first operand of the function. + * @param operand2 + * The second operand of the function. + * @param lambdaFieldPredicate + * The predicate for which fields will be given a prefix. + * @return The FilterExpression. + */ + static FilterExpression createFunctionLambda( + @Nonnull final String operator, + @Nonnull final OperandMultiple operand1, + @Nonnull final Operand operand2, + @Nonnull final Predicate lambdaFieldPredicate ) + { + final String format = "%2$s/%1$s(%3$s)"; + + final Operand operandLambda = new Operand() + { + /** + * {@inheritDoc} + */ + @Nonnull + @Override + public String getExpression( + @Nonnull final ODataProtocol protocol, + @Nonnull final Map> prefixes ) + { + final String lambdaFieldPrefix = "" + (char) ('a' + prefixes.size()); + final Map> prefx = new LinkedHashMap<>(); + prefx.put(lambdaFieldPrefix, lambdaFieldPredicate); // prepend prefix + prefx.putAll(prefixes); + return lambdaFieldPrefix + ":" + operand2.getExpression(protocol, prefx); + } + }; + + final List operands = Lists.newArrayList(operand1, operandLambda); + return new DefaultFilterExpression(format, operator, operands); + } + + /** + * Generic OData filter expression operand. + */ + @FunctionalInterface + public interface Operand + { + /** + * The null operand, representing the absence of any value. + */ + Operand NULL = ( protocol, prefixes ) -> "null"; + + /** + * Create the String representation of the expression based on a given {@link ODataProtocol}. + * + * @param protocol + * The {@link ODataProtocol} that the expression should conform to. + * + * @return The expression String. + */ + @Nonnull + default String getExpression( @Nonnull final ODataProtocol protocol ) + { + return getExpression(protocol, Collections.emptyMap()); + } + + /** + * Create the String representation of the expression. + * + * @param protocol + * The OData protocol to derive serialization rules from. + * @param prefixes + * Additional field prefixes, e.g. when using lambda expressions. + * @return The expression String. + */ + @Nonnull + String getExpression( + @Nonnull final ODataProtocol protocol, + @Nonnull final Map> prefixes ); + } + + /** + * Singular OData filter expression operand. + */ + public interface OperandSingle extends Operand + { + + } + + /** + * OData filter collection expression operand. + */ + public interface OperandMultiple extends Operand + { + + } + + /** + * Helper function to generate an OData filter expression operand for a primitive Java type. + * + * @param value + * Java literal. + * @param + * Type of the Java literal. + * @return The OData filter expression operand representation of the Java literal. + * @throws IllegalArgumentException + * When there is no mapping found for the provided Java literal. + */ + @Nonnull + public static OperandSingle createOperand( @Nullable final PrimitiveT value ) + { + if( value == null ) { + return ( protocol, prefixes ) -> "null"; + } + if( value instanceof OperandSingle ) { + return (OperandSingle) value; + } + if( value instanceof String ) { + return ValueString.literal((String) value); + } + if( value instanceof Boolean ) { + return ValueBoolean.literal((Boolean) value); + } + if( value instanceof Number ) { + return ValueNumeric.literal((Number) value); + } + if( value instanceof Duration ) { + return ValueDuration.literal((Duration) value); + } + if( value instanceof LocalDateTime ) { + return ValueDateTime.literal((LocalDateTime) value); + } + if( value instanceof OffsetDateTime ) { + return ValueDateTimeOffset.literal((OffsetDateTime) value); + } + if( value instanceof ZonedDateTime ) { + return ValueDateTimeOffset.literal(OffsetDateTime.from((TemporalAccessor) value)); + } + if( value instanceof LocalDate ) { + return ValueDate.literal((LocalDate) value); + } + if( value instanceof LocalTime ) { + return ValueTimeOfDay.literal((LocalTime) value); + } + if( value instanceof UUID ) { + return ValueGuid.literal((UUID) value); + } + if( value instanceof byte[] ) { + return ValueBinary.literal((byte[]) value); + } + throw new IllegalArgumentException("Unable to create filter expression for value " + value); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FieldReference.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FieldReference.java new file mode 100644 index 000000000..c1777a1a8 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FieldReference.java @@ -0,0 +1,66 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.util.Map; +import java.util.Optional; +import java.util.function.Predicate; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; + +/** + * OData filter expression operand for a generic entity field reference. + */ +public interface FieldReference extends Expressions.OperandSingle +{ + /** + * Static factory method to easily instantiate a generic field reference. + * + * @param fieldName + * The field name. + * @return The newly created instance. + */ + @Nonnull + static FieldUntyped of( @Nonnull final String fieldName ) + { + return () -> fieldName; + } + + /** + * Static factory method to easily instantiate a nested field reference via a path of fields. + * + * @param fieldNames + * The field name(s) identifying the field. + * @return The newly created instance. + */ + @Nonnull + static FieldUntyped ofPath( @Nonnull final String... fieldNames ) + { + final String fieldIdentifier = String.join("/", fieldNames); + return () -> fieldIdentifier; + } + + /** + * javadoc + * + * @return The field name this reference points towards. + */ + @Nonnull + String getFieldName(); + + @Nonnull + @Override + default String getExpression( + @Nonnull final ODataProtocol protocol, + @Nonnull final Map> prefixes ) + { + String result = getFieldName(); + final Optional prefix = + prefixes.entrySet().stream().filter(e -> e.getValue().test(this)).map(Map.Entry::getKey).findFirst(); + + if( prefix.isPresent() ) { + result = prefix.get() + "/" + result; + } + return result; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FieldUntyped.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FieldUntyped.java new file mode 100644 index 000000000..6ed22e0d6 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FieldUntyped.java @@ -0,0 +1,108 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import javax.annotation.Nonnull; + +/** + * OData filter expression operand for an untyped entity field reference. + */ +public interface FieldUntyped extends FieldReference, FilterableComparisonAbsolute, FilterableComparisonRelative +{ + /** + * Cast the field reference to a string value, enabling type-safe expressions. + * + * @return The string flavored field reference. + */ + @Nonnull + default ValueString asString() + { + return this::getExpression; + } + + /** + * Cast the field reference to a numeric value, enabling type-safe expressions. + * + * @return The numeric flavored field reference. + */ + @Nonnull + default ValueNumeric asNumber() + { + return this::getExpression; + } + + /** + * Cast the field reference to a boolean value, enabling type-safe expressions. + * + * @return The boolean flavored field reference. + */ + @Nonnull + default ValueBoolean asBoolean() + { + return this::getExpression; + } + + /** + * Cast the field reference to a binary value, enabling type-safe expressions. + * + * @return The binary flavored field reference. + */ + @Nonnull + default ValueBinary asBinary() + { + return this::getExpression; + } + + /** + * Cast the field reference to a duration value, enabling type-safe expressions. + * + * @return The duration flavored field reference. + */ + @Nonnull + default ValueDuration asDuration() + { + return this::getExpression; + } + + /** + * Cast the field reference to a time-of-day value, enabling type-safe expressions. + * + * @return The time-of-day flavored field reference. + */ + @Nonnull + default ValueTimeOfDay asTimeOfDay() + { + return this::getExpression; + } + + /** + * Cast the field reference to an offset-date-time value, enabling type-safe expressions. + * + * @return The offset-date-time flavored field reference. + */ + @Nonnull + default ValueDateTimeOffset asDateTimeOffset() + { + return this::getExpression; + } + + /** + * Cast the field reference to a date value, enabling type-safe expressions. + * + * @return The date flavored field reference. + */ + @Nonnull + default ValueDate asDate() + { + return this::getExpression; + } + + /** + * Cast the field reference to a collection value, enabling type-safe expressions. + * + * @return The collection flavored field reference. + */ + @Nonnull + default ValueCollection asCollection() + { + return this::getExpression; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpression.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpression.java new file mode 100644 index 000000000..ef8191a43 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpression.java @@ -0,0 +1,27 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.util.List; + +import javax.annotation.Nonnull; + +/** + * Generic interface to describe an OData filter expression. + */ +public interface FilterExpression extends Expressions.Operand +{ + /** + * String representation of the OData filter expression operator. + * + * @return The operator. + */ + @Nonnull + String getOperator(); + + /** + * List of the operands used for the OData filter expression. + * + * @return The operands. + */ + @Nonnull + List getOperands(); +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionArithmetic.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionArithmetic.java new file mode 100644 index 000000000..bb5dd60f1 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionArithmetic.java @@ -0,0 +1,336 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import javax.annotation.Nonnull; + +/** + * Set of OData filter functions for arithmetic types. + */ +@SuppressWarnings( "overloads" ) +public interface FilterExpressionArithmetic +{ + /** + * Addition expression for numbers. + * + * @param operand1 + * The first, numeric operand. + * @param operand2 + * The second, numeric operand. + * @return A new numeric expression. + */ + @Nonnull + static ValueNumeric.Expression add( @Nonnull final ValueNumeric operand1, @Nonnull final ValueNumeric operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("add", operand1, operand2); + return new ValueNumeric.Expression(expression); + } + + /** + * Addition expression for date time and duration. + * + * @param operand1 + * The first, date-time-offset operand. + * @param operand2 + * The second, duration operand. + * @return A new date-time-offset expression. + */ + @Nonnull + static + ValueDateTimeOffset.Expression + add( @Nonnull final ValueDateTimeOffset operand1, @Nonnull final ValueDuration operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("add", operand1, operand2); + return new ValueDateTimeOffset.Expression(expression); + } + + /** + * Addition expression for duration and duration. + * + * @param operand1 + * The first, duration operand. + * @param operand2 + * The second, duration operand. + * @return A new duration expression. + */ + @Nonnull + static ValueDuration.Expression add( @Nonnull final ValueDuration operand1, @Nonnull final ValueDuration operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("add", operand1, operand2); + return new ValueDuration.Expression(expression); + } + + /** + * Addition expression for date and duration. + * + * @param operand1 + * The first, date operand. + * @param operand2 + * The second, duration operand. + * @return A new date expression. + */ + @Nonnull + static ValueDate.Expression add( @Nonnull final ValueDate operand1, @Nonnull final ValueDuration operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("add", operand1, operand2); + return new ValueDate.Expression(expression); + } + + /** + * Subtraction expression for numbers. + * + * @param operand1 + * The first, numeric operand. + * @param operand2 + * The second, numeric operand. + * @return A new numeric expression. + */ + @Nonnull + static + ValueNumeric.Expression + subtract( @Nonnull final ValueNumeric operand1, @Nonnull final ValueNumeric operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("sub", operand1, operand2); + return new ValueNumeric.Expression(expression); + } + + /** + * Subtraction expression for date time and duration. + * + * @param operand1 + * The first, date-time-offset operand. + * @param operand2 + * The second, duration operand. + * @return A new date-time-offset expression. + */ + @Nonnull + static + ValueDateTimeOffset.Expression + subtract( @Nonnull final ValueDateTimeOffset operand1, @Nonnull final ValueDuration operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("sub", operand1, operand2); + return new ValueDateTimeOffset.Expression(expression); + } + + /** + * Subtraction expression for duration and duration. + * + * @param operand1 + * The first, duration operand. + * @param operand2 + * The second, duration operand. + * @return A new duration expression. + */ + @Nonnull + static + ValueDuration.Expression + subtract( @Nonnull final ValueDuration operand1, @Nonnull final ValueDuration operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("sub", operand1, operand2); + return new ValueDuration.Expression(expression); + } + + /** + * Subtraction expression for date and duration. + * + * @param operand1 + * The first, date operand. + * @param operand2 + * The second, duration operand. + * @return A new date expression. + */ + @Nonnull + static ValueDate.Expression subtract( @Nonnull final ValueDate operand1, @Nonnull final ValueDuration operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("sub", operand1, operand2); + return new ValueDate.Expression(expression); + } + + /** + * Subtraction expression for date and date. + * + * @param operand1 + * The first, date operand. + * @param operand2 + * The second, date operand. + * @return A new duration expression. + */ + @Nonnull + static ValueDuration.Expression subtract( @Nonnull final ValueDate operand1, @Nonnull final ValueDate operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("sub", operand1, operand2); + return new ValueDuration.Expression(expression); + } + + /** + * Negation expression for numbers. + * + * @param operand + * The first, numeric operand. + * @return A new numeric expression. + */ + @Nonnull + static ValueNumeric.Expression negate( @Nonnull final ValueNumeric operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("-", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Negation expression for duration. + * + * @param operand + * The first, duration operand. + * @return A new duration expression. + */ + @Nonnull + static ValueDuration.Expression negate( @Nonnull final ValueDuration operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("-", operand); + return new ValueDuration.Expression(expression); + } + + /** + * Multiplication expression for numbers. + * + * @param operand1 + * The first, numeric operand. + * @param operand2 + * The second, numeric operand. + * @return A new numeric expression. + */ + @Nonnull + static + ValueNumeric.Expression + multiply( @Nonnull final ValueNumeric operand1, @Nonnull final ValueNumeric operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("mul", operand1, operand2); + return new ValueNumeric.Expression(expression); + } + + /** + * Multiplication expression for duration and number. + * + * @param operand1 + * The first, duration operand. + * @param operand2 + * The second, numeric operand. + * @return A new duration expression. + */ + @Nonnull + static + ValueDuration.Expression + multiply( @Nonnull final ValueDuration operand1, @Nonnull final ValueNumeric operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("mul", operand1, operand2); + return new ValueDuration.Expression(expression); + } + + /** + * Division expression for floating point numbers. + * + * @param operand1 + * The first, numeric operand. + * @param operand2 + * The second, numeric operand. + * @return A new numeric expression. + */ + @Nonnull + static ValueNumeric.Expression divide( @Nonnull final ValueNumeric operand1, @Nonnull final ValueNumeric operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("divby", operand1, operand2); + return new ValueNumeric.Expression(expression); + } + + /** + * Division expression for integer numbers. + * + * @param operand1 + * The first, numeric operand. + * @param operand2 + * The second, numeric operand. + * @return A new numeric expression. + */ + @Nonnull + static + ValueNumeric.Expression + divideEuclidean( @Nonnull final ValueNumeric operand1, @Nonnull final ValueNumeric operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("div", operand1, operand2); + return new ValueNumeric.Expression(expression); + } + + /** + * Division expression for duration. + * + * @param operand1 + * The first, duration operand. + * @param operand2 + * The second, numeric operand. + * @return A new duration expression. + */ + @Nonnull + static + ValueDuration.Expression + divide( @Nonnull final ValueDuration operand1, @Nonnull final ValueNumeric operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("div", operand1, operand2); + return new ValueDuration.Expression(expression); + } + + /** + * Modulo expression for numbers. + * + * @param operand1 + * The first, numeric operand. + * @param operand2 + * The second, numeric operand. + * @return A new numeric expression. + */ + @Nonnull + static ValueNumeric.Expression modulo( @Nonnull final ValueNumeric operand1, @Nonnull final ValueNumeric operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("mod", operand1, operand2); + return new ValueNumeric.Expression(expression); + } + + /** + * Ceil expression for numbers. + * + * @param operand + * The numeric operand. + * @return A new numeric expression. + */ + @Nonnull + static ValueNumeric.Expression ceiling( @Nonnull final ValueNumeric operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("ceiling", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Floor expression for numbers. + * + * @param operand + * The numeric operand. + * @return A new numeric expression. + */ + @Nonnull + static ValueNumeric.Expression floor( @Nonnull final ValueNumeric operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("floor", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Round expression for numbers. + * + * @param operand + * The numeric operand. + * @return A new numeric expression. + */ + @Nonnull + static ValueNumeric.Expression round( @Nonnull final ValueNumeric operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("round", operand); + return new ValueNumeric.Expression(expression); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionCollection.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionCollection.java new file mode 100644 index 000000000..35a0e15fe --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionCollection.java @@ -0,0 +1,259 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.util.function.Predicate; + +import javax.annotation.Nonnull; + +/** + * Set of OData filter functions for collection types. + */ +public interface FilterExpressionCollection +{ + /** + * Returns a {@link ValueBoolean.Expression} that checks whether the given {@code operand1} has {@code operand2} as + * a subset ({@code "hassubset"}). + * + * @param operand1 + * The potential super set. + * @param operand2 + * The potential subset. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static ValueBoolean.Expression hasSubset( + @Nonnull final Expressions.OperandMultiple operand1, + @Nonnull final Expressions.OperandMultiple operand2 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("hassubset", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether the given {@code operand1} has {@code operand2} as + * a subsequence ({@code "hassubsequence"}). + * + * @param operand1 + * The potential super sequence. + * @param operand2 + * The potential subsequence. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static ValueBoolean.Expression hasSubSequence( + @Nonnull final Expressions.OperandMultiple operand1, + @Nonnull final Expressions.OperandMultiple operand2 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("hassubsequence", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueCollection.Expression} that concatenates the given {@code operand1} and {@code operand2}. + * + * @param operand1 + * The first collection-like entity. + * @param operand2 + * The second collection-like entity. + * @return A {@link ValueCollection.Expression}. + */ + @Nonnull + static ValueCollection.Expression concat( + @Nonnull final Expressions.OperandMultiple operand1, + @Nonnull final Expressions.OperandMultiple operand2 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("concat", operand1, operand2); + return new ValueCollection.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether the given {@code operand1} contains + * {@code operand2} ({@code "contains"}). + * + * @param operand1 + * The potential super set. + * @param operand2 + * The potential subset. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static ValueBoolean.Expression contains( + @Nonnull final Expressions.OperandMultiple operand1, + @Nonnull final Expressions.OperandMultiple operand2 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("contains", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether the given {@code operand1} ends with + * {@code operand2} ({@code "endswith"}). + * + * @param operand1 + * The potential super set. + * @param operand2 + * The potential subset. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static ValueBoolean.Expression endsWith( + @Nonnull final Expressions.OperandMultiple operand1, + @Nonnull final Expressions.OperandMultiple operand2 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("endswith", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether the given {@code operand1} starts with + * {@code operand2} ({@code "startswith"}). + * + * @param operand1 + * The potential super set. + * @param operand2 + * The potential subset. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static ValueBoolean.Expression startsWith( + @Nonnull final Expressions.OperandMultiple operand1, + @Nonnull final Expressions.OperandMultiple operand2 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("startswith", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that is supposed to return the index of {@code operand1} where + * {@code operand2} starts ({@code "indexof"}). + * + * @param operand1 + * The potential super set. + * @param operand2 + * The potential subset. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static ValueNumeric.Expression indexOf( + @Nonnull final Expressions.OperandMultiple operand1, + @Nonnull final Expressions.OperandMultiple operand2 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("indexof", operand1, operand2); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that returns the length of the {@code operand} ({@code "length"}). + * + * @param operand + * The operand to get the length from. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression length( @Nonnull final Expressions.OperandMultiple operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("length", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueCollection.Expression} that extracts the subsequence of {@code operand1} starting from + * {@code operand2} ({@code "substring"}). + * + * @param operand1 + * The collection to get the subsequence from. + * + * @param operand2 + * The index of the first element of the subsequence to be extracted. + * @return A {@link ValueCollection.Expression}. + */ + @Nonnull + static + ValueCollection.Expression + substring( @Nonnull final Expressions.OperandMultiple operand1, @Nonnull final ValueNumeric operand2 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("substring", operand1, operand2); + return new ValueCollection.Expression(expression); + } + + /** + * Returns a {@link ValueCollection.Expression} that extracts the subsequence of {@code operand1} starting from + * {@code operand2} with length {@code operand3} ({@code "substring"}). + * + * @param operand1 + * The collection to get the subsequence from. + * @param operand2 + * The index of the first element of the subsequence to be extracted. + * @param operand3 + * The length of the subsequence to be extracted. + * @return A {@link ValueCollection.Expression}. + */ + @Nonnull + static ValueCollection.Expression substring( + @Nonnull final Expressions.OperandMultiple operand1, + @Nonnull final ValueNumeric operand2, + @Nonnull final ValueNumeric operand3 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("substring", operand1, operand2, operand3); + return new ValueCollection.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether all elements in {@code operand1} satisfy + * {@code operand2}. + * + * @param operand1 + * The collection-like entity to be checked. + * @param operand2 + * The condition to be satisfied. + * @param lambdaFieldPredicate + * The predicate for which fields will be given a prefix. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static ValueBoolean.Expression all( + @Nonnull final Expressions.OperandMultiple operand1, + @Nonnull final ValueBoolean operand2, + @Nonnull final Predicate lambdaFieldPredicate ) + { + final FilterExpression expression = + Expressions.createFunctionLambda("all", operand1, operand2, lambdaFieldPredicate); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether any element in {@code operand1} satisfies + * {@code operand2}. + * + * @param operand1 + * The collection-like entity to be checked. + * @param operand2 + * The condition to be satisfied. + * @param lambdaFieldPredicate + * The predicate for which fields will be given a prefix. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static ValueBoolean.Expression any( + @Nonnull final Expressions.OperandMultiple operand1, + @Nonnull final ValueBoolean operand2, + @Nonnull final Predicate lambdaFieldPredicate ) + { + final FilterExpression expression = + Expressions.createFunctionLambda("any", operand1, operand2, lambdaFieldPredicate); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether {@code operand1} contains any elements. + * + * @param operand1 + * The collection=like entity to be checked. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static ValueBoolean.Expression any( @Nonnull final Expressions.OperandMultiple operand1 ) + { + final FilterExpression expression = Expressions.createFunctionLambda("any", operand1); + return new ValueBoolean.Expression(expression); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionLogical.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionLogical.java new file mode 100644 index 000000000..21b5c7d9a --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionLogical.java @@ -0,0 +1,234 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.util.Arrays; +import java.util.stream.Collectors; + +import javax.annotation.Nonnull; + +/** + * Set of OData filter functions for logical types. + */ +public interface FilterExpressionLogical +{ + /** + * Returns a {@link ValueBoolean.Expression} that checks whether {@code operand1} and {@code operand2} are equal + * ({@code "eq"}). + * + * @param operand1 + * The first operand. + * @param operand2 + * The second operand. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static + ValueBoolean.Expression + equalTo( @Nonnull final Expressions.Operand operand1, @Nonnull final Expressions.Operand operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("eq", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether {@code operand1} and {@code operand2} are not equal + * ({@code "ne"}). + * + * @param operand1 + * The first operand. + * @param operand2 + * The second operand. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static + ValueBoolean.Expression + notEqualTo( @Nonnull final Expressions.Operand operand1, @Nonnull final Expressions.Operand operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("ne", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether {@code operand1} is greater than {@code operand2} + * ({@code "gt"}). + * + * @param operand1 + * The first operand. + * @param operand2 + * The second operand. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static + ValueBoolean.Expression + greaterThan( @Nonnull final Expressions.Operand operand1, @Nonnull final Expressions.Operand operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("gt", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether {@code operand1} is greater than or equal to + * {@code operand2} ({@code "ge"}). + * + * @param operand1 + * The first operand. + * @param operand2 + * The second operand. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static + ValueBoolean.Expression + greaterThanEquals( @Nonnull final Expressions.Operand operand1, @Nonnull final Expressions.Operand operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("ge", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether {@code operand1} is less than {@code operand2} + * ({@code "lt"}). + * + * @param operand1 + * The first operand. + * @param operand2 + * The second operand. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static + ValueBoolean.Expression + lessThan( @Nonnull final Expressions.Operand operand1, @Nonnull final Expressions.Operand operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("lt", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether {@code operand1} is less than or equal to + * {@code operand2} ({@code "le"}). + * + * @param operand1 + * The first operand. + * @param operand2 + * The second operand. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static + ValueBoolean.Expression + lessThanEquals( @Nonnull final Expressions.Operand operand1, @Nonnull final Expressions.Operand operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("le", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether both {@code operand1} and {@code operand2} are true + * ({@code "and"}). + * + * @param operand1 + * The first operand. + * @param operand2 + * The second operand. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static ValueBoolean.Expression and( @Nonnull final ValueBoolean operand1, @Nonnull final ValueBoolean operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("and", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether {@code operand1} or {@code operand2} is true + * ({@code "or"}). + * + * @param operand1 + * The first operand. + * @param operand2 + * The second operand. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static ValueBoolean.Expression or( @Nonnull final ValueBoolean operand1, @Nonnull final ValueBoolean operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("or", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that negates the {@code operand} ({@code "not"}). + * + * @param operand + * The operand to negate. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static ValueBoolean.Expression not( @Nonnull final ValueBoolean operand ) + { + final FilterExpression expression = Expressions.createOperatorPrefix("not", operand); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether {@code operand1} has {@code operand2} + * ({@code "has"}). + * + * @param operand1 + * The first operand. + * @param operand2 + * The second operand. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static + ValueBoolean.Expression + has( @Nonnull final Expressions.OperandSingle operand1, @Nonnull final ValueEnum operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("has", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether {@code operand1} is contained in {@code operands2} + * ({@code "in"}). + * + * @param operand1 + * The potential member. + * @param operands2 + * The potential container. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static + ValueBoolean.Expression + in( @Nonnull final Expressions.Operand operand1, @Nonnull final Expressions.Operand... operands2 ) + { + final Expressions.OperandMultiple operand2 = + ( protocol, prefixes ) -> "(" + + Arrays.stream(operands2).map(o -> o.getExpression(protocol)).collect(Collectors.joining(",")) + + ")"; + return in(operand1, operand2); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether {@code operand1} is contained in {@code operand2} + * ({@code "in"}). + * + * @param operand1 + * The potential member. + * @param operand2 + * The potential container. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static + ValueBoolean.Expression + in( @Nonnull final Expressions.Operand operand1, @Nonnull final Expressions.OperandMultiple operand2 ) + { + final FilterExpression expression = Expressions.createFunctionInfix("in", operand1, operand2); + return new ValueBoolean.Expression(expression); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionString.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionString.java new file mode 100644 index 000000000..aae366dfd --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionString.java @@ -0,0 +1,233 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import javax.annotation.Nonnull; + +/** + * Set of OData filter functions for string types. + */ +public interface FilterExpressionString +{ + /** + * Return a {@link ValueBoolean.Expression} that checks whether {@code operand1} matches the pattern + * {@code operand2} ({@code "matchesPattern"}). + * + * @param operand1 + * The potential subsequence. + * @param operand2 + * The pattern. + * @return A {@link ValueBoolean.Expression}. + */ + + @Nonnull + static + ValueBoolean.Expression + matchesPattern( @Nonnull final ValueString operand1, @Nonnull final ValueString operand2 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("matchesPattern", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueString.Expression} that converts the given {@code operand} to all lower case + * ({@code "toLower"}). + * + * @param operand + * The operand to be converted to lower case. + * @return A {@link ValueString.Expression}. + */ + @Nonnull + static ValueString.Expression toLower( @Nonnull final ValueString operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("tolower", operand); + return new ValueString.Expression(expression); + } + + /** + * Returns a {@link ValueString.Expression} that converts the given {@code operand} to all upper case + * ({@code "toUpper"}). + * + * @param operand + * The operand to be converted to upper case. + * @return A {@link ValueString.Expression}. + */ + @Nonnull + static ValueString.Expression toUpper( @Nonnull final ValueString operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("toupper", operand); + return new ValueString.Expression(expression); + } + + /** + * Returns a {@link ValueString.Expression} that removes leading and trailing whitespace from the given + * {@code operand} ({@code "trim"}). + * + * @param operand + * The operand to be trimmed. + * @return A {@link ValueString.Expression}. + */ + @Nonnull + static ValueString.Expression trim( @Nonnull final ValueString operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("trim", operand); + return new ValueString.Expression(expression); + } + + /** + * Returns a {@link ValueString.Expression} that concatenates {@code operand1} and {@code operand2} + * ({@code "concat"}). + * + * @param operand1 + * The first operand. + * @param operand2 + * The second operand. + * @return A {@link ValueString.Expression}. + */ + @Nonnull + static ValueString.Expression concat( @Nonnull final ValueString operand1, @Nonnull final ValueString operand2 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("concat", operand1, operand2); + return new ValueString.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether {@code operand1} contains {@code operand2} + * ({@code "contains"}). + * + * @param operand1 + * The potential super sequence. + * @param operand2 + * The potential subsequence. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static ValueBoolean.Expression contains( @Nonnull final ValueString operand1, @Nonnull final ValueString operand2 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("contains", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether {@code operand1} has {@code operand2} as a + * substring ({@code "substringof"}). + * + * @param operand1 + * The potential super sequence. + * @param operand2 + * The potential subsequence. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static + ValueBoolean.Expression + substringOf( @Nonnull final ValueString operand1, @Nonnull final ValueString operand2 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("substringof", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether {@code operand1} ends with {@code operand2} + * ({@code "endswith"}). + * + * @param operand1 + * The potential super sequence. + * @param operand2 + * The potential subsequence. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static ValueBoolean.Expression endsWith( @Nonnull final ValueString operand1, @Nonnull final ValueString operand2 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("endswith", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueBoolean.Expression} that checks whether {@code operand1} starts with {@code operand2} + * ({@code "startswith"}). + * + * @param operand1 + * The potential super sequence. + * @param operand2 + * The potential subsequence. + * @return A {@link ValueBoolean.Expression}. + */ + @Nonnull + static + ValueBoolean.Expression + startsWith( @Nonnull final ValueString operand1, @Nonnull final ValueString operand2 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("startswith", operand1, operand2); + return new ValueBoolean.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that returns the index of {@code operand2} in {@code operand1} + * ({@code "indexof"}). + * + * @param operand1 + * The potential super sequence. + * @param operand2 + * The potential subsequence. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression indexOf( @Nonnull final ValueString operand1, @Nonnull final ValueString operand2 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("indexof", operand1, operand2); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that returns the length of {@code operand} ({@code "length"}). + * + * @param operand + * The operand. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression length( @Nonnull final ValueString operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("length", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueString.Expression} that returns a substring of {@code operand1} starting at + * {@code operand2} ({@code "substring"}). + * + * @param operand1 + * The operand. + * @param operand2 + * The start index. + * @return A {@link ValueString.Expression}. + */ + @Nonnull + static ValueString.Expression substring( @Nonnull final ValueString operand1, @Nonnull final ValueNumeric operand2 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("substring", operand1, operand2); + return new ValueString.Expression(expression); + } + + /** + * Returns a {@link ValueString.Expression} that returns a substring of {@code operand1} starting at + * {@code operand2} with length {@code operand3} ({@code "substring"}). + * + * @param operand1 + * The operand. + * @param operand2 + * The start index. + * @param operand3 + * The length. + * @return A {@link ValueString.Expression}. + */ + @Nonnull + static ValueString.Expression substring( + @Nonnull final ValueString operand1, + @Nonnull final ValueNumeric operand2, + @Nonnull final ValueNumeric operand3 ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("substring", operand1, operand2, operand3); + return new ValueString.Expression(expression); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionTemporal.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionTemporal.java new file mode 100644 index 000000000..ff81723fb --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionTemporal.java @@ -0,0 +1,316 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import javax.annotation.Nonnull; + +/** + * Set of OData filter functions for temporal types. + */ +@SuppressWarnings( "overloads" ) +public interface FilterExpressionTemporal +{ + /** + * Returns a {@link ValueDate.Expression} that uses the given {@code operand} to filter for the {@code "date"} + * portion of a temporal value. + * + * @param operand + * The {@link ValueDateTimeOffset} to filter for. + * @return A {@link ValueDate.Expression}. + */ + @Nonnull + static ValueDate.Expression date( @Nonnull final ValueDateTimeOffset operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("date", operand); + return new ValueDate.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that uses the given {@code operand} to filter for the + * {@code "fractionalseconds"} portion of a temporal value. + * + * @param operand + * The {@link ValueDateTimeOffset} to filter for. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression fractionalSeconds( @Nonnull final ValueDateTimeOffset operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("fractionalseconds", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that uses the given {@code operand} to filter for the + * {@code "fractionalseconds"} portion of a temporal value. + * + * @param operand + * The {@link ValueTimeOfDay} to filter for. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression fractionalSeconds( @Nonnull final ValueTimeOfDay operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("fractionalseconds", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that uses the given {@code operand} to filter for the {@code "second"} + * portion of a temporal value. + * + * @param operand + * The {@link ValueDateTimeOffset} to filter for. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression second( @Nonnull final ValueDateTimeOffset operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("second", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that uses the given {@code operand} to filter for the {@code "second"} + * portion of a temporal value. + * + * @param operand + * The {@link ValueTimeOfDay} to filter for. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression second( @Nonnull final ValueTimeOfDay operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("second", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that uses the given {@code operand} to filter for the {@code "minute"} + * portion of a temporal value. + * + * @param operand + * The {@link ValueDateTimeOffset} to filter for. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression minute( @Nonnull final ValueDateTimeOffset operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("minute", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that uses the given {@code operand} to filter for the {@code "minute"} + * portion of a temporal value. + * + * @param operand + * The {@link ValueTimeOfDay} to filter for. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression minute( @Nonnull final ValueTimeOfDay operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("minute", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that uses the given {@code operand} to filter for the {@code "hour"} + * portion of a temporal value. + * + * @param operand + * The {@link ValueDateTimeOffset} to filter for. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression hour( @Nonnull final ValueDateTimeOffset operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("hour", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that uses the given {@code operand} to filter for the {@code "hour"} + * portion of a temporal value. + * + * @param operand + * The {@link ValueTimeOfDay} to filter for. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression hour( @Nonnull final ValueTimeOfDay operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("hour", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that uses the given {@code operand} to filter for the {@code "day"} + * portion of a temporal value. + * + * @param operand + * The {@link ValueDate} to filter for. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression day( @Nonnull final ValueDate operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("day", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that uses the given {@code operand} to filter for the {@code "day"} + * portion of a temporal value. + * + * @param operand + * The {@link ValueDateTimeOffset} to filter for. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression day( @Nonnull final ValueDateTimeOffset operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("day", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that uses the given {@code operand} to filter for the {@code "month"} + * portion of a temporal value. + * + * @param operand + * The {@link ValueDate} to filter for. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression month( @Nonnull final ValueDate operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("month", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that uses the given {@code operand} to filter for the {@code "month"} + * portion of a temporal value. + * + * @param operand + * The {@link ValueDateTimeOffset} to filter for. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression month( @Nonnull final ValueDateTimeOffset operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("month", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that uses the given {@code operand} to filter for the {@code "year"} + * portion of a temporal value. + * + * @param operand + * The {@link ValueDateTimeOffset} to filter for. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression year( @Nonnull final ValueDate operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("year", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that uses the given {@code operand} to filter for the {@code "year"} + * portion of a temporal value. + * + * @param operand + * The {@link ValueDateTimeOffset} to filter for. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression year( @Nonnull final ValueDateTimeOffset operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("year", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueTimeOfDay.Expression} that uses the given {@code operand} to filter for the {@code "time"} + * portion of a temporal value. + * + * @param operand + * The {@link ValueDateTimeOffset} to filter for. + * @return A {@link ValueTimeOfDay.Expression}. + */ + @Nonnull + static ValueTimeOfDay.Expression time( @Nonnull final ValueDateTimeOffset operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("time", operand); + return new ValueTimeOfDay.Expression(expression); + } + + /** + * Returns a {@link ValueDateTimeOffset.Expression} that uses the current date time ({@code "now")}. + * + * @return A {@link ValueDateTimeOffset.Expression}. + */ + @Nonnull + static ValueDateTimeOffset.Expression now() + { + final FilterExpression expression = Expressions.createFunctionPrefix("now"); + return new ValueDateTimeOffset.Expression(expression); + } + + /** + * Returns a {@link ValueDateTimeOffset.Expression} that uses the maximum date time ({@code "maxdatetime")}. + * + * @return A {@link ValueDateTimeOffset.Expression}. + */ + @Nonnull + static ValueDateTimeOffset.Expression maxDateTime() + { + final FilterExpression expression = Expressions.createFunctionPrefix("maxdatetime"); + return new ValueDateTimeOffset.Expression(expression); + } + + /** + * Returns a {@link ValueDateTimeOffset.Expression} that uses the minimum date time ({@code "mindatetime")}. + * + * @return A {@link ValueDateTimeOffset.Expression}. + */ + @Nonnull + static ValueDateTimeOffset.Expression minDateTime() + { + final FilterExpression expression = Expressions.createFunctionPrefix("mindatetime"); + return new ValueDateTimeOffset.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that uses the given {@code operand} to filter for the + * {@code "totaloffsetseconds"} portion of a temporal value. + * + * @param operand + * The {@link ValueDuration} to filter for. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression totalOffsetSeconds( @Nonnull final ValueDuration operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("totaloffsetseconds", operand); + return new ValueNumeric.Expression(expression); + } + + /** + * Returns a {@link ValueNumeric.Expression} that uses the given {@code operand} to filter for the + * {@code "totaloffsetminutes"} portion of a temporal value. + * + * @param operand + * The {@link ValueDateTimeOffset} to filter for. + * @return A {@link ValueNumeric.Expression}. + */ + @Nonnull + static ValueNumeric.Expression totalOffsetMinutes( @Nonnull final ValueDateTimeOffset operand ) + { + final FilterExpression expression = Expressions.createFunctionPrefix("totaloffsetminutes", operand); + return new ValueNumeric.Expression(expression); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableBoolean.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableBoolean.java new file mode 100644 index 000000000..e5c3594a9 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableBoolean.java @@ -0,0 +1,74 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import javax.annotation.Nonnull; + +/** + * Boolean operations for generic OData filter expression operands. + */ +public interface FilterableBoolean extends Expressions.Operand +{ + /** + * Combine current filter expression with another expression in conjunction. + * + * @param operand + * The other expression. + * @return This FluentHelper reference. + */ + @Nonnull + default ValueBoolean and( @Nonnull final ValueBoolean operand ) + { + return FilterExpressionLogical.and(this::getExpression, operand); + } + + /** + * Combine the filter expression with another expression in conjunction + * + * @param operand + * A boolean value. + * @return This FluentHelper reference. + */ + @Nonnull + default ValueBoolean and( @Nonnull final Boolean operand ) + { + final ValueBoolean value = ValueBoolean.literal(operand); + return and(value); + } + + /** + * Combine current filter expression with another expression in disjunction. + * + * @param operand + * The other expression. + * @return This FluentHelper reference. + */ + @Nonnull + default ValueBoolean or( @Nonnull final ValueBoolean operand ) + { + return FilterExpressionLogical.or(this::getExpression, operand); + } + + /** + * Combine the filter expression with another expression in disjunction + * + * @param operand + * A boolean value. + * @return This FluentHelper reference. + */ + @Nonnull + default ValueBoolean or( @Nonnull final Boolean operand ) + { + final ValueBoolean value = ValueBoolean.literal(operand); + return or(value); + } + + /** + * Negate the current filter expression. + * + * @return This FluentHelper reference. + */ + @Nonnull + default ValueBoolean not() + { + return FilterExpressionLogical.not(this::getExpression); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableCollection.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableCollection.java new file mode 100644 index 000000000..a1be6a72e --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableCollection.java @@ -0,0 +1,279 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import javax.annotation.Nonnull; + +/** + * Collection operations for generic OData filter expression operands. + */ +public interface FilterableCollection extends Expressions.OperandMultiple +{ + /** + * Filter by expression "hasSubset". + * + * @param operand + * Only operand of collection type. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean.Expression hasSubset( @Nonnull final ValueCollection operand ) + { + return FilterExpressionCollection.hasSubset(this, operand); + } + + /** + * Filter by expression "hasSubset". + * + * @param operand + * Only operand of Java iterable. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean.Expression hasSubset( @Nonnull final Iterable operand ) + { + final ValueCollection value = ValueCollection.literal(operand); + return FilterExpressionCollection.hasSubset(this, value); + } + + /** + * Filter by expression "hasSubSequence". + * + * @param operand + * Only operand of collection type. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean.Expression hasSubSequence( @Nonnull final ValueCollection operand ) + { + return FilterExpressionCollection.hasSubSequence(this, operand); + } + + /** + * Filter by expression "hasSubSequence". + * + * @param operand + * Only operand of Java iterable. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean.Expression hasSubSequence( @Nonnull final Iterable operand ) + { + final ValueCollection value = ValueCollection.literal(operand); + return FilterExpressionCollection.hasSubSequence(this, value); + } + + /** + * Filter by expression "contains". + * + * @param operand + * Only operand of collection type. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean.Expression contains( @Nonnull final ValueCollection operand ) + { + return FilterExpressionCollection.contains(this, operand); + } + + /** + * Filter by expression "contains". + * + * @param operand + * Only operand of Java iterable. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean.Expression contains( @Nonnull final Iterable operand ) + { + final Expressions.OperandMultiple value = ValueCollection.literal(operand); + return FilterExpressionCollection.contains(this, value); + } + + /** + * Filter by expression "startsWith". + * + * @param operand + * Only operand of collection type. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean.Expression startsWith( @Nonnull final ValueCollection operand ) + { + return FilterExpressionCollection.startsWith(this, operand); + } + + /** + * Filter by expression "startsWith". + * + * @param operand + * Only operand of Java iterable. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean.Expression startsWith( @Nonnull final Iterable operand ) + { + final ValueCollection value = ValueCollection.literal(operand); + return FilterExpressionCollection.startsWith(this, value); + } + + /** + * Filter by expression "endsWith". + * + * @param operand + * Only operand of collection type. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean.Expression endsWith( @Nonnull final ValueCollection operand ) + { + return FilterExpressionCollection.endsWith(this, operand); + } + + /** + * Filter by expression "endsWith". + * + * @param operand + * Only operand of Java iterable. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean.Expression endsWith( @Nonnull final Iterable operand ) + { + final ValueCollection value = ValueCollection.literal(operand); + return FilterExpressionCollection.endsWith(this, value); + } + + /** + * Filter by expression "indexOf". + * + * @param operand + * Only operand of collection type. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression indexOf( @Nonnull final ValueCollection operand ) + { + return FilterExpressionCollection.indexOf(this, operand); + } + + /** + * Filter by expression "indexOf". + * + * @param operand + * Only operand of Java iterable. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression indexOf( @Nonnull final Iterable operand ) + { + final ValueCollection value = ValueCollection.literal(operand); + return FilterExpressionCollection.indexOf(this, value); + } + + /** + * Filter by expression "concat". + * + * @param operand + * Only operand of collection type. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueCollection.Expression concat( @Nonnull final ValueCollection operand ) + { + return FilterExpressionCollection.concat(this, operand); + } + + /** + * Filter by expression "concat". + * + * @param operand + * Only operand of Java iterable. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueCollection.Expression concat( @Nonnull final Iterable operand ) + { + final ValueCollection value = ValueCollection.literal(operand); + return FilterExpressionCollection.concat(this, value); + } + + /** + * Filter by expression "length". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression length() + { + return FilterExpressionCollection.length(this); + } + + /** + * Filter by expression "substring". + * + * @param operand + * Only operand of Integer type. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueCollection.Expression substring( @Nonnull final Integer operand ) + { + final ValueNumeric value = ValueNumeric.literal(operand); + return FilterExpressionCollection.substring(this, value); + } + + /** + * Filter by expression "substring". + * + * @param operandIndex + * Operand of Integer type to mark the start of the subset. + * @param operandLength + * Operand of Integer type to mark the size of the subset. + * @return The FluentHelper filter. + */ + @Nonnull + default + ValueCollection.Expression + substring( @Nonnull final Integer operandIndex, @Nonnull final Integer operandLength ) + { + final ValueNumeric value1 = ValueNumeric.literal(operandIndex); + final ValueNumeric value2 = ValueNumeric.literal(operandLength); + return FilterExpressionCollection.substring(this, value1, value2); + } + + /** + * Filter by lambda expression "all". + * + * @param operand + * Operand to provide a generic filter to the collection item. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean.Expression all( @Nonnull final ValueBoolean operand ) + { + return FilterExpressionCollection.all(this, operand, o -> true); + } + + /** + * Filter by lambda expression "any". + * + * @param operand + * Operand to provide a generic filter to the collection item. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean.Expression any( @Nonnull final ValueBoolean operand ) + { + return FilterExpressionCollection.any(this, operand, o -> true); + } + + /** + * Filter by lambda expression "any", for finding non-empty collections. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean.Expression any() + { + return FilterExpressionCollection.any(this); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableComparisonAbsolute.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableComparisonAbsolute.java new file mode 100644 index 000000000..63eea3b9f --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableComparisonAbsolute.java @@ -0,0 +1,157 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import static com.sap.cloud.sdk.datamodel.odata.client.expression.Expressions.createOperand; + +import java.util.Arrays; +import java.util.List; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Logical operations for generic OData filter expression operands. + */ +public interface FilterableComparisonAbsolute extends Expressions.Operand +{ + /** + * Filter by expression "eq". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean equalTo( @Nonnull final Expressions.Operand operand ) + { + return FilterExpressionLogical.equalTo(this, operand); + } + + /** + * Filter by expression "eq". + * + * @param operand + * The generic object to compare with. + * @return The FluentHelper filter. + * @throws IllegalArgumentException + * When there is no mapping found for the provided Java literal. + */ + @Nonnull + default ValueBoolean equalTo( @Nullable final Object operand ) + { + final Expressions.Operand value = createOperand(operand); + return equalTo(value); + } + + /** + * Filter by expression "in". + * + * @param operands + * The generic operands to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean in( @Nonnull final Expressions.Operand... operands ) + { + return FilterExpressionLogical.in(this, operands); + } + + /** + * Filter by expression "in". + * + * @param operands + * The generic objects to compare with. + * @return The FluentHelper filter. + * @throws IllegalArgumentException + * When there is no mapping found for the provided Java literal. + */ + @Nonnull + default ValueBoolean in( @Nonnull final Object... operands ) + { + final Expressions.Operand[] value = + Arrays.stream(operands).map(Expressions::createOperand).toArray(Expressions.Operand[]::new); + return in(value); + } + + /** + * Filter by expression "in". + * + * @param collection + * A filterable collection reference. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean in( @Nonnull final FilterableCollection collection ) + { + return FilterExpressionLogical.in(this, collection); + } + + /** + * Filter by expression "in". + * + * @param + * Generic argument type for provided list items. + * @param operands + * The generic objects to compare with. + * @return The FluentHelper filter. + * @throws IllegalArgumentException + * When there is no mapping found for the provided Java literal. + */ + @Nonnull + default ValueBoolean in( @Nonnull final List operands ) + { + final Expressions.Operand[] value = + operands.stream().map(Expressions::createOperand).toArray(Expressions.Operand[]::new); + return in(value); + } + + /** + * Filter by expression "ne". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean notEqualTo( @Nonnull final Expressions.Operand operand ) + { + return FilterExpressionLogical.notEqualTo(this, operand); + } + + /** + * Filter by expression "ne". + * + * @param operand + * The generic object to compare with. + * @return The FluentHelper filter. + * @throws IllegalArgumentException + * When there is no mapping found for the provided Java literal. + */ + @Nonnull + default ValueBoolean notEqualTo( @Nullable final Object operand ) + { + final Expressions.Operand value = createOperand(operand); + return notEqualTo(value); + } + + /** + * Filter by expression "eq null". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean equalToNull() + { + return equalTo(Expressions.Operand.NULL); + } + + /** + * Filter by expression "ne null". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean notEqualToNull() + { + return notEqualTo(Expressions.Operand.NULL); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableComparisonRelative.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableComparisonRelative.java new file mode 100644 index 000000000..50586992b --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableComparisonRelative.java @@ -0,0 +1,127 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import static com.sap.cloud.sdk.datamodel.odata.client.expression.Expressions.createOperand; + +import javax.annotation.Nonnull; + +/** + * Logical operations for generic OData filter expression operands. + */ +public interface FilterableComparisonRelative extends Expressions.Operand +{ + /** + * Filter by expression "lt". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean lessThan( @Nonnull final Expressions.Operand operand ) + { + return FilterExpressionLogical.lessThan(this, operand); + } + + /** + * Filter by expression "lt". + * + * @param operand + * The generic object to compare with. + * @return The FluentHelper filter. + * @throws IllegalArgumentException + * When there is no mapping found for the provided Java literal. + */ + @Nonnull + default ValueBoolean lessThan( @Nonnull final Object operand ) + { + final Expressions.Operand value = createOperand(operand); + return lessThan(value); + } + + /** + * Filter by expression "le". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean lessThanEqual( @Nonnull final Expressions.Operand operand ) + { + return FilterExpressionLogical.lessThanEquals(this, operand); + } + + /** + * Filter by expression "le". + * + * @param operand + * The generic object to compare with. + * @return The FluentHelper filter. + * @throws IllegalArgumentException + * When there is no mapping found for the provided Java literal. + */ + @Nonnull + default ValueBoolean lessThanEqual( @Nonnull final Object operand ) + { + final Expressions.Operand value = createOperand(operand); + return lessThanEqual(value); + } + + /** + * Filter by expression "gt". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean greaterThan( @Nonnull final Expressions.Operand operand ) + { + return FilterExpressionLogical.greaterThan(this, operand); + } + + /** + * Filter by expression "gt". + * + * @param operand + * The generic object to compare with. + * @return The FluentHelper filter. + * @throws IllegalArgumentException + * When there is no mapping found for the provided Java literal. + */ + @Nonnull + default ValueBoolean greaterThan( @Nonnull final Object operand ) + { + final Expressions.Operand value = createOperand(operand); + return greaterThan(value); + } + + /** + * Filter by expression "ge". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean greaterThanEqual( @Nonnull final Expressions.Operand operand ) + { + return FilterExpressionLogical.greaterThanEquals(this, operand); + } + + /** + * Filter by expression "ge". + * + * @param operand + * The generic object to compare with. + * @return The FluentHelper filter. + * @throws IllegalArgumentException + * When there is no mapping found for the provided Java literal. + */ + @Nonnull + default ValueBoolean greaterThanEqual( @Nonnull final Object operand ) + { + final Expressions.Operand value = createOperand(operand); + return greaterThanEqual(value); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableDate.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableDate.java new file mode 100644 index 000000000..30a10c857 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableDate.java @@ -0,0 +1,133 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.time.Duration; + +import javax.annotation.Nonnull; + +/** + * Date operations for generic OData filter expression operands. + */ +public interface FilterableDate extends Expressions.Operand +{ + /** + * + * Filter by expression "day". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression dateDay() + { + final ValueDate thisDate = this::getExpression; + return FilterExpressionTemporal.day(thisDate); + } + + /** + * + * Filter by expression "month". + * + * @return The FluentHelper filter + */ + @Nonnull + default ValueNumeric.Expression dateMonth() + { + final ValueDate thisDate = this::getExpression; + return FilterExpressionTemporal.month(thisDate); + } + + /** + * + * Filter by expression "year". + * + * @return The FluentHelper filter + */ + @Nonnull + default ValueNumeric.Expression dateYear() + { + final ValueDate thisDate = this::getExpression; + return FilterExpressionTemporal.year(thisDate); + } + + /** + * + * Filter by expression "add". + * + * @param operand + * The duration to add to the date expression. + * @return The FluentHelper filter + */ + @Nonnull + default ValueDate.Expression add( @Nonnull final ValueDuration operand ) + { + final ValueDate thisDate = this::getExpression; + final ValueDuration value = operand::getExpression; + return FilterExpressionArithmetic.add(thisDate, value); + } + + /** + * + * Filter by expression "add". + * + * @param operand + * The duration to ad to the date expression. + * + * @return The FluentHelper filter + */ + @Nonnull + default ValueDate.Expression add( @Nonnull final Duration operand ) + { + final ValueDate thisDate = this::getExpression; + final ValueDuration value = ValueDuration.literal(operand); + return FilterExpressionArithmetic.add(thisDate, value); + } + + /** + * + * Filter by expression "sub". + * + * @param operand + * The duration to subtract from the date. + * + * @return The FluentHelper filter + */ + @Nonnull + default ValueDate.Expression subtract( @Nonnull final ValueDuration operand ) + { + final ValueDate thisDate = this::getExpression; + final ValueDuration value = operand::getExpression; + return FilterExpressionArithmetic.subtract(thisDate, value); + } + + /** + * + * Filter by expression "sub". + * + * @param operand + * The duration to subtract from the date. + * + * @return The FluentHelper filter + */ + @Nonnull + default ValueDate.Expression subtract( @Nonnull final Duration operand ) + { + final ValueDate thisDate = this::getExpression; + final ValueDuration value = ValueDuration.literal(operand); + return FilterExpressionArithmetic.subtract(thisDate, value); + } + + /** + * + * Filter by expression "sub". + * + * @param operand + * The other date to calculate the difference from. + * + * @return The FluentHelper filter + */ + @Nonnull + default ValueDuration.Expression difference( @Nonnull final ValueDate operand ) + { + final ValueDate thisDate = this::getExpression; + return FilterExpressionArithmetic.subtract(thisDate, operand); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableDateTimeOffset.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableDateTimeOffset.java new file mode 100644 index 000000000..6e5704741 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableDateTimeOffset.java @@ -0,0 +1,206 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.time.Duration; + +import javax.annotation.Nonnull; + +/** + * Date-Time-Offset operations for generic OData filter expression operands. + */ +public interface FilterableDateTimeOffset extends Expressions.Operand +{ + /** + * + * Filter by expression "date". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueDate.Expression date() + { + return FilterExpressionTemporal.date(this::getExpression); + } + + /** + * + * Filter by expression "fractionalseconds". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression timeFractionalSeconds() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + return FilterExpressionTemporal.fractionalSeconds(thisDateTime); + } + + /** + * + * Filter by expression "second". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression timeSecond() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + return FilterExpressionTemporal.second(thisDateTime); + } + + /** + * + * Filter by expression "minute". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression timeMinute() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + return FilterExpressionTemporal.minute(thisDateTime); + } + + /** + * + * Filter by expression "hour". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression timeHour() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + return FilterExpressionTemporal.hour(thisDateTime); + } + + /** + * + * Filter by expression "day". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression dateDay() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + return FilterExpressionTemporal.day(thisDateTime); + } + + /** + * + * Filter by expression "month". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression dateMonth() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + return FilterExpressionTemporal.month(thisDateTime); + } + + /** + * + * Filter by expression "year". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression dateYear() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + return FilterExpressionTemporal.year(thisDateTime); + } + + /** + * + * Filter by expression "time". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueTimeOfDay.Expression time() + { + return FilterExpressionTemporal.time(this::getExpression); + } + + /** + * + * Filter by expression "offsetminutes". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression offsetMinutes() + { + return FilterExpressionTemporal.totalOffsetMinutes(this::getExpression); + } + + /** + * + * Filter by expression "add". + * + * @param operand + * The duration to add to the date time. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueDateTimeOffset.Expression add( @Nonnull final ValueDuration operand ) + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final ValueDuration value = operand::getExpression; + return FilterExpressionArithmetic.add(thisDateTime, value); + } + + /** + * + * Filter by expression "add". + * + * @param operand + * The duration to add to the date time. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueDateTimeOffset.Expression add( @Nonnull final Duration operand ) + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final ValueDuration value = ValueDuration.literal(operand); + return FilterExpressionArithmetic.add(thisDateTime, value); + } + + /** + * + * Filter by expression "sub". + * + * @param operand + * The duration to subtract from the date time. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueDateTimeOffset.Expression subtract( @Nonnull final ValueDuration operand ) + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final ValueDuration value = operand::getExpression; + return FilterExpressionArithmetic.subtract(thisDateTime, value); + } + + /** + * + * Filter by expression "add". + * + * @param operand + * The duration to subtract from the date time. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueDateTimeOffset.Expression subtract( @Nonnull final Duration operand ) + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final ValueDuration value = ValueDuration.literal(operand); + return FilterExpressionArithmetic.subtract(thisDateTime, value); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableDuration.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableDuration.java new file mode 100644 index 000000000..fb2e386e9 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableDuration.java @@ -0,0 +1,172 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.time.Duration; + +import javax.annotation.Nonnull; + +/** + * Duration operations for generic OData filter expression operands. + */ +public interface FilterableDuration extends Expressions.Operand +{ + /** + * + * Filter by expression "offsetseconds". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression offsetSeconds() + { + return FilterExpressionTemporal.totalOffsetSeconds(this::getExpression); + } + + /** + * + * Filter by expression "add". + * + * @param operand + * The duration to add to the duration. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueDuration.Expression add( @Nonnull final ValueDuration operand ) + { + final ValueDuration thisDuration = this::getExpression; + final ValueDuration value = operand::getExpression; + return FilterExpressionArithmetic.add(thisDuration, value); + } + + /** + * + * Filter by expression "add". + * + * @param operand + * The duration to add to the duration. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueDuration.Expression add( @Nonnull final Duration operand ) + { + final ValueDuration thisDuration = this::getExpression; + final ValueDuration value = ValueDuration.literal(operand); + return FilterExpressionArithmetic.add(thisDuration, value); + } + + /** + * + * Filter by expression "sub". + * + * @param operand + * The duration to subtract from this duration. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueDuration.Expression subtract( @Nonnull final ValueDuration operand ) + { + final ValueDuration thisDuration = this::getExpression; + final ValueDuration value = operand::getExpression; + return FilterExpressionArithmetic.subtract(thisDuration, value); + } + + /** + * + * Filter by expression "sub". + * + * @param operand + * The duration to subtract from this duration. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueDuration.Expression subtract( @Nonnull final Duration operand ) + { + final ValueDuration thisDuration = this::getExpression; + final ValueDuration value = ValueDuration.literal(operand); + return FilterExpressionArithmetic.subtract(thisDuration, value); + } + + /** + * + * Filter by expression "mul". + * + * @param operand + * The product to be used to multiply the duration. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueDuration.Expression multiply( @Nonnull final ValueNumeric operand ) + { + final ValueDuration thisDuration = this::getExpression; + final ValueNumeric value = operand::getExpression; + return FilterExpressionArithmetic.multiply(thisDuration, value); + } + + /** + * + * Filter by expression "mul". + * + * @param operand + * The product to be used to multiply the duration. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueDuration.Expression multiply( @Nonnull final Number operand ) + { + final ValueDuration thisDuration = this::getExpression; + final ValueNumeric value = ValueNumeric.literal(operand); + return FilterExpressionArithmetic.multiply(thisDuration, value); + } + + /** + * + * Filter by expression "div". + * + * @param operand + * The quotient to be used to divide the duration. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueDuration.Expression divide( @Nonnull final ValueNumeric operand ) + { + final ValueDuration thisDuration = this::getExpression; + final ValueNumeric value = operand::getExpression; + return FilterExpressionArithmetic.divide(thisDuration, value); + } + + /** + * + * Filter by expression "div". + * + * @param operand + * The quotient to be used to divide the duration. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueDuration.Expression divide( @Nonnull final Number operand ) + { + final ValueDuration thisDuration = this::getExpression; + final ValueNumeric value = ValueNumeric.literal(operand); + return FilterExpressionArithmetic.divide(thisDuration, value); + } + + /** + * + * Filter by expression "-". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueDuration.Expression negate() + { + final ValueDuration thisDuration = this::getExpression; + return FilterExpressionArithmetic.negate(thisDuration); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableNumeric.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableNumeric.java new file mode 100644 index 000000000..5619263ec --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableNumeric.java @@ -0,0 +1,216 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import javax.annotation.Nonnull; + +/** + * Numeric operations for generic OData filter expression operands. + */ +public interface FilterableNumeric extends Expressions.Operand +{ + /** + * + * Filter by expression "add". + * + * @param operand + * The number to add. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric add( @Nonnull final ValueNumeric operand ) + { + return FilterExpressionArithmetic.add(this::getExpression, operand); + } + + /** + * + * Filter by expression "add". + * + * @param operand + * The number to add. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric add( @Nonnull final Number operand ) + { + final ValueNumeric value = ValueNumeric.literal(operand); + return FilterExpressionArithmetic.add(this::getExpression, value); + } + + /** + * + * Filter by expression "sub". + * + * @param operand + * The number to subtract. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric subtract( @Nonnull final ValueNumeric operand ) + { + return FilterExpressionArithmetic.subtract(this::getExpression, operand); + } + + /** + * + * Filter by expression "sub". + * + * @param operand + * The number to subtract. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric subtract( @Nonnull final Number operand ) + { + final ValueNumeric value = ValueNumeric.literal(operand); + return FilterExpressionArithmetic.subtract(this::getExpression, value); + } + + /** + * + * Filter by expression "mul". + * + * @param operand + * The number to multiply. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric multiply( @Nonnull final ValueNumeric operand ) + { + return FilterExpressionArithmetic.multiply((ValueNumeric) this::getExpression, operand); + } + + /** + * + * Filter by expression "mul". + * + * @param operand + * The number to multiply. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric multiply( @Nonnull final Number operand ) + { + final ValueNumeric value = ValueNumeric.literal(operand); + return FilterExpressionArithmetic.multiply((ValueNumeric) this::getExpression, value); + } + + /** + * + * Filter by expression "divby". + * + * @param operand + * The number to divide. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric divide( @Nonnull final ValueNumeric operand ) + { + return FilterExpressionArithmetic.divide((ValueNumeric) this::getExpression, operand); + } + + /** + * + * Filter by expression "divby". + * + * @param operand + * The number to divide. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric divide( @Nonnull final Number operand ) + { + final ValueNumeric value = ValueNumeric.literal(operand); + return FilterExpressionArithmetic.divide((ValueNumeric) this::getExpression, value); + } + + /** + * + * Filter by expression "mod". + * + * @param operand + * The base number to calculate the modulo from. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric modulo( @Nonnull final ValueNumeric operand ) + { + return FilterExpressionArithmetic.modulo(this::getExpression, operand); + } + + /** + * + * Filter by expression "mod". + * + * @param operand + * The base number to calculate the modulo from. + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric modulo( @Nonnull final Number operand ) + { + final ValueNumeric value = ValueNumeric.literal(operand); + return modulo(value); + } + + /** + * + * Filter by expression "ceiling". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression ceil() + { + final ValueNumeric thisNumber = this::getExpression; + return FilterExpressionArithmetic.ceiling(thisNumber); + } + + /** + * + * Filter by expression "floor". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression floor() + { + final ValueNumeric thisNumber = this::getExpression; + return FilterExpressionArithmetic.floor(thisNumber); + } + + /** + * + * Filter by expression "round". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression round() + { + final ValueNumeric thisNumber = this::getExpression; + return FilterExpressionArithmetic.round(thisNumber); + } + + /** + * + * Filter by expression "-". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression negate() + { + final ValueNumeric thisNumber = this::getExpression; + return FilterExpressionArithmetic.negate(thisNumber); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableString.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableString.java new file mode 100644 index 000000000..02b4e5f5c --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableString.java @@ -0,0 +1,277 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import javax.annotation.Nonnull; + +/** + * String operations for generic OData filter expression operands. + */ +public interface FilterableString extends Expressions.Operand +{ + /** + * Filter by expression "matchesPattern". + * + * @param operand + * String expression to match the string against. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean matches( @Nonnull final String operand ) + { + final ValueString value = ValueString.literal(operand); + return FilterExpressionString.matchesPattern(this::getExpression, value); + } + + /** + * Filter by expression "matchesPattern". + * + * @param operand + * String expression to match the string against. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean matches( @Nonnull final ValueString operand ) + { + return FilterExpressionString.matchesPattern(this::getExpression, operand); + } + + /** + * Filter by expression "tolower". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueString toLower() + { + return FilterExpressionString.toLower(this::getExpression); + } + + /** + * Filter by expression "toUpper". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueString toUpper() + { + return FilterExpressionString.toUpper(this::getExpression); + } + + /** + * Filter by expression "trim". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueString trim() + { + return FilterExpressionString.trim(this::getExpression); + } + + /** + * Filter by expression "length". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric length() + { + return FilterExpressionString.length(this::getExpression); + } + + /** + * Filter by expression "concat". + * + * @param operand + * The string to concatenate with. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueString concat( @Nonnull final String operand ) + { + final ValueString value = ValueString.literal(operand); + return FilterExpressionString.concat(this::getExpression, value); + } + + /** + * Filter by expression "concat". + * + * @param operand + * The string to concatenate with. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueString concat( @Nonnull final ValueString operand ) + { + return FilterExpressionString.concat(this::getExpression, operand); + } + + /** + * Filter by expression "startswith". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean startsWith( @Nonnull final ValueString operand ) + { + return FilterExpressionString.startsWith(this::getExpression, operand); + } + + /** + * Filter by expression "startswith". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean startsWith( @Nonnull final String operand ) + { + final ValueString value = ValueString.literal(operand); + return FilterExpressionString.startsWith(this::getExpression, value); + } + + /** + * Filter by expression "endswith". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean endsWith( @Nonnull final ValueString operand ) + { + return FilterExpressionString.endsWith(this::getExpression, operand); + } + + /** + * Filter by expression "endswith". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueBoolean endsWith( @Nonnull final String operand ) + { + final ValueString value = ValueString.literal(operand); + return endsWith(value); + } + + /** + * Filter by expression "contains". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + * @see #substringOf(ValueString) substringOf(ValueString) for OData V2 + */ + @Nonnull + default ValueBoolean contains( @Nonnull final ValueString operand ) + { + return FilterExpressionString.contains(this::getExpression, operand); + } + + /** + * Filter by expression "contains". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + * @see #substringOf(String) substringOf(String) for OData V2 + */ + @Nonnull + default ValueBoolean contains( @Nonnull final String operand ) + { + final ValueString value = ValueString.literal(operand); + return contains(value); + } + + /** + * Filter by expression "substringof". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + * @see #contains(ValueString) contains(ValueString) for OData V4 + */ + @Nonnull + default ValueBoolean substringOf( @Nonnull final ValueString operand ) + { + return FilterExpressionString.substringOf(this::getExpression, operand); + } + + /** + * Filter by expression "substringof". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + * @see #contains(String) contains(String) for OData V4 + */ + @Nonnull + default ValueBoolean substringOf( @Nonnull final String operand ) + { + final ValueString value = ValueString.literal(operand); + return substringOf(value); + } + + /** + * Filter by expression "indexof". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric indexOf( @Nonnull final String operand ) + { + final ValueString value = ValueString.literal(operand); + return indexOf(value); + } + + /** + * Filter by expression "indexof". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric indexOf( @Nonnull final ValueString operand ) + { + return FilterExpressionString.indexOf(this::getExpression, operand); + } + + /** + * Filter by expression "substring". + * + * @param operand + * The number of characters to cut off. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueString substring( @Nonnull final Integer operand ) + { + final ValueNumeric value = ValueNumeric.literal(operand); + return FilterExpressionString.substring(this::getExpression, value); + } + + /** + * Filter by expression "substring". + * + * @param operandIndex + * The number of characters to cut off. + * @param operandLength + * The number of characters to keep in. + * @return The FluentHelper filter. + */ + @Nonnull + default ValueString substring( @Nonnull final Integer operandIndex, @Nonnull final Integer operandLength ) + { + final ValueNumeric value1 = ValueNumeric.literal(operandIndex); + final ValueNumeric value2 = ValueNumeric.literal(operandLength); + return FilterExpressionString.substring(this::getExpression, value1, value2); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableTimeOfDay.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableTimeOfDay.java new file mode 100644 index 000000000..2c91a6976 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterableTimeOfDay.java @@ -0,0 +1,61 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import javax.annotation.Nonnull; + +/** + * Time-of-day operations for generic OData filter expression operands. + */ +public interface FilterableTimeOfDay extends Expressions.Operand +{ + /** + * + * Filter by expression "fractionalseconds". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression timeFractionalSeconds() + { + final ValueTimeOfDay thisTime = this::getExpression; + return FilterExpressionTemporal.fractionalSeconds(thisTime); + } + + /** + * + * Filter by expression "second". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression timeSecond() + { + final ValueTimeOfDay thisTime = this::getExpression; + return FilterExpressionTemporal.second(thisTime); + } + + /** + * + * Filter by expression "minute". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression timeMinute() + { + final ValueTimeOfDay thisTime = this::getExpression; + return FilterExpressionTemporal.minute(thisTime); + } + + /** + * + * Filter by expression "hour". + * + * @return The FluentHelper filter. + */ + @Nonnull + default ValueNumeric.Expression timeHour() + { + final ValueTimeOfDay thisTime = this::getExpression; + return FilterExpressionTemporal.hour(thisTime); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ODataResourcePath.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ODataResourcePath.java new file mode 100644 index 000000000..8a9e6e280 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ODataResourcePath.java @@ -0,0 +1,183 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.datamodel.odata.client.request.AbstractODataParameters; +import com.sap.cloud.sdk.datamodel.odata.client.request.UriEncodingStrategy; + +import io.vavr.Tuple; +import io.vavr.Tuple2; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * A class that assembles resource references into a URL path. References can be entity sets, entities by key, + * properties, functions, actions and special endpoints like the metadata endpoint. + * + * E.g. the following resource path identifies a function invocation on the a navigational property of the entity + * identified by {@code 'val'}: + *

+ * {@code Entity(key='val')/NavigationProperty/Model.Function(1)/ResultProperty}. + *

+ * Of the result only the property {@code ResultProperty} is accessed. + */ +@EqualsAndHashCode +public final class ODataResourcePath +{ + /** + * The current path as a list of its individual path segments. + */ + @Getter( AccessLevel.PUBLIC ) + @Nonnull + private final List> segments = new ArrayList<>(); + + /** + * Convenience method for {@code new ODataResourcePath().addSegment(segment)}. It creates a new resource path for + * the given path string. + * + * @param segment + * The string identifying the resource e.g. {@code EntityName} + * @return A new {@link ODataResourcePath}. + */ + @Nonnull + public static ODataResourcePath of( @Nonnull final String segment ) + { + return new ODataResourcePath().addSegment(segment); + } + + /** + * Convenience method for {@code new ODataResourcePath().addSegment(segment, segmentParameter)}. It creates a new + * resource path for the given path string and parameters. + * + * @param segment + * The string identifying the resource e.g. {@code EntityName} + * @param segmentParameter + * The parameters to be included in this segment e.g. {@code (key1='val',key2=123)} + * @return A new {@link ODataResourcePath}. + */ + @Nonnull + public static + ODataResourcePath + of( @Nonnull final String segment, @Nonnull final AbstractODataParameters segmentParameter ) + { + return new ODataResourcePath().addSegment(segment, segmentParameter); + } + + /** + * Add a navigation to the path. + * + * @param segment + * The navigation to add. Any slashes will be encoded and not treated as segment separators. + * @return This builder instance. + */ + @Nonnull + public ODataResourcePath addSegment( @Nonnull final String segment ) + { + return addSegment(segment, null); + } + + /** + * Add a navigation with a parameters or function or action call to the path. + * + * @param segment + * The unencoded navigation or function or action reference to add. Any slashes will be encoded and not + * treated as segment separators. + * @param parameters + * The unencoded parameters or parameters to add. + * @return This builder instance. + */ + @Nonnull + public + ODataResourcePath + addSegment( @Nonnull final String segment, @Nullable final AbstractODataParameters parameters ) + { + segments.add(Tuple.of(segment, parameters)); + return this; + } + + /** + * Add a path parameter to the last segment. Can only be applied if the last segment added to this builder did not + * contain any parameters. + * + * @param parameters + * The unencoded parameters to add. + * @return This builder instance. + * + * @throws IllegalStateException + * When the current path is empty or the last segment already contains a parameter. + */ + @Nonnull + public ODataResourcePath addParameterToLastSegment( @Nonnull final AbstractODataParameters parameters ) + { + if( segments.isEmpty() ) { + throw new IllegalStateException( + "Cannot add parameter to the last segment because the current path is empty."); + } + final Tuple2 lastSegment = segments.get(segments.size() - 1); + if( lastSegment._2() != null ) { + final String msg = + String + .format( + "Cannot add parameter for path segment \"%s\". The segment already contains a parameter expression.", + lastSegment._2()); + throw new IllegalStateException(msg); + } + segments.set(segments.size() - 1, lastSegment.update2(parameters)); + return this; + } + + /** + * Encodes the path segments and assembles them together. The returned path will always start with a leading forward + * slash and ends without any trailing slashes. + * + * @return The encoded path. + */ + @Nonnull + public String toEncodedPathString() + { + return toEncodedPathString(UriEncodingStrategy.REGULAR); + } + + /** + * Encodes the path segments and assembles them together. The returned path will always start with a leading forward + * slash and ends without any trailing slashes. + * + * @param strategy + * The URI encoding strategy. + * @return The encoded path. + */ + @Nonnull + public String toEncodedPathString( @Nonnull final UriEncodingStrategy strategy ) + { + return "/" + + segments + .stream() + .map(t -> t.map1(strategy.getPathPercentEscaper()::escape)) + .map(t -> t.map2(key -> key != null ? key.toEncodedString(strategy) : "")) + .map(t -> t._1() + t._2()) + .collect(Collectors.joining("/")); + } + + /** + * Builds the path segments to a full URL path. + * + * @return The unencoded URL path. + */ + @Override + @Nonnull + public String toString() + { + return "/" + + segments + .stream() + .map(t -> t.map2(key -> key != null ? key.toString() : "")) + .map(t -> t._1() + t._2()) + .collect(Collectors.joining("/")); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/OrderExpression.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/OrderExpression.java new file mode 100644 index 000000000..9ecbd1965 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/OrderExpression.java @@ -0,0 +1,70 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.query.Order; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * A class representing order expressions over fields, maintaining an order over them. + */ +@NoArgsConstructor( access = AccessLevel.PRIVATE ) +public final class OrderExpression +{ + private final Map orderBy = new LinkedHashMap<>(); + + /** + * To create OrderExpression with a field and ordering. + * + * @param fieldName + * The field name to create an order expression for. + * @param order + * The order direction. + * @return The order expression. + */ + @Nonnull + public static OrderExpression of( @Nonnull final String fieldName, @Nonnull final Order order ) + { + return new OrderExpression().and(fieldName, order); + } + + /** + * To translate OrderExpression to query string. + * + * @return The part of the query string dedicated to ordering. + */ + @Nonnull + public String toOrderByString() + { + return orderBy + .entrySet() + .stream() + .map( + entry -> entry.getValue() == null + ? entry.getKey() + : entry.getKey() + " " + entry.getValue().toString().toLowerCase()) + .collect(Collectors.joining(",")); + } + + /** + * To maintain a Map of OrderExpressions with field name and order. + * + * @param fieldName + * The field name to create an order expression for. + * @param order + * The order direction. + * @return The concatenated order expression (conjunction). + */ + @Nonnull + public OrderExpression and( @Nonnull final String fieldName, @Nonnull final Order order ) + { + orderBy.put(fieldName, order); + return this; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueBinary.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueBinary.java new file mode 100644 index 000000000..2f2e545b4 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueBinary.java @@ -0,0 +1,55 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.util.Base64; +import java.util.function.Function; + +import javax.annotation.Nonnull; + +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * OData filter expression operand of type Edm.String + */ +public interface ValueBinary extends Expressions.OperandSingle, FilterableComparisonAbsolute +{ + /** + * Null value for binary operations. + */ + @Nonnull + ValueBinary NULL = Expressions.OperandSingle.NULL::getExpression; + + /** + * Lambda to translate a byte array to String. + */ + Function ENCODE_TO_STRING = Base64.getEncoder()::encodeToString; + + /** + * Lambda to translate a String with "ISO-8859-1" encoding to byte array. + */ + Function DECODE_FROM_STRING = Base64.getDecoder()::decode; + + /** + * Returns a {@link ValueBinary} from the given {@code v}. + * + * @param v + * The value to be transformed into a {@link ValueBinary}. + * @return A {@link ValueBinary} that contains the given {@code v}. + */ + @Nonnull + static ValueBinary literal( @Nonnull final byte[] v ) + { + final String value = ENCODE_TO_STRING.apply(v); + return ( protocol, prefixes ) -> "binary'" + value + "'"; + } + + /** + * OData expression for binary values. + */ + @RequiredArgsConstructor + class Expression implements FilterExpression, ValueBinary + { + @Delegate + private final FilterExpression delegate; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueBoolean.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueBoolean.java new file mode 100644 index 000000000..851ad48e0 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueBoolean.java @@ -0,0 +1,41 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import javax.annotation.Nonnull; + +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * OData filter expression operand of type Edm.Boolean + */ +public interface ValueBoolean extends Expressions.OperandSingle, FilterableBoolean, FilterableComparisonAbsolute +{ + /** + * Null value for boolean operations. + */ + @Nonnull + ValueBoolean NULL = Expressions.OperandSingle.NULL::getExpression; + + /** + * Get the literal of this expression. + * + * @param v + * boolean. + * @return The literal. + */ + @Nonnull + static ValueBoolean literal( @Nonnull final Boolean v ) + { + return ( protocol, prefixes ) -> v.toString(); + } + + /** + * Implementation with literal number value. + */ + @RequiredArgsConstructor + class Expression implements FilterExpression, ValueBoolean + { + @Delegate + private final FilterExpression delegate; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueCollection.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueCollection.java new file mode 100644 index 000000000..5b8c176a6 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueCollection.java @@ -0,0 +1,56 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.util.stream.Collectors; + +import javax.annotation.Nonnull; + +import com.google.common.collect.Streams; + +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * OData filter expression for a generic collection. + */ +public interface ValueCollection + extends + Expressions.OperandMultiple, + FilterableCollection, + FilterableComparisonAbsolute, + FilterableComparisonRelative +{ + /** + * Null value for collection operations. + */ + @Nonnull + ValueCollection NULL = Expressions.Operand.NULL::getExpression; + + /** + * Returns a {@link ValueCollection} instance from the given {@code v}. + * + * @param v + * The values to be transformed into a {@link ValueCollection}. + * @return A {@link ValueCollection} that contains the given {@code v}. + */ + @Nonnull + static ValueCollection literal( @Nonnull final Iterable v ) + { + return ( protocol, prefixes ) -> "[" + + Streams + .stream(v) + .map(Expressions::createOperand) + .map(o -> o.getExpression(protocol)) + .collect(Collectors.joining(",")) + + "]"; + } + + /** + * OData expression for generic value collections. + */ + @RequiredArgsConstructor + class Expression implements FilterExpression, ValueCollection + { + @Delegate + private final FilterExpression delegate; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDate.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDate.java new file mode 100644 index 000000000..8fd404ffe --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDate.java @@ -0,0 +1,49 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import javax.annotation.Nonnull; + +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * OData filter expression operand of type Edm.Date + */ +public interface ValueDate + extends + Expressions.OperandSingle, + FilterableDate, + FilterableComparisonAbsolute, + FilterableComparisonRelative +{ + /** + * Null value for date operations. + */ + @Nonnull + ValueDate NULL = Expressions.OperandSingle.NULL::getExpression; + + /** + * Returns a {@link ValueDate} from the given {@code v}. + * + * @param v + * The value to be transformed into a {@link ValueDate}. + * @return A {@link ValueDate} that contains the given {@code v}. + */ + @Nonnull + static ValueDate literal( @Nonnull final LocalDate v ) + { + return ( protocol, prefixes ) -> v.format(DateTimeFormatter.ISO_LOCAL_DATE); + } + + /** + * OData expression for date values. + */ + @RequiredArgsConstructor + class Expression implements FilterExpression, ValueDate + { + @Delegate + private final FilterExpression delegate; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDateTime.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDateTime.java new file mode 100644 index 000000000..5c60353bf --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDateTime.java @@ -0,0 +1,48 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.time.LocalDateTime; + +import javax.annotation.Nonnull; + +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * OData filter expression operand of type Edm.DateTime (OData 2.0 only) + */ +public interface ValueDateTime + extends + Expressions.OperandSingle, + FilterableDateTimeOffset, + FilterableComparisonAbsolute, + FilterableComparisonRelative +{ + /** + * Null value for date time operations. + */ + @Nonnull + ValueDateTime NULL = Expressions.OperandSingle.NULL::getExpression; + + /** + * Returns a {@link ValueDateTime} from the given {@code v}. + * + * @param v + * The value to be transformed into a {@link ValueDateTime}. + * @return A {@link ValueDateTime} that contains the given {@code v}. + */ + @Nonnull + static ValueDateTime literal( @Nonnull final LocalDateTime v ) + { + return ( protocol, prefixes ) -> protocol.getDateTimeSerializer().apply(v); + } + + /** + * OData expression for date time values. + */ + @RequiredArgsConstructor + class Expression implements FilterExpression, ValueDateTime + { + @Delegate + private final FilterExpression delegate; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDateTimeOffset.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDateTimeOffset.java new file mode 100644 index 000000000..9be9a1918 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDateTimeOffset.java @@ -0,0 +1,48 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.time.OffsetDateTime; + +import javax.annotation.Nonnull; + +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * OData filter expression operand of type Edm.DateTimeOffset + */ +public interface ValueDateTimeOffset + extends + Expressions.OperandSingle, + FilterableDateTimeOffset, + FilterableComparisonAbsolute, + FilterableComparisonRelative +{ + /** + * Null value for date time offset operations. + */ + @Nonnull + ValueDateTimeOffset NULL = Expressions.OperandSingle.NULL::getExpression; + + /** + * Returns a {@link ValueDateTimeOffset} from the given {@code v}. + * + * @param v + * The value to be transformed into a {@link ValueDateTimeOffset}. + * @return A {@link ValueDateTimeOffset} that contains the given {@code v}. + */ + @Nonnull + static ValueDateTimeOffset literal( @Nonnull final OffsetDateTime v ) + { + return ( protocol, prefixes ) -> protocol.getDateTimeOffsetSerializer().apply(v); + } + + /** + * OData expression for date time offset values. + */ + @RequiredArgsConstructor + class Expression implements FilterExpression, ValueDateTimeOffset + { + @Delegate + private final FilterExpression delegate; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDuration.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDuration.java new file mode 100644 index 000000000..5ee051baa --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDuration.java @@ -0,0 +1,48 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.time.Duration; + +import javax.annotation.Nonnull; + +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * OData filter expression operand of type Edm.Duration + */ +public interface ValueDuration + extends + Expressions.OperandSingle, + FilterableDuration, + FilterableComparisonAbsolute, + FilterableComparisonRelative +{ + /** + * Null value for enum operations. + */ + @Nonnull + ValueEnum NULL = Expressions.OperandSingle.NULL::getExpression; + + /** + * Returns a {@link ValueDuration} from the given {@code v}. + * + * @param v + * The value to be transformed into a {@link ValueDuration}. + * @return A {@link ValueDuration} that contains the given {@code v}. + */ + @Nonnull + static ValueDuration literal( @Nonnull final Duration v ) + { + return ( protocol, prefixes ) -> "duration'" + v + "'"; + } + + /** + * OData expression for duration values. + */ + @RequiredArgsConstructor + class Expression implements FilterExpression, ValueDuration + { + @Delegate + private final FilterExpression delegate; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueEnum.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueEnum.java new file mode 100644 index 000000000..5fb3e45fc --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueEnum.java @@ -0,0 +1,43 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import javax.annotation.Nonnull; + +/** + * OData filter expression operand of enumeration type + */ +public interface ValueEnum extends Expressions.OperandSingle, FilterableComparisonAbsolute +{ + /** + * Null value for enum operations. + */ + @Nonnull + ValueEnum NULL = Expressions.OperandSingle.NULL::getExpression; + + /** + * Returns a {@link ValueEnum} from the given {@code v}. + * + * @param v + * The value to be transformed into a {@link ValueEnum}. + * @return A {@link ValueEnum} that contains the given {@code v}. + */ + @Nonnull + static ValueEnum literal( @Nonnull final String v ) + { + return ( protocol, prefixes ) -> "'" + v + "'"; + } + + /** + * Returns a {@link ValueEnum} from the given {@code enumType} and {@code v}. + * + * @param enumType + * The enum type. + * @param v + * The value to be transformed into a {@link ValueEnum}. + * @return A {@link ValueEnum} that contains the given {@code v}. + */ + @Nonnull + static ValueEnum literal( @Nonnull final String enumType, @Nonnull final String v ) + { + return ( protocol, prefixes ) -> enumType + "'" + v + "'"; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueGuid.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueGuid.java new file mode 100644 index 000000000..6295d6432 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueGuid.java @@ -0,0 +1,30 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.util.UUID; + +import javax.annotation.Nonnull; + +/** + * OData filter expression operand of type Edm.Guid + */ +public interface ValueGuid extends Expressions.OperandSingle, FilterableComparisonAbsolute, FilterableComparisonRelative +{ + /** + * Null value for guid operations. + */ + @Nonnull + ValueGuid NULL = Expressions.OperandSingle.NULL::getExpression; + + /** + * Returns a {@link ValueGuid} from the given {@code v}. + * + * @param v + * The value to be transformed into a {@link ValueGuid}. + * @return A {@link ValueGuid} that contains the given {@code v}. + */ + @Nonnull + static ValueGuid literal( @Nonnull final UUID v ) + { + return ( protocol, prefixes ) -> protocol.getUUIDSerializer().apply(v); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueNumeric.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueNumeric.java new file mode 100644 index 000000000..7028b3fca --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueNumeric.java @@ -0,0 +1,46 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import javax.annotation.Nonnull; + +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * OData filter expression operand of type Edm.Int32, ... + */ +public interface ValueNumeric + extends + Expressions.OperandSingle, + FilterableNumeric, + FilterableComparisonAbsolute, + FilterableComparisonRelative +{ + /** + * Null value for numeric operations. + */ + @Nonnull + ValueNumeric NULL = Expressions.OperandSingle.NULL::getExpression; + + /** + * Returns a {@link ValueNumeric} from the given {@code v}. + * + * @param v + * The value to be transformed into a {@link ValueNumeric}. + * @return A {@link ValueNumeric} that contains the given {@code v}. + */ + @Nonnull + static ValueNumeric literal( @Nonnull final Number v ) + { + return ( protocol, prefixes ) -> protocol.getNumberSerializer().apply(v); + } + + /** + * OData expression on numeric values. + */ + @RequiredArgsConstructor + class Expression implements FilterExpression, ValueNumeric + { + @Delegate + private final FilterExpression delegate; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueString.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueString.java new file mode 100644 index 000000000..0016499a5 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueString.java @@ -0,0 +1,47 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import javax.annotation.Nonnull; + +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * OData filter expression operand of type Edm.String + */ +public interface ValueString + extends + Expressions.OperandSingle, + FilterableString, + FilterableComparisonAbsolute, + FilterableComparisonRelative +{ + /** + * Null value for string operations. + */ + @Nonnull + ValueString NULL = Expressions.OperandSingle.NULL::getExpression; + + /** + * Returns a {@link ValueString} from the given {@code v}. + * + * @param v + * The value to be transformed into a {@link ValueString}. + * @return A {@link ValueString} that contains the given {@code v}. + */ + @Nonnull + static ValueString literal( @Nonnull final String v ) + { + final String escapedValue = v.replaceAll("'", "''"); + return ( protocol, prefixes ) -> "'" + escapedValue + "'"; + } + + /** + * OData expression on string values. + */ + @RequiredArgsConstructor + class Expression implements FilterExpression, ValueString + { + @Delegate + private final FilterExpression delegate; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueTimeOfDay.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueTimeOfDay.java new file mode 100644 index 000000000..28c2ba312 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueTimeOfDay.java @@ -0,0 +1,48 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import java.time.LocalTime; + +import javax.annotation.Nonnull; + +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * OData filter expression operand of type Edm.TimeOfDay in case of OData 4.0 or Edm.Time in case of OData 2.0. + */ +public interface ValueTimeOfDay + extends + Expressions.OperandSingle, + FilterableTimeOfDay, + FilterableComparisonAbsolute, + FilterableComparisonRelative +{ + /** + * Null value for time of day operations. + */ + @Nonnull + ValueTimeOfDay NULL = Expressions.OperandSingle.NULL::getExpression; + + /** + * Returns a {@link ValueTimeOfDay} from the given {@code v}. + * + * @param v + * The value to be transformed into a {@link ValueTimeOfDay}. + * @return A {@link ValueTimeOfDay} that contains the given {@code v}. + */ + @Nonnull + static ValueTimeOfDay literal( @Nonnull final LocalTime v ) + { + return ( protocol, prefixes ) -> protocol.getTimeOfDaySerializer().apply(v); + } + + /** + * OData expression for time of day values. + */ + @RequiredArgsConstructor + class Expression implements FilterExpression, ValueTimeOfDay + { + @Delegate + private final FilterExpression delegate; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/Order.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/Order.java new file mode 100644 index 000000000..ec1df56e3 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/Order.java @@ -0,0 +1,17 @@ +package com.sap.cloud.sdk.datamodel.odata.client.query; + +/** + * Used with orderBy methods in entity fluent helper objects to set the sorting order of field values. + */ +public enum Order +{ + /** + * Sort field values in ascending order. + */ + ASC, + + /** + * Sort field values in descending order. + */ + DESC +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/QuerySerializable.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/QuerySerializable.java new file mode 100644 index 000000000..c2db448c0 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/QuerySerializable.java @@ -0,0 +1,28 @@ +package com.sap.cloud.sdk.datamodel.odata.client.query; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.request.UriEncodingStrategy; + +/** + * A serializable query interface to serve an encoded and not-encoded String representation. + */ +public interface QuerySerializable +{ + /** + * Compute the encoded string representation of this query with the following {@link UriEncodingStrategy#REGULAR} + * strategy. + * + * @return A string representing the encoded request query. + */ + @Nonnull + String getEncodedQueryString(); + + /** + * Compute the string representation of this query. + * + * @return A string representing the request query. + */ + @Nonnull + String getQueryString(); +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/QuerySerializer.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/QuerySerializer.java new file mode 100644 index 000000000..3784ed147 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/QuerySerializer.java @@ -0,0 +1,191 @@ +package com.sap.cloud.sdk.datamodel.odata.client.query; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.stream.Collectors; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.common.collect.ImmutableMap; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.OrderExpression; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataUriFactory; + +import io.vavr.control.Option; + +class QuerySerializer +{ + private static final String SEPARATOR_SUB_QUERY = ";"; + private static final String SEPARATOR_ROOT_QUERY = "&"; + + private static final Map> QUERY_TO_STRING_MAP = + ImmutableMap + .> builder() + .put("$select=%s", QuerySerializer::selectorsToQueryString) + .put("$expand=%s", QuerySerializer::expansionsToQueryString) + .put("$filter=%s", QuerySerializer::filtersToQueryString) + .put("$top=%s", ( q, applyEncoding ) -> q.top) + .put("$skip=%s", ( q, applyEncoding ) -> q.skip) + .put("$orderby=%s", QuerySerializer::orderByToQueryString) + .put("$search=%s", ( q, applyEncoding ) -> conditionalEncode(q.search, applyEncoding)) + .build(); + + @Nonnull + static String serializeAndEncodeQuery( @Nonnull final StructuredQuery query, final boolean applyEncoding ) + { + final List parameters = new ArrayList<>(); + + // For every system query (e.g. select, expand) apply the provided StructuredQuery object. + // Add the computed String to the list of HTTP query parameters. Optionally apply URI encoding to the value. + QUERY_TO_STRING_MAP + .forEach( + ( parameterString, valueFunction ) -> Option + .of(valueFunction.apply(query, applyEncoding)) + .filter(q -> q instanceof String ? !"".equals(q) : q != null) + .map(q -> String.format(parameterString, q)) + .forEach(parameters::add)); + + if( query.isRoot() ) { + query + .getCustomParameters() + .forEach(( key, value ) -> parameters.add(key + "=" + conditionalEncode(value, applyEncoding))); + } + + final String queryElementSeparator = query.isRoot() ? SEPARATOR_ROOT_QUERY : SEPARATOR_SUB_QUERY; + return String.join(queryElementSeparator, parameters); + } + + /** + * Helper method to translate the simple selector expressions to query String. + * + * @return The part of the query string dedicated to selects. + */ + @Nonnull + private static String selectorsToQueryString( @Nonnull final StructuredQuery q, final boolean applyEncoding ) + { + final List selectors = getSelectors(q, applyEncoding); + return String.join(",", selectors); + } + + /** + * Helper method to translate selector expressions of a structured query to query String. + * + * @param query + * The structured query which will be translated + * @param applyEncoding + * Boolean flag for encoding values to enable HTTP URI compatibility + * + * @return The part of the query string dedicated to $select + */ + @Nonnull + private static List getSelectors( @Nonnull final StructuredQuery query, final boolean applyEncoding ) + { + // common simple selectors for query in OData V2 + OData V4 + final List selectors = + query + .getSimpleSelectors() + .stream() + .map(result -> applyEncoding ? ODataUriFactory.encodeQuery(result) : result) + .collect(Collectors.toList()); + + if( query.getProtocol() == ODataProtocol.V2 ) { + // derive selectors from expansions and add to result list + for( final StructuredQuery childQuery : query.getComplexSelectors() ) { + String propertyName = childQuery.getEntityOrPropertyName(); + if( applyEncoding ) { + propertyName = ODataUriFactory.encodeQuery(propertyName); + } + for( final String select : getSelectors(childQuery, applyEncoding) ) { + selectors.add(propertyName + "/" + select); + } + } + } + return selectors; + } + + /** + * Helper method to translate the complex selector expressions to encoded query String. + * + * @return The part of the query string dedicated to selects. + */ + @Nonnull + private static String expansionsToQueryString( @Nonnull final StructuredQuery q, final boolean applyEncoding ) + { + final List filters = getExpansions(q, applyEncoding); + return String.join(",", filters); + } + + /** + * Helper method to translate expansion expressions of a structured query to query String. + * + * @param query + * The structured query which will be translated + * @param applyEncoding + * Boolean flag for encoding values to enable HTTP URI compatibility + * + * @return The part of the query string dedicated to $expand + */ + @Nonnull + private static List getExpansions( @Nonnull final StructuredQuery query, final boolean applyEncoding ) + { + final List expansions = new ArrayList<>(); + final Collection complexSelectors = query.getComplexSelectors(); + for( final StructuredQuery subQuery : complexSelectors ) { + String propertyName = subQuery.getEntityOrPropertyName(); + if( applyEncoding ) { + propertyName = ODataUriFactory.encodeQuery(propertyName); + } + + if( query.getProtocol() == ODataProtocol.V2 ) { + expansions.add(propertyName); + for( final String expand : getExpansions(subQuery, applyEncoding) ) { + expansions.add(propertyName + "/" + expand); + } + } else { + final String subQueryString = + applyEncoding ? subQuery.getEncodedQueryString() : subQuery.getQueryString(); + if( !subQueryString.isEmpty() ) { + propertyName += "(" + subQueryString + ")"; + } + expansions.add(propertyName); + } + } + return expansions; + } + + /** + * Helper method to translate the filter expressions to query String. + * + * @return The part of the query string dedicated to filters. + */ + @Nonnull + private static String filtersToQueryString( @Nonnull final StructuredQuery q, final boolean applyEncoding ) + { + final List filters = + q.getFilters().stream().map(filter -> filter.getExpression(q.getProtocol())).collect(Collectors.toList()); + + return conditionalEncode(String.join(" and ", filters), applyEncoding); + } + + /** + * Helper method to translate the order expressions to query String. + * + * @return The part of the query string dedicated to filters. + */ + @Nullable + private static String orderByToQueryString( @Nonnull final StructuredQuery q, final boolean applyEncoding ) + { + final String orderBy = Option.of(q.getOrderBy()).map(OrderExpression::toOrderByString).getOrNull(); + return conditionalEncode(orderBy, applyEncoding); + } + + @Nullable + private static String conditionalEncode( @Nullable final String input, final boolean applyEncoding ) + { + return applyEncoding && input != null ? ODataUriFactory.encodeQuery(input) : input; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/StructuredQuery.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/StructuredQuery.java new file mode 100644 index 000000000..030b2caf9 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/StructuredQuery.java @@ -0,0 +1,294 @@ +package com.sap.cloud.sdk.datamodel.odata.client.query; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.common.base.Strings; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.OrderExpression; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueBoolean; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * {@code StructuredQuery} acts as a builder for OData 2.0 or 4.0 queries. It assists with assembling request parameters + * such as {@code $select, $filter, ...}. This API does not differentiate between OData versions. Only leveraging + * features e.g. within filters that conform to the selected protocol version is the responsibility of the consumer. + */ +@RequiredArgsConstructor( access = AccessLevel.PRIVATE ) +public final class StructuredQuery implements QuerySerializable +{ + /** + * The structured property field name. + */ + @Getter + @Nonnull + private final String entityOrPropertyName; + + @Getter + private final boolean isRoot; + + @Getter + private final ODataProtocol protocol; + + @Getter + @Nonnull + private final Collection simpleSelectors = new LinkedHashSet<>(); + @Getter + @Nonnull + private final Collection complexSelectors = new LinkedHashSet<>(); + @Getter + @Nonnull + private final Collection filters = new ArrayList<>(); + @Getter + @Nonnull + private final Map customParameters = new LinkedHashMap<>(); + @Getter + @Nullable + private OrderExpression orderBy = null; + + @Getter + @Nullable + Number top; + @Getter + @Nullable + Number skip; + @Getter + @Nullable + String search; + + /** + * Create a {@code StructuredQuery} for building up OData 2.0 or 4.0 queries. + * + * @param entityName + * The entity collection to be queried. + * + * @param protocol + * The {@link ODataProtocol} version this query should conform to. + * @return A new {@code StructuredQuery} object. + */ + @Nonnull + public static StructuredQuery onEntity( @Nonnull final String entityName, @Nonnull final ODataProtocol protocol ) + { + return new StructuredQuery(entityName, true, protocol); + } + + /** + * Create a nested query on a property. This is an OData 4.0 specific feature. + * + * @param fieldName + * The property that is to be queried. + * @param protocol + * The {@link ODataProtocol} version this query should conform to. + * + * @return A new {@code StructuredQuery} object. + */ + @Nonnull + public static + StructuredQuery + asNestedQueryOnProperty( @Nonnull final String fieldName, @Nonnull final ODataProtocol protocol ) + { + return new StructuredQuery(fieldName, false, protocol); + } + + /** + * Query modifier to limit which field values of the entity get fetched and populated. + * + * @param fields + * Properties to be selected. + * @return This query object with the added selections. + */ + @Nonnull + public StructuredQuery select( @Nonnull final String... fields ) + { + simpleSelectors.addAll(Arrays.asList(fields)); + return this; + } + + /** + * Query modifier to limit which complex and navigational properties will be expanded (and thus selected). Such + * expansions are represented again through structured queries. + * + * @param subqueries + * Query objects on properties to be expanded. The {@link StructuredQuery#getEntityOrPropertyName()} will + * be the key for fields that should be expanded. + * @return This query object with the added selections. + */ + @Nonnull + public StructuredQuery select( @Nonnull final StructuredQuery... subqueries ) + { + final List queries = Arrays.asList(subqueries); + // If there are already expands on the same properties as the ones passed in -> remove them + // That avoids duplications e.g. $expand=BestFriend,BestFriend,BestFriend($select=FirstName) + final List fields = + queries.stream().map(StructuredQuery::getEntityOrPropertyName).collect(Collectors.toList()); + + complexSelectors.removeIf(selector -> fields.contains(selector.getEntityOrPropertyName())); + complexSelectors.addAll(queries); + return this; + } + + /** + * Query modified to limit which entities should be contained in the result set. + * + * @param filters + * Filter objects on properties to be filtered. + * @return This query object with the added filters. + */ + @Nonnull + @SuppressWarnings( "varargs" ) + public StructuredQuery filter( @Nonnull final ValueBoolean... filters ) + { + this.filters.addAll(Arrays.asList(filters)); + return this; + } + + /** + * Query modifier to limit how many entities should be contained in the result set. + * + * @param top + * The number of entities to include in the result set at most. + * @return This query object with the top limit included. + */ + @Nonnull + public StructuredQuery top( @Nonnull final Number top ) + { + this.top = top; + return this; + } + + /** + * Query modifier to skip a certain amount of results before then adding entities. + * + * @param skip + * The number of entities that shall be skipped before actually filling the result set. + * @return This query object with the skip operation included. + */ + @Nonnull + public StructuredQuery skip( @Nonnull final Number skip ) + { + this.skip = skip; + return this; + } + + /** + * Adds an {@code orderBy} expression to this query object. + *

+ * If there is no {@code orderBy} operation present yet, a new one will be created. + *

+ * + * @param field + * The name of the field that should for ordering the result set. + * @param order + * The {@link Order}. + * @return This query object with the order by operation added. + */ + @Nonnull + public StructuredQuery orderBy( @Nonnull final String field, @Nonnull final Order order ) + { + if( orderBy == null ) { + orderBy = OrderExpression.of(field, order); + } else { + orderBy.and(field, order); + } + return this; + } + + /** + * Sets the {@code orderBy} operation for this query object, overwriting any existing {@code orderBy} operations. + * + * @param ordering + * The {@link OrderExpression} to be used. + * @return This query object with the given {@code ordering} set. + */ + @Nonnull + public StructuredQuery orderBy( @Nonnull final OrderExpression ordering ) + { + orderBy = ordering; + return this; + } + + /** + * Sets the {@code search} operation for this query object. + * + * @param search + * The value to be searched for. + * @return This query object with the {@code search} operation set. + */ + @Nonnull + public StructuredQuery search( @Nonnull final String search ) + { + this.search = search; + return this; + } + + /** + * Adds a custom query parameter in the form of a key=value pair. This will not override any parameters set via + * {@link #select(String...) select}, {@link #filter(ValueBoolean...) filter} etc. + * + * @param key + * The parameter key. Must not be null or empty. + * @param value + * The parameter value. + * @return This query object with the added parameter + * + * @throws IllegalArgumentException + * if the key is null or empty + * @throws IllegalStateException + * if this query object is a nested query + */ + @Nonnull + public StructuredQuery withCustomParameter( @Nonnull final String key, @Nullable final String value ) + { + if( Strings.isNullOrEmpty(key) ) { + throw new IllegalArgumentException("Custom parameter key must not be null or empty."); + } + // if the query is inside an expand it is not corresponding to an HTTP query + // so here only odata parameters are allowed + else if( !isRoot() ) { + throw new IllegalStateException( + "Custom query parameters can only be added to the HTTP query but not to nested OData queries on navigation properties."); + } + customParameters.put(key, value); + return this; + } + + /** + * Requests an inline count by adding the system query option {@code $inlinecount} (OData V2) or {@code $count} + * (OData V4). + * + * @return This query object with the added parameter + */ + @Nonnull + public StructuredQuery withInlineCount() + { + final Map.Entry queryOption = getProtocol().getQueryOptionInlineCount(true); + customParameters.put(queryOption.getKey(), queryOption.getValue()); + return this; + } + + @Nonnull + @Override + public String getEncodedQueryString() + { + return QuerySerializer.serializeAndEncodeQuery(this, true); + } + + @Nonnull + @Override + public String getQueryString() + { + return QuerySerializer.serializeAndEncodeQuery(this, false); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/AbstractODataParameters.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/AbstractODataParameters.java new file mode 100644 index 000000000..0abf559c7 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/AbstractODataParameters.java @@ -0,0 +1,180 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.Expressions; + +import io.vavr.Tuple; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Abstract class to build parameter expressions for the URL path. Parameters can resemble an entity key or function + * parameters. + */ +@RequiredArgsConstructor( access = AccessLevel.PACKAGE ) +public abstract class AbstractODataParameters +{ + @Getter( AccessLevel.PACKAGE ) + @Nonnull + private final Map parameters = new LinkedHashMap<>(); + + /** + * The {@link ODataProtocol} these parameters should conform to. + */ + @Getter + @Nonnull + private final ODataProtocol protocol; + + /** + * Add a parameter. + * + * @param parameterName + * Name of the entity property or function parameters. + * @param value + * Property value, assumed to be a primitive. + * @param + * Type of the primitive value. + * @throws IllegalArgumentException + * When a parameter by that idenfitier already exists or primitive type is not supported. + */ + void addParameterInternal( @Nonnull final String parameterName, @Nullable final PrimitiveT value ) + { + if( parameters.containsKey(parameterName) ) { + throw new IllegalArgumentException( + "Cannot add parameter \"" + parameterName + "\": A parameter by that name already exists."); + } + parameters.put(parameterName, Expressions.createOperand(value)); + } + + /** + * Convenience method to add multpiple parameters at once. + * + * @throws IllegalArgumentException + * When one of the values is null. + * + * @see #addParameterInternal(String, Object) + */ + void addParameterSetInternal( @Nonnull final Map properties ) + { + properties.forEach(this::addParameterInternal); + } + + /** + * Serializes all parameters into an encoded URL path segment. The format is as follows: + *

+ *

    + *
  • An empty set of parameters will yield {@code ()}
  • + *
  • A single parameter entry will yield {@code (value)}
  • + *
  • Multiple of parameters will yield {@code (key1=val,key2=val)}
  • + *
+ *

+ * + * @return Encoded URL string representation of the parameters. + */ + @Nonnull + public String toEncodedString() + { + return toStringInternal(UriEncodingStrategy.REGULAR, ParameterFormat.PATH_SHORT); + } + + /** + * Serializes all parameters into an encoded URL path segment. The format is as follows: + *

+ *

    + *
  • An empty set of parameters will yield {@code ()}
  • + *
  • A single parameter entry will yield {@code (value)}
  • + *
  • Multiple of parameters will yield {@code (key1=val,key2=val)}
  • + *
+ *

+ * + * @param strategy + * The URI encoding strategy. + * @return Encoded URL string representation of the parameters. + */ + @Nonnull + public String toEncodedString( @Nonnull final UriEncodingStrategy strategy ) + { + return toStringInternal(strategy, ParameterFormat.PATH_SHORT); + } + + /** + * Serializes all parameters into an unencoded URL path segment. The format is as follows: + *

+ *

    + *
  • An empty set of parameters will yield {@code ()}
  • + *
  • A single parameter entry will yield {@code (value)}
  • + *
  • Multiple of parameters will yield {@code (key1=val,key2=val)}
  • + *
+ *

+ * + * @return String representation of the parameters. + */ + @Nonnull + @Override + public String toString() + { + return toStringInternal(UriEncodingStrategy.NONE, ParameterFormat.PATH_SHORT); + } + + @Nonnull + String toStringInternal( @Nonnull final UriEncodingStrategy strategy, final ParameterFormat format ) + { + final Function encoder = + format.isQuery() ? strategy.getQueryPercentEscaper()::escape : strategy.getPathPercentEscaper()::escape; + + // case short format: single key value without field-name + if( format == ParameterFormat.PATH_SHORT && parameters.size() == 1 ) { + final Expressions.OperandSingle singleValue = parameters.values().iterator().next(); + String parameterValue = singleValue.getExpression(protocol); + parameterValue = encoder.apply(parameterValue); + parameterValue = String.format("(%s)", parameterValue); + + return parameterValue; + } + + // case long format: compound key with field-name/value pair(s) + final String keyDelimiter = format.isQuery() ? "&" : ","; + String keys = + parameters + .entrySet() + .stream() + .map(param -> Tuple.of(param.getKey(), param.getValue())) + .map(param -> param.map2(val -> val.getExpression(protocol))) + .map(param -> param.map2(encoder)) + .map(param -> param.apply(( key, val ) -> String.format("%s=%s", key, val))) + .collect(Collectors.joining(keyDelimiter)); + + if( !format.isQuery() ) { + keys = String.format("(%s)", keys); + } + return keys; + } + + enum ParameterFormat + { + PATH(false, false), + PATH_SHORT(false, true), + QUERY(true, false); + + @Getter + private final boolean isQuery; + + @Getter + private final boolean isShort; + + ParameterFormat( final boolean isQuery, final boolean isShort ) + { + this.isQuery = isQuery; + this.isShort = isShort; + } + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ETagSubmissionStrategy.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ETagSubmissionStrategy.java new file mode 100644 index 000000000..f2d209d3a --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ETagSubmissionStrategy.java @@ -0,0 +1,47 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import io.vavr.control.Option; + +/** + * Strategy options for sending IF-MATCH headers. + */ +public enum ETagSubmissionStrategy +{ + /** + * Send an IF-MATCH header, if and only if a version identifier is defined on an {@code VdmEntity}. + */ + SUBMIT_ETAG_FROM_ENTITY, + /** + * Do not send any IF-MATCH header. + */ + SUBMIT_NO_ETAG, + /** + * Send a wildcard ({@code *}) in the IF-MATCH header matching all version identifiers. This is essentially a force + * overwrite. + */ + SUBMIT_ANY_MATCH_ETAG; + + /** + * Returns the value of the IF-MATCH header to be sent based on the given {@code maybeVersionIdentifier}. + * + * @param maybeVersionIdentifier + * The version identifier to be sent in the IF-MATCH header. + * @return The value of the IF-MATCH header to be sent, or {@code null} if no header should be sent. + */ + @Nullable + public String getHeaderFromVersionIdentifier( @Nonnull final Option maybeVersionIdentifier ) + { + switch( this ) { + case SUBMIT_ANY_MATCH_ETAG: + return "*"; + case SUBMIT_NO_ETAG: + return null; + case SUBMIT_ETAG_FROM_ENTITY: + default: + return maybeVersionIdentifier.filter(s -> !s.isEmpty()).getOrNull(); + } + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/HttpEntityReader.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/HttpEntityReader.java new file mode 100644 index 000000000..9753e74e4 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/HttpEntityReader.java @@ -0,0 +1,129 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.function.BiFunction; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpEntity; + +import com.google.gson.JsonElement; +import com.google.gson.JsonIOException; +import com.google.gson.JsonParser; +import com.google.gson.stream.JsonReader; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataDeserializationException; + +import io.vavr.CheckedFunction1; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Utility class to leverage reading from an HTTP response. + */ +@Slf4j +@RequiredArgsConstructor( access = AccessLevel.PRIVATE ) +final class HttpEntityReader +{ + private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + + @Nullable + private final HttpEntity entity; + + @Nonnull + private final BiFunction errorHandler; + + @Nonnull + private final ODataProtocol protocol; + + @Nullable + private T read( @Nonnull final CheckedFunction1 streamConsumer ) + { + if( entity == null ) { + final String msg = protocol + " response does not contain an HTTP entity."; + log.warn(msg); + throw errorHandler.apply(msg, null); + } + + try( InputStream content = entity.getContent() ) { + return streamConsumer.apply(content); + } + catch( final IOException | JsonIOException e ) { + final String msg = protocol + " response stream cannot be read for HTTP entity: "; + log.debug(msg + entity, e); + throw errorHandler.apply(msg + entity.getClass().getName(), e); + } + catch( final UnsupportedOperationException e ) { + final String msg = protocol + " response entity content cannot be represented as stream object."; + log.debug(msg, e); + throw errorHandler.apply(msg, e); + } + // CHECKSTYLE:OFF + catch( final Throwable e ) { + final String msg = "A problem occurred while streaming the " + protocol + " response."; + log.debug(msg, e); + throw errorHandler.apply(msg, e); + } + // CHECKSTYLE:ON + } + + /** + * Protocol dependent method to consume the InputStream from the HTTP response entity. This method parses the whole + * JSON tree eagerly. + * + * @param result + * The result object to read from. + * @param elementConsumer + * Function to turn a GSON element to a generic result. + * @param + * The generic return type. + * @return The response. + * @throws ODataDeserializationException + * When streamed deserialization process failed. + */ + static T read( + @Nonnull final ODataRequestResult result, + @Nonnull final CheckedFunction1 elementConsumer ) + { + return stream(result, reader -> { + final JsonElement rootElement = JsonParser.parseReader(reader); + return elementConsumer.apply(rootElement); + }); + } + + /** + * Protocol dependent method to consume the InputStream from the HTTP response entity. This method allows for lazy + * consuming of the JSON tree. + * + * @param result + * The result object to read from. + * @param readerConsumer + * The consumer of the JsonReader. + * @param + * The generic return type. + * @return The response. + * @throws ODataDeserializationException + * When streamed deserialization process failed. + */ + static T stream( + @Nonnull final ODataRequestResult result, + @Nonnull final CheckedFunction1 readerConsumer ) + { + final ClassicHttpResponse httpResponse = result.getHttpResponse(); + final ODataRequestGeneric request = result.getODataRequest(); + final BiFunction errorHandler = + ( msg, e ) -> new ODataDeserializationException(request, httpResponse, msg, e); + + return new HttpEntityReader(httpResponse.getEntity(), errorHandler, request.getProtocol()).read(inputStream -> { + final JsonReader reader = new JsonReader(new InputStreamReader(inputStream, DEFAULT_CHARSET)); + return readerConsumer.apply(reader); + }); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartHttpResponse.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartHttpResponse.java new file mode 100644 index 000000000..6ab2173ec --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartHttpResponse.java @@ -0,0 +1,166 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.io.Serial; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpVersion; +import org.apache.hc.core5.http.NameValuePair; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.apache.hc.core5.http.message.BasicHeader; +import org.apache.hc.core5.http.message.BasicNameValuePair; +import org.apache.hc.core5.http.message.StatusLine; + +import io.vavr.control.Try; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Helper class to construct an HttpResponse object on behalf of serialized HTTP protocol content. + */ +@Slf4j +class MultipartHttpResponse extends BasicClassicHttpResponse +{ + @Serial + private static final long serialVersionUID = 1L; + + private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + private static final Pattern PATTERN_STATUS_LINE = Pattern.compile("^HTTP/(\\d).(\\d) (\\d+) (.*)"); + private static final Pattern PATTERN_NEW_LINE = Pattern.compile("\\R"); + + @Nullable + @Getter + private final Integer contentId; + + private MultipartHttpResponse( + @Nonnull final StatusLine statusLine, + @Nonnull final List
headers, + @Nonnull final HttpEntity entity, + @Nullable final Integer contentId ) + { + super(statusLine.getStatusCode(), statusLine.getReasonPhrase()); + setVersion(statusLine.getProtocolVersion()); + headers.forEach(this::addHeader); + setEntity(entity); + this.contentId = contentId; + } + + /** + * Factory method to construct an {@link MultipartHttpResponse} on behalf of serialized HTTP protocol content: First + * line is the status line, the following lines are headers, the optional body is introduced with an empty line. + * + * @param entry + * The HTTP protocol content, consisting of status line, headers, (empty-line) and payload. + * @return A new HTTP response instance. + */ + @Nonnull + public static MultipartHttpResponse ofHttpContent( @Nonnull final MultipartParser.Entry entry ) + { + final Matcher contentIdMatcher = + Pattern + .compile("^Content-ID:\\s*(\\d+)\\s*$", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE) + .matcher(entry.getMeta()); + final Integer contentId = contentIdMatcher.find() ? Integer.parseInt(contentIdMatcher.group(1)) : null; + final String[] lines = PATTERN_NEW_LINE.split(entry.getPayload()); + + final StatusLine statusLine = getStatusLine(lines[0]); + + final StringBuilder payload = new StringBuilder(); + final StringBuilder header = new StringBuilder(); + + boolean isHeaders = true; + for( int i = 1; i < lines.length; i++ ) { + if( isHeaders ) { + if( lines[i].isEmpty() ) { + isHeaders = false; + } else { + header.append(lines[i]).append('\n'); + } + } else { + payload.append(lines[i]).append('\n'); + } + } + + final List
headers = getHeadersFromString(header.toString()); + final ContentType contentType = getContentType(headers).orElse(ContentType.APPLICATION_JSON); + final ContentType contentTypeCharset = withFallbackCharset(contentType); + final StringEntity httpEntity = new StringEntity(payload.toString(), contentTypeCharset); + return new MultipartHttpResponse(statusLine, headers, httpEntity, contentId); + } + + @Nonnull + static List
getHeadersFromString( @Nonnull final String headerString ) + { + final List
result = new ArrayList<>(); + for( final String headerLine : PATTERN_NEW_LINE.split(headerString.trim()) ) { + final String[] split = headerLine.split(":", 2); + result.add(new BasicHeader(split[0].trim(), split.length > 1 ? split[1].trim() : "")); + } + return result; + } + + @Nonnull + static Optional getContentType( @Nonnull final List
headers ) + { + return headers + .stream() + .filter(h -> HttpHeaders.CONTENT_TYPE.equalsIgnoreCase(h.getName())) + .map(NameValuePair::getValue) + .map(contentType -> Try.of(() -> ContentType.parse(contentType)).getOrNull()) + .filter(Objects::nonNull) + .findFirst(); + } + + @Nonnull + private static ContentType withFallbackCharset( @Nonnull final ContentType contentType ) + { + if( contentType.getCharset() != null ) { + return contentType; + } + return contentType + .withParameters(new BasicNameValuePair("charset", MultipartHttpResponse.DEFAULT_CHARSET.name())); + } + + @Nonnull + private static StatusLine getStatusLine( @Nonnull final String firstLine ) + { + final Matcher m = PATTERN_STATUS_LINE.matcher(firstLine); + if( m.find() ) { + final int major = Integer.parseInt(m.group(1)); + final int minor = Integer.parseInt(m.group(2)); + final int code = Integer.parseInt(m.group(3)); + final String reason = m.group(4); + return new StatusLine(new HttpVersion(major, minor), code, reason); + } + log.error("Failed to construct status line for HTTP protocol response: {}", firstLine); + return new StatusLine(HttpVersion.HTTP_1_1, 0, "Unknown"); + } + + @Serial + private void readObject( java.io.ObjectInputStream in ) + throws java.io.NotSerializableException + { + throw new java.io.NotSerializableException(getClass().getName()); + } + + @Serial + private void writeObject( java.io.ObjectOutputStream out ) + throws java.io.NotSerializableException + { + throw new java.io.NotSerializableException(getClass().getName()); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartParser.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartParser.java new file mode 100644 index 000000000..194d52356 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartParser.java @@ -0,0 +1,264 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import javax.annotation.Nonnull; + +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpResponse; + +import io.vavr.control.Try; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.Value; +import lombok.extern.slf4j.Slf4j; + +/** + * Helper class to parse an {@link InputStream} to an {@link Iterable} multi-part response. One part can have multiple + * segments (e.g. changeset). For that reason the API exposes {@code Iterable>}. + */ +@Slf4j +@RequiredArgsConstructor( access = AccessLevel.PACKAGE ) +class MultipartParser implements AutoCloseable +{ + private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + private static final String MULTIPART_MIXED_MIME_TYPE = "multipart/mixed"; + private static final String MULTIPART_MIXED_BOUNDARY = "boundary"; + + @Nonnull + private final BufferedReader reader; + + @Nonnull + private final String delimiter; + + /** + * Factory method to instantiate an {@link MultipartParser} object. + * + * @param httpResponse + * The HTTP response to read from: content-type, input-stream and charset. + * @return A new instance of {@link MultipartParser}. + */ + @SuppressWarnings( "PMD.CloseResource" ) // The attached InputStream is closed by the MultipartParser itself. + public static MultipartParser ofHttpResponse( @Nonnull final ClassicHttpResponse httpResponse ) + { + final HttpEntity entity = httpResponse.getEntity(); + if( entity == null ) { + throw new IllegalStateException("HTTP response does not contain a content."); + } + + final InputStream inputStream = + Try + .of(entity::getContent) + .getOrElseThrow(e -> new IllegalStateException("Unable to read HTTP content.", e)); + + final String delimiter = + getDelimiterFromHttpResponse(httpResponse) + .orElseThrow(() -> new IllegalStateException("No delimiter found in HTTP header.")); + + final Charset charsetValue = + Try + .of(() -> ContentType.parse(entity.getContentType())) + .onFailure(e -> log.debug("Unable to detect charset, using to default charset {}", DEFAULT_CHARSET)) + .toOption() + .map(ContentType::getCharset) + .filter(Objects::nonNull) + .peek(c -> log.debug("Using detected charset {}", c)) + .getOrElse(DEFAULT_CHARSET); + + return ofInputStream(inputStream, charsetValue, delimiter); + } + + /** + * Factory method to instantiate an {@link MultipartParser} object. + * + * @param contentStream + * The {@link InputStream} which is read from. + * @param contentCharset + * The charset of the content. + * @param delimiter + * The delimiter, usually derived from content type. + * @return A new instance of {@link MultipartParser}. + */ + @Nonnull + public static MultipartParser ofInputStream( + @Nonnull final InputStream contentStream, + @Nonnull final Charset contentCharset, + @Nonnull final String delimiter ) + { + final BufferedReader reader = + Try + .of(() -> new InputStreamReader(contentStream, contentCharset)) + .map(BufferedReader::new) + .getOrElseThrow(e -> new IllegalStateException("Unable to initialize multi-part parsing.", e)); + + return new MultipartParser(reader, delimiter); + } + + /** + * Get the iterable, raw multi-part response. This method can be invoked once. It internally caches the objects, so + * the returned List can be iterated multiple times. The {@link List} objects are eagerly evaluated. + * + * @return An iterable multi-part response. + */ + @Nonnull + public List> toList() + { + return toList(Function.identity()); + } + + /** + * Get the iterable multi-part response, optionally parsed with a custom {@link Function}. This method can be + * invoked once. It internally caches the objects, so the returned List can be iterated multiple times. The + * {@link List} objects are eagerly evaluated. + * + * @param + * Generic item type. + * @param transformation + * Generic item transition logic. + * + * @return An iterable multi-part response, with custom deserialization. + */ + @Nonnull + public List> toList( @Nonnull final Function transformation ) + { + return toStream() + .map(segments -> segments.map(transformation).collect(Collectors.toList())) + .collect(Collectors.toList()); + } + + /** + * Get the iterable, raw multi-part response. This method only works as long as the underlying {@link InputStream} + * of {@link MultipartParserReader} is not depleted. Hence for proper results, this method can usually only be + * invoked once. The resulting {@link Iterable} objects are lazily evaluated and remain uncached. + * + * @return An iterable multi-part response. + */ + @Nonnull + public Stream> toStream() + { + // Create a stream from a spliterator but only retrieve the spliterator upon terminal operation of stream. + final Stream> result = + StreamSupport.stream(this::createSpliterator, MultipartSpliterator.CHARACTERISTICS, false); + + // Translate stream of spliterators to stream of stream. Make sure all previous spliterators were fully consumed. + final AtomicReference> previous = new AtomicReference<>(Spliterators.emptySpliterator()); + + return result.map(currentSpliterator -> { + // if previous spliterator was not yet fully consumed, do so with the remaining elements + previous.getAndSet(currentSpliterator).forEachRemaining(item -> log.trace("Skipping element {}", item)); + + // return Stream of current spliterator + return StreamSupport.stream(currentSpliterator, false); + }); + } + + private Spliterator> createSpliterator() + { + final MultipartParserReader batchRead = new MultipartParserReader(reader, delimiter); + + // position reader after first batch delimiter + batchRead.untilDelimiter(); + + return new MultipartSpliterator<>(() -> { + if( batchRead.isFinished() ) { + Try.run(reader::close).onFailure(e -> log.debug("Failed to close reader.", e)); + return null; + } + final String segmentHead = batchRead.untilPayload(); + final Optional maybeChangesetBoundary = getDelimiterFromString(segmentHead); + + if( maybeChangesetBoundary.isPresent() ) { // multiple responses in changeset + return getChangeset(maybeChangesetBoundary.get(), batchRead); + } else { // single response + final String content = batchRead.untilDelimiter(); + return Collections.singleton(new Entry(segmentHead, content)).spliterator(); + } + }); + } + + @Nonnull + private + Spliterator + getChangeset( @Nonnull final String changesetDelimiter, final MultipartParserReader batchRead ) + { + final MultipartParserReader changesetRead = new MultipartParserReader(reader, changesetDelimiter); + + // position reader after first sub-segment delimiter + changesetRead.untilDelimiter(); + + return new MultipartSpliterator<>(() -> { + if( changesetRead.isFinished() ) { + return null; + } + final String subSegmentHead = changesetRead.untilPayload(); + log.trace("Iterating Batch changeset segment with header {}", subSegmentHead); + + final String content = changesetRead.untilDelimiter(); + if( changesetRead.isFinished() ) { + batchRead.untilDelimiter(); // position reader to next batch delimiter + } + return new Entry(subSegmentHead, content); + }); + } + + @Nonnull + private static Optional getDelimiterFromString( @Nonnull final String segmentHead ) + { + final List
segmentHeaders = MultipartHttpResponse.getHeadersFromString(segmentHead); + final Optional segmentContentType = MultipartHttpResponse.getContentType(segmentHeaders); + return segmentContentType.flatMap(MultipartParser::getDelimiterFromContentType); + } + + @Nonnull + private static Optional getDelimiterFromContentType( @Nonnull final ContentType contentType ) + { + final String boundary = contentType.getParameter(MULTIPART_MIXED_BOUNDARY); + return boundary == null ? Optional.empty() : Optional.of("--" + boundary); + } + + @Nonnull + private static Optional getDelimiterFromHttpResponse( @Nonnull final HttpResponse httpResponse ) + { + final List
headers = Arrays.asList(httpResponse.getHeaders()); + final ContentType contentType = MultipartHttpResponse.getContentType(headers).orElse(null); + if( contentType == null ) { + return Optional.empty(); + } + if( !MULTIPART_MIXED_MIME_TYPE.equalsIgnoreCase(contentType.getMimeType()) ) { + log.debug("Unexpected value in HTTP header \"Content-Type\" of OData batch response: {}", contentType); + } + return getDelimiterFromContentType(contentType); + } + + @Override + public void close() + { + Try.run(reader::close).onFailure(e -> log.warn("Failed to close HTTP entity multi-part parser.", e)); + } + + @Value + static class Entry + { + String meta; + String payload; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartParserReader.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartParserReader.java new file mode 100644 index 000000000..0c7d758ab --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartParserReader.java @@ -0,0 +1,85 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.function.Predicate; + +import javax.annotation.Nonnull; + +import lombok.Getter; + +/** + * Helper class to manage read operations on a Reader object. + */ +class MultipartParserReader +{ + @Nonnull + private final BufferedReader reader; + + @Nonnull + final String delimiter; + + @Nonnull + private final String delimiterEnd; + + @Getter + private boolean started = false; + + @Getter + private boolean finished = false; + + MultipartParserReader( @Nonnull final BufferedReader reader, @Nonnull final String delimiter ) + { + this.reader = reader; + this.delimiter = delimiter; + this.delimiterEnd = delimiter + "--"; + } + + /** + * Reads and returns the String with new-line separator "\n" until (including) delimiter. + * + * @return The contents until (including) next separator. + */ + @Nonnull + public String untilDelimiter() + { + return readWhile(line -> !line.equals(delimiter) && !line.equals(delimiterEnd)); + } + + /** + * Read and return the String with new-line separator "\n" until next empty line. This signals the start of HTTP + * payload. + * + * @return The contents until (excluding) start of HTTP payload. + */ + @Nonnull + public String untilPayload() + { + return readWhile(s -> s != null && !s.isEmpty()); + } + + @Nonnull + private String readWhile( @Nonnull final Predicate recordFlag ) + { + started = true; + final StringBuilder sb = new StringBuilder(); + String line; + try { + while( !finished && (line = reader.readLine()) != null ) { + if( !recordFlag.test(line) ) { + if( delimiterEnd.equals(line) ) { + finished = true; + } + return sb.toString(); + } + sb.append(line).append('\n'); + } + } + catch( final IOException e ) { + throw new UncheckedIOException("Unable to parse multi-part text.", e); + } + finished = true; + return sb.toString(); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartSpliterator.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartSpliterator.java new file mode 100644 index 000000000..b9db73339 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartSpliterator.java @@ -0,0 +1,52 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.util.Spliterator; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +/** + * Helper class to enable a {@link Supplier} based {@link Spliterator} implementation. + * + * @param + * Generic type of produced items. + */ +@RequiredArgsConstructor( access = AccessLevel.PACKAGE ) +class MultipartSpliterator implements Spliterator +{ + static final int CHARACTERISTICS = IMMUTABLE | ORDERED | NONNULL; + + // if Supplier returns a null, the end is signalled + private final Supplier producer; + + @Override + public boolean tryAdvance( final Consumer action ) + { + final T result = producer.get(); + if( result != null ) { + action.accept(result); + return true; + } + return false; + } + + @Override + public Spliterator trySplit() + { + return null; // don't support split + } + + @Override + public long estimateSize() + { + return Long.MAX_VALUE; + } + + @Override + public int characteristics() + { + return CHARACTERISTICS; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/NumberDeserializationStrategy.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/NumberDeserializationStrategy.java new file mode 100644 index 000000000..193ddf262 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/NumberDeserializationStrategy.java @@ -0,0 +1,43 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.util.function.Consumer; + +import javax.annotation.Nonnull; + +import com.google.gson.GsonBuilder; + +import lombok.RequiredArgsConstructor; + +/** + * Number deserialization strategy to determine behavior for JSON numbers without target type references. + */ +@RequiredArgsConstructor +public enum NumberDeserializationStrategy +{ + /** + * Double strategy to deserialize JSON numbers to double, if no target type references are specified. + */ + DOUBLE(gsonBuilder -> { + // default behavior of GSON + }), + + /** + * BigDecimal strategy to deserialize JSON numbers to BigDecimal, if no target type references are specified. + */ + BIG_DECIMAL(gsonBuilder -> { + gsonBuilder.setObjectToNumberStrategy(com.google.gson.ToNumberPolicy.BIG_DECIMAL); // FQN for backwards-compatibility + }); + + private final Consumer adapter; + + /** + * Adjust the deserialization strategy for untyped numbers. + * + * @param gsonBuilder + * The GsonBuilder to change. + */ + void decorate( @Nonnull final GsonBuilder gsonBuilder ) + { + adapter.accept(gsonBuilder); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataEntityKey.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataEntityKey.java new file mode 100644 index 000000000..20ba20346 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataEntityKey.java @@ -0,0 +1,131 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.util.Map; +import java.util.Set; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; + +import lombok.extern.slf4j.Slf4j; + +/** + * Fluent API class to build and hold entity keys. A key is comprised of one or multiple individual key-value pairs. + */ +@Slf4j +public class ODataEntityKey extends AbstractODataParameters +{ + /** + * Create a new, empty entity key for the given protocol version. + * + * @param protocol + * The {@link ODataProtocol} version this key should conform to. + */ + public ODataEntityKey( @Nonnull final ODataProtocol protocol ) + { + super(protocol); + } + + /** + * Get all field names that are part of this key. + * + * @return A set of entity property field names. + */ + @Nonnull + public Set getFieldNames() + { + return getParameters().keySet(); + } + + /** + * Add an entity property to this key. + * + * @param propertyName + * Name of the property (derived from the EDMX) + * @param value + * Property value, assumed to be a primitive. + * @param + * Type of the primitive value. + * @return The modified instance. + * + * @throws IllegalArgumentException + * When a parameter by that idenfitier already exists or primitive type is not supported. + */ + @Nonnull + public ODataEntityKey addKeyProperty( + @Nonnull final String propertyName, + @Nullable final PrimitiveT value ) + { + super.addParameterInternal(propertyName, value); + return this; + } + + /** + * Add properties to the OData entity key. + * + * @param properties + * The key-value mapping. + * @return The same instance. + * + * @throws IllegalArgumentException + * When the map contains a primitive type that is not supported. + * + * @see #addKeyProperty(String, Object) + */ + @Nonnull + public ODataEntityKey addKeyProperties( @Nonnull final Map properties ) + { + super.addParameterSetInternal(properties); + return this; + } + + /** + * Create an instance of {@link ODataEntityKey} from a generic key-value composition. + * + * @param key + * Key-value pairs of entity properties and their values. + * @param protocol + * The {@link ODataProtocol} version this key should conform to. + * @return A new instance of {@link ODataEntityKey}. + * + * @throws IllegalArgumentException + * When the map contains a primitive type that is not supported. + * + * @see #addKeyProperty(String, Object) + */ + @Nonnull + public static ODataEntityKey of( @Nonnull final Map key, @Nonnull final ODataProtocol protocol ) + { + return new ODataEntityKey(protocol).addKeyProperties(key); + } + + @Override + @Nonnull + public String toEncodedString( @Nonnull final UriEncodingStrategy strategy ) + { + if( getParameters().isEmpty() ) { + log + .warn( + "The current entity key is empty. Using it within a request will cause the request to fail as empty entity keys are not allowed."); + } + return super.toEncodedString(strategy); + } + + /** + * Serializes the entity key into an unencoded OData URL format for entity keys. + * + * @return Encoded URL string representation of entity key. + */ + @Nonnull + @Override + public String toString() + { + if( getParameters().isEmpty() ) { + log + .warn( + "The current entity key is empty. Using it within a request will cause the request to fail as empty entity keys are not allowed."); + } + return super.toString(); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataFormat.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataFormat.java new file mode 100644 index 000000000..950213bf6 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataFormat.java @@ -0,0 +1,55 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import javax.annotation.Nonnull; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * OData format for responses and requests. + */ +@RequiredArgsConstructor +enum ODataFormat +{ + /** + * JSON + */ + JSON("json", "application/json"), + + /** + * XML + */ + XML("xml", "application/xml"); + + private final String odataFormatValue; + + @Getter( AccessLevel.PACKAGE ) + @Nonnull + private final String httpAccept; + + @Nonnull + @Override + public String toString() + { + return odataFormatValue; + } + + /** + * Returns the {@code ODataFormat} for the given identifier or throws and {@code IllegalArgumentException} if the + * provided string is not a valid identifier. This operation is case-insensitive. + * + * @param value + * The string identifier of the {@code ODataFormat}. + * @return The {@code ODataFormat} associated with the identifier. + */ + public static ODataFormat getODataFormat( @Nonnull final String value ) + { + for( final ODataFormat f : values() ) { + if( f.toString().equalsIgnoreCase(value) ) { + return f; + } + } + throw new IllegalArgumentException(); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataFunctionParameters.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataFunctionParameters.java new file mode 100644 index 000000000..37942a752 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataFunctionParameters.java @@ -0,0 +1,144 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; + +/** + * Fluent API class to build and hold function parameters. + */ +public class ODataFunctionParameters extends AbstractODataParameters +{ + private final ParameterFormat parameterFormat; + + /** + * Create a new, empty set of parameters for an OData function. + * + * @param protocol + * The {@link ODataProtocol} version the parameters should conform to. + */ + public ODataFunctionParameters( @Nonnull final ODataProtocol protocol ) + { + super(protocol); + parameterFormat = protocol.isEqualTo(ODataProtocol.V2) ? ParameterFormat.QUERY : ParameterFormat.PATH; + } + + /** + * Create an instance of {@link ODataFunctionParameters} from a set of parameters. + * + * @param parameters + * Key-value pairs for parameter values. + * @param protocol + * The {@link ODataProtocol} version these parameters should conform to. + * @return A new instance of {@link ODataFunctionParameters}. + * + * @throws IllegalArgumentException + * When the map contains a primitive type that is not supported. + * + * @see #addParameter(String, Object) + */ + @Nonnull + public static + ODataFunctionParameters + of( @Nonnull final Map parameters, @Nonnull final ODataProtocol protocol ) + { + final ODataFunctionParameters functionParameters = new ODataFunctionParameters(protocol); + functionParameters.addParameterSetInternal(parameters); + + return functionParameters; + } + + /** + * Convenience method to create an empty set of function parameters. + * + * @param protocol + * The OData protocol version that the parameters should conform to. + * + * @return A new instance of {@link ODataFunctionParameters} + */ + @Nonnull + public static ODataFunctionParameters empty( @Nonnull final ODataProtocol protocol ) + { + return new ODataFunctionParameters(protocol); + } + + /** + * Add a parameter to function parameters. + * + * @param parameterName + * Name of the property (derived from the EDMX) + * @param value + * Property value, assumed to be a primitive. + * @param + * Type of the primitive value. + * @return The modified instance. + * + * @throws IllegalArgumentException + * When a parameter by that idenfitier already exists or primitive type is not supported. + */ + @Nonnull + public ODataFunctionParameters addParameter( + @Nonnull final String parameterName, + @Nullable final PrimitiveT value ) + { + addParameterInternal(parameterName, value); + return this; + } + + /** + * Serializes all parameters into an encoded URL path segment. The format is as follows: + *

+ *

    + *
  • An empty set of parameters will yield {@code ()}
  • + *
  • One or more parameters will yield {@code (key1=val,key2=val)}
  • + *
+ *

+ * + * @return Encoded URL string representation of the parameters. + */ + @Nonnull + @Override + public String toEncodedString() + { + return super.toStringInternal(UriEncodingStrategy.REGULAR, parameterFormat); + } + + /** + * Serializes all parameters into an encoded URL path segment. The format is as follows: + *

+ *

    + *
  • An empty set of parameters will yield {@code ()}
  • + *
  • One or more parameters will yield {@code (key1=val,key2=val)}
  • + *
+ *

+ * + * @return Encoded URL string representation of the parameters. + */ + @Nonnull + @Override + public String toEncodedString( @Nonnull final UriEncodingStrategy strategy ) + { + return super.toStringInternal(strategy, parameterFormat); + } + + /** + * Serializes all parameters into an unencoded URL path segment. The format is as follows: + *

+ *

    + *
  • An empty set of parameters will yield {@code ()}
  • + *
  • One or more parameters will yield {@code (key1=val,key2=val)}
  • + *
+ *

+ * + * @return String representation of the parameters. + */ + @Nonnull + @Override + public String toString() + { + return super.toStringInternal(UriEncodingStrategy.NONE, parameterFormat); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataGsonBuilder.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataGsonBuilder.java new file mode 100644 index 000000000..0706d7193 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataGsonBuilder.java @@ -0,0 +1,64 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.util.UUID; + +import javax.annotation.Nonnull; + +import com.google.gson.GsonBuilder; +import com.sap.cloud.sdk.datamodel.odata.client.adapter.BinaryTypeAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.adapter.DurationTypeAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.adapter.LocalDateTypeAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.adapter.LocalTimeTypeAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.adapter.OffsetDateTimeTypeAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.adapter.UuidTypeAdapter; +import com.sap.cloud.sdk.result.AnnotatedFieldGsonExclusionStrategy; +import com.sap.cloud.sdk.result.ElementName; +import com.sap.cloud.sdk.result.ElementNameGsonFieldNamingStrategy; + +/** + * Factory class to manage GSON references. + */ +public final class ODataGsonBuilder +{ + /** + * Construct a new GsonBuilder for serialization and deserialization of OData values. + * + * @return The GsonBuilder reference. + */ + @Nonnull + public static GsonBuilder newGsonBuilder() + { + return newGsonBuilder(NumberDeserializationStrategy.DOUBLE); + } + + /** + * Construct a new GsonBuilder for serialization and deserialization of OData values. + * + * @param numberStrategy + * The default number deserialization strategy to be used for untyped numbers. + * @return The GsonBuilder reference. + */ + @Nonnull + static GsonBuilder newGsonBuilder( @Nonnull final NumberDeserializationStrategy numberStrategy ) + { + final GsonBuilder gsonBuilder = + new GsonBuilder() + .disableHtmlEscaping() + .setFieldNamingStrategy(new ElementNameGsonFieldNamingStrategy()) + .setExclusionStrategies(new AnnotatedFieldGsonExclusionStrategy<>(ElementName.class)) + .registerTypeAdapter(UUID.class, new UuidTypeAdapter()) + .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeTypeAdapter()) + .registerTypeAdapter(Duration.class, new DurationTypeAdapter()) + .registerTypeAdapter(LocalTime.class, new LocalTimeTypeAdapter()) + .registerTypeAdapter(LocalDate.class, new LocalDateTypeAdapter()) + .registerTypeAdapter(byte[].class, new BinaryTypeAdapter()); + + numberStrategy.decorate(gsonBuilder); + + return gsonBuilder; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHealthyResponseValidator.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHealthyResponseValidator.java new file mode 100644 index 000000000..ba5b45ae9 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHealthyResponseValidator.java @@ -0,0 +1,110 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.message.StatusLine; + +import com.google.gson.JsonObject; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataDeserializationException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataResponseException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataServiceError; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataServiceErrorException; +import com.sap.cloud.sdk.result.GsonResultElementFactory; +import com.sap.cloud.sdk.result.GsonResultObject; +import com.sap.cloud.sdk.result.ResultObject; + +import io.vavr.control.Option; +import io.vavr.control.Try; + +/** + * Utility class to enable a healthy response validation. + */ +class ODataHealthyResponseValidator +{ + /** + * Check the HTTP response code and body of the OData request result. If the code indicates an unhealthy response, + * an exception will be thrown with further details. + * + * @param result + * The OData response object. + * @throws ODataResponseException + * When the response code infers an unhealthy state, i.e. when >= 400. + * @throws ODataServiceErrorException + * When the response contains an OData error message according to specification. + */ + static void requireHealthyResponse( @Nonnull final ODataRequestResult result ) + { + final ODataRequestGeneric originalRequest = result.getODataRequest(); + final StatusLine statusLine = result.getStatusLine(); + + if( statusLine != null && statusLine.getStatusCode() < HttpStatus.SC_BAD_REQUEST ) { // code < 400 + return; + } + + final ClassicHttpResponse httpResponse = result.getHttpResponse(); + final ODataRequestGeneric requestRelevantForException = + findPotentialBatchItem(httpResponse, originalRequest).getOrElse(originalRequest); + + final Integer statusCode = statusLine == null ? null : statusLine.getStatusCode(); + final String msg = "The HTTP response code (" + statusCode + ") indicates an error."; + + final Try odataError = Try.of(() -> loadErrorFromResponse(result)); + if( odataError.isSuccess() ) { + final String msgError = msg + " The OData service responded with an error message."; + throw new ODataServiceErrorException( + requestRelevantForException, + httpResponse, + msgError, + null, + odataError.get()); + } + throw new ODataResponseException(requestRelevantForException, httpResponse, msg, null); + } + + @Nonnull + private static + Option + findPotentialBatchItem( final HttpResponse httpResponse, final ODataRequestGeneric request ) + { + if( !(request instanceof ODataRequestBatch requestBatch) + || !(httpResponse instanceof MultipartHttpResponse multipartHttpResponse) ) { + return Option.none(); + } + @Nullable + final Integer failedBatchRequestNumber = multipartHttpResponse.getContentId(); + if( failedBatchRequestNumber == null ) { + return Option.none(); + } + + for( final ODataRequestBatch.BatchItem requestGeneric : requestBatch.getRequests() ) { + if( requestGeneric instanceof ODataRequestBatch.BatchItemChangeset changeset ) { + for( final ODataRequestBatch.BatchItemSingle single : changeset.getRequests() ) { + if( single.getContentId() == failedBatchRequestNumber ) { + return Option.of(single.getRequest()); + } + } + } else if( requestGeneric instanceof ODataRequestBatch.BatchItemSingle single + && single.getContentId() == failedBatchRequestNumber ) { + return Option.of(single.getRequest()); + } + } + return Option.none(); + } + + @Nonnull + private static ODataServiceError loadErrorFromResponse( final ODataRequestResult result ) + throws ODataDeserializationException + { + final GsonResultElementFactory elementFactory = new GsonResultElementFactory(ODataGsonBuilder.newGsonBuilder()); + + return HttpEntityReader.read(result, root -> { + final JsonObject error = root.getAsJsonObject().get("error").getAsJsonObject(); + final ResultObject errorObject = new GsonResultObject(error, elementFactory); + return ODataServiceError.fromResultObject(errorObject, result.getODataRequest().getProtocol()); + }); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHttpRequest.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHttpRequest.java new file mode 100644 index 000000000..4320678e0 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHttpRequest.java @@ -0,0 +1,223 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.io.IOException; +import java.net.URI; +import java.util.function.Function; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.ClientProtocolException; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpDelete; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpPatch; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.classic.methods.HttpPut; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ConnectionRequestTimeoutException; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpEntityContainer; +import org.apache.hc.core5.http.io.entity.StringEntity; + +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataConnectionException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataRequestException; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@AllArgsConstructor( access = AccessLevel.PRIVATE ) +@Slf4j +class ODataHttpRequest +{ + @Nonnull + private final ODataRequestGeneric odataRequest; + + @Nonnull + private final HttpClient httpClient; + + @Nullable + private final HttpEntity requestBody; + + static + ODataHttpRequest + withoutBody( @Nonnull final ODataRequestGeneric requestGeneric, @Nonnull final HttpClient httpClient ) + { + return forHttpEntity(requestGeneric, httpClient, null); + } + + static ODataHttpRequest forBodyJson( + @Nonnull final ODataRequestGeneric requestGeneric, + @Nonnull final HttpClient httpClient, + @Nonnull final String json ) + { + final StringEntity requestBody = new StringEntity(json, ContentType.APPLICATION_JSON); + return forHttpEntity(requestGeneric, httpClient, requestBody); + } + + static ODataHttpRequest forBodyText( + @Nonnull final ODataRequestGeneric requestGeneric, + @Nonnull final HttpClient httpClient, + @Nonnull final String text ) + { + return forHttpEntity(requestGeneric, httpClient, new StringEntity(text, UTF_8)); + } + + static ODataHttpRequest forHttpEntity( + @Nonnull final ODataRequestGeneric requestGeneric, + @Nonnull final HttpClient httpClient, + @Nullable final HttpEntity httpEntity ) + { + return new ODataHttpRequest(requestGeneric, httpClient, httpEntity); + } + + /** + * Perform the request the remote resource. + * + * @param requestCreator + * The factory for HTTP requests. + * @throws ODataRequestException + * When the request URI could not constructed. + * @throws ODataConnectionException + * When an error occurred while handling the HTTP connection. + * @return The HTTP response. + */ + @Nonnull + private ClassicHttpResponse requestResource( @Nonnull final Function requestCreator ) + { + final HttpUriRequestBase httpRequest = requestCreator.apply(getUri()); + + odataRequest.getHeaders().forEach(( k, values ) -> values.forEach(v -> httpRequest.addHeader(k, v))); + + // add optional request body + if( httpRequest instanceof HttpEntityContainer ) { + if( requestBody != null ) { + ((HttpEntityContainer) httpRequest).setEntity(requestBody); + } else { + log.warn("The HTTP request {} was expecting an entity, but none was provided.", httpRequest); + } + } + + odataRequest.getListeners().forEach(v -> v.listenOnRequest(httpRequest)); + + try { + return httpClient.executeOpen(null, httpRequest, null); + } + catch( final ClientProtocolException e ) { + log.debug("Connection could not be established.", e); + throw new ODataConnectionException( + this.odataRequest, + httpRequest, + "Connection could not be established.", + e); + } + catch( final ConnectionRequestTimeoutException e ) { + log.debug("Connection pool timed out.", e); + throw new ODataConnectionException(this.odataRequest, httpRequest, """ + Time out occurred because of a probable connection leak. Please execute your request \ + with try-with-resources to ensure resources are properly closed. \ + If you are using the OData client instead to execute your request, explicitly consume \ + the entity of the associated ClassicHttpResponse using EntityUtils.consume(httpEntity)\ + """, e); + + } + catch( final IOException e ) { + log.debug("Connection was aborted.", e); + throw new ODataConnectionException(this.odataRequest, httpRequest, "Connection was aborted.", e); + } + catch( final Exception e ) { + log.debug("Connection failed.", e); + throw new ODataConnectionException(this.odataRequest, httpRequest, "Connection failed.", e); + } + } + + /** + * Perform a GET request. + * + * @return The HTTP response. + * @throws ODataRequestException + * When the request URI could not constructed. + * @throws ODataConnectionException + * When an error occurred while handling the HTTP connection. + */ + @Nonnull + ClassicHttpResponse requestGet() + { + return requestResource(HttpGet::new); + } + + /** + * Perform a POST request. + * + * @return The HTTP response. + * @throws ODataRequestException + * When the request URI could not constructed. + * @throws ODataConnectionException + * When an error occurred while handling the HTTP connection. + */ + @Nonnull + ClassicHttpResponse requestPost() + { + return requestResource(HttpPost::new); + } + + /** + * Perform a PATCH request. + * + * @return The HTTP response. + * @throws ODataRequestException + * When the request URI could not constructed. + * @throws ODataConnectionException + * When an error occurred while handling the HTTP connection. + */ + @Nonnull + ClassicHttpResponse requestPatch() + { + return requestResource(HttpPatch::new); + } + + /** + * Perform a PUT request. + * + * @return The HTTP response. + * @throws ODataRequestException + * When the request URI could not constructed. + * @throws ODataConnectionException + * When an error occurred while handling the HTTP connection. + */ + @Nonnull + ClassicHttpResponse requestPut() + { + return requestResource(HttpPut::new); + } + + /** + * Perform a DELETE request. + * + * @return The HTTP response. + * @throws ODataRequestException + * When the request URI could not constructed. + * @throws ODataConnectionException + * When an error occurred while handling the HTTP connection. + */ + @Nonnull + ClassicHttpResponse requestDelete() + { + return requestResource(HttpDelete::new); + } + + /** + * Constructs an URI for the given request. + * + * @return The URI + */ + private URI getUri() + { + return odataRequest.getRelativeUri(); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestAction.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestAction.java new file mode 100644 index 000000000..6d763d0b9 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestAction.java @@ -0,0 +1,130 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.net.URI; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.HttpHeaders; + +import com.google.common.collect.Lists; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * The executable OData action request. + */ +@Getter +@EqualsAndHashCode( callSuper = true ) +public class ODataRequestAction extends ODataRequestGeneric +{ + @Nonnull + private final String actionParameters; + + @Nonnull + private final String query; + + /** + * Convenience constructor for invocations of unbound actions. For bound actions use + * {@link #ODataRequestAction(String, ODataResourcePath, String, ODataProtocol)}. + * + * @param servicePath + * The OData service path. + * @param actionName + * The action name. + * @param actionParameters + * Optional: The action parameters HTTP payload. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestAction( + @Nonnull final String servicePath, + @Nonnull final String actionName, + @Nullable final String actionParameters, + @Nonnull final ODataProtocol protocol ) + { + this(servicePath, ODataResourcePath.of(actionName), actionParameters, protocol); + } + + /** + * Default constructor for OData Action request. + * + * @param servicePath + * The OData service path. + * @param actionPath + * The {@link ODataResourcePath path} identifying the action. In case of an unbound + * action this is simply the action name. If this is a bound action the path must also + * contain the full path to the action. + * @param actionParameters + * Optional: The action parameters as HTTP payload. This is expected to be a JSON formatted String. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestAction( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath actionPath, + @Nullable final String actionParameters, + @Nonnull final ODataProtocol protocol ) + { + this(servicePath, actionPath, actionParameters, null, protocol); + } + + /** + * Constructor with StructuredQuery for OData Function request. + * + * @param servicePath + * The OData service path. + * @param actionPath + * The full {@link ODataResourcePath} containing the action name, its parameters and possible further + * path segments. If this is a bound action the path must also contain the full path to + * the action. + * @param actionParameters + * Optional: The action parameters as HTTP payload. This is expected to be a JSON formatted String. + * @param encodedQuery + * Optional: An encodedQuery HTTP request query. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestAction( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath actionPath, + @Nullable final String actionParameters, + @Nullable final String encodedQuery, + @Nonnull final ODataProtocol protocol ) + { + super(servicePath, actionPath, protocol); + this.actionParameters = actionParameters != null ? actionParameters : "{}"; + this.query = encodedQuery != null ? encodedQuery : ""; + headers.putIfAbsent(HttpHeaders.CONTENT_TYPE, Lists.newArrayList("application/json")); + } + + @Nonnull + @Override + public URI getRelativeUri( @Nonnull final UriEncodingStrategy strategy ) + { + return ODataUriFactory.createAndEncodeUri(getServicePath(), getResourcePath(), getRequestQuery(), strategy); + } + + @Override + @Nonnull + public String getRequestQuery() + { + final String genericQueryString = super.getRequestQuery(); + if( !genericQueryString.isEmpty() && !query.isEmpty() ) { + return query + "&" + genericQueryString; + } + return query + genericQueryString; + } + + @Override + @Nonnull + public ODataRequestResultGeneric execute( @Nonnull final HttpClient httpClient ) + { + final ODataHttpRequest request = ODataHttpRequest.forBodyJson(this, httpClient, actionParameters); + return tryExecuteWithCsrfToken(httpClient, request::requestPost).get(); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestBatch.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestBatch.java new file mode 100644 index 000000000..d1d9e12f5 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestBatch.java @@ -0,0 +1,489 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static lombok.AccessLevel.PRIVATE; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.OptionalInt; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import java.util.stream.IntStream; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.HttpHeaders; + +import com.google.common.collect.ImmutableMap; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataRequestException; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; + +import io.vavr.Tuple; +import io.vavr.Tuple2; +import io.vavr.control.Try; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * The OData Batch request. + */ +@Getter +@EqualsAndHashCode( callSuper = true ) +@Slf4j +public class ODataRequestBatch extends ODataRequestGeneric +{ + private static final String DEFAULT_ODATA_BATCH_FORMAT_NEWLINE = "\r\n"; + + @Nonnull + private final List requests = new ArrayList<>(); + + private final AtomicInteger contentId = new AtomicInteger(1); + + private final UUID batchUuid; + + private final Supplier uuidProvider; + + /** + * Default constructor for OData Batch request. + * + * The service path will be URL-encoded during request serialization. + * + * @param servicePath + * The unencoded OData service path + * @param protocol + * The OData protocol + */ + public ODataRequestBatch( @Nonnull final String servicePath, @Nonnull final ODataProtocol protocol ) + { + this(servicePath, protocol, UUID::randomUUID); + } + + /** + * Default constructor for OData Batch request. + * + * The service path will be URL-encoded during request serialization. + * + * @param servicePath + * The unencoded OData service path + * @param protocol + * The OData protocol + * @param uuidProvider + * A generic UUID provider, customizable for testing + */ + public ODataRequestBatch( + @Nonnull final String servicePath, + @Nonnull final ODataProtocol protocol, + @Nonnull final Supplier uuidProvider ) + { + super(servicePath, ODataResourcePath.of("$batch"), protocol); + this.uuidProvider = uuidProvider; + this.batchUuid = uuidProvider.get(); + this.headers.remove(HttpHeaders.ACCEPT); // batch request does not require Accept header + this.requestResultFactory = ODataRequestResultFactory.WITHOUT_BUFFER; + } + + @Nonnull + @Override + public URI getRelativeUri( @Nonnull final UriEncodingStrategy strategy ) + { + return ODataUriFactory.createAndEncodeUri(getServicePath(), getResourcePath(), getRequestQuery(), strategy); + } + + /** + * Add an OData Read request to the current OData Batch request. + * + * @param request + * The Read request. + * @return The Batch request fluent helper instance. + */ + @Nonnull + public ODataRequestBatch addRead( @Nonnull final ODataRequestRead request ) + { + final BatchItem item = new BatchItemSingle(this, request, "GET", null); + requests.add(item); + return this; + } + + /** + * Add an OData Read-By-Key request to the current OData Batch request. + * + * @param request + * The Read-By-Key request. + * @return The Batch request fluent helper instance. + */ + @Nonnull + public ODataRequestBatch addReadByKey( @Nonnull final ODataRequestReadByKey request ) + { + final BatchItem item = new BatchItemSingle(this, request, "GET", null); + requests.add(item); + return this; + } + + /** + * Add an OData Function request to the current OData Batch request. + * + * @param request + * The Function request. + * @return The Batch request fluent helper instance. + */ + @Nonnull + public ODataRequestBatch addFunction( @Nonnull final ODataRequestFunction request ) + { + final BatchItem item = new BatchItemSingle(this, request, "GET", null); + requests.add(item); + return this; + } + + /** + * Instantiate a new changeset to the current OData Batch request. As per specification if any data modifying + * operation fails within one changeset, then the incomplete changes will be reverted. + * + * @return A new Changeset fluent helper instance. + */ + @Nonnull + public Changeset beginChangeset() + { + return new Changeset(this); + } + + @Override + @Nonnull + public ODataRequestResultMultipartGeneric execute( @Nonnull final HttpClient httpClient ) + { + return tryExecute(httpClient).get(); + } + + private Try tryExecute( @Nonnull final HttpClient httpClient ) + { + final String requestBody = getBatchRequestBody(); + final ODataHttpRequest request = ODataHttpRequest.forBodyText(this, httpClient, requestBody); + + return Try + .of(request::requestPost) + .map(response -> new ODataRequestResultMultipartGeneric(this, response)) + .andThenTry(ODataHealthyResponseValidator::requireHealthyResponse); + } + + @Override + @Nonnull + public Map> getHeaders() + { + final Map> headers = super.getHeaders(); + + // replace select existing headers with custom batch headers + for( final Map.Entry batchHeader : getBatchHeaders().entrySet() ) { + headers.compute(batchHeader.getKey(), ( k, v ) -> new ArrayList<>(1)).add(batchHeader.getValue()); + } + + return headers; + } + + @Nonnull + private Map getBatchHeaders() + { + return ImmutableMap + .of( + "Content-Type", + "multipart/mixed;boundary=batch_" + batchUuid, + "OData-Version", + getProtocol().getProtocolVersion()); + } + + @Nonnull + String getBatchRequestBody() + { + final String batchDelimiter = "--batch_" + batchUuid; + final String batchDelimiterEnd = batchDelimiter + "--"; + + final List resultLines = new ArrayList<>(); + for( final BatchItem item : requests ) { + resultLines.add(batchDelimiter); + resultLines.addAll(item.getLines()); + } + + // closing delimiter + resultLines.add(batchDelimiterEnd); + resultLines.add(""); + return String.join(DEFAULT_ODATA_BATCH_FORMAT_NEWLINE, resultLines); + } + + /** + * The Changeset representation of the OData Batch operation. + */ + @RequiredArgsConstructor( access = PRIVATE ) + public static final class Changeset + { + private final ODataRequestBatch originalRequest; + private final List queries = new ArrayList<>(); + + /** + * Add an OData Create request to the current OData Batch changeset. + * + * @param request + * The Create request. + * @return The Changeset fluent helper instance. + */ + @Nonnull + public Changeset addCreate( @Nonnull final ODataRequestCreate request ) + { + final BatchItemSingle item = + new BatchItemSingle(originalRequest, request, "POST", request::getSerializedEntity); + queries.add(item); + return this; + } + + /** + * Add an OData Update request to the current OData Batch changeset. + * + * @param request + * The Update request. + * @return The Changeset fluent helper instance. + */ + @Nonnull + public Changeset addUpdate( @Nonnull final ODataRequestUpdate request ) + { + final String versionIdentifier = request.getVersionIdentifier(); + request.addVersionIdentifierToHeaderIfPresent(versionIdentifier); + + final String httpMethod; + switch( request.getUpdateStrategy() ) { + case MODIFY_WITH_PATCH, MODIFY_WITH_PATCH_RECURSIVE_DELTA, MODIFY_WITH_PATCH_RECURSIVE_FULL: + httpMethod = "PATCH"; + break; + case REPLACE_WITH_PUT: + httpMethod = "PUT"; + break; + default: + throw new IllegalStateException("Unexpected update strategy: " + request.getUpdateStrategy()); + } + + final BatchItemSingle item = + new BatchItemSingle(originalRequest, request, httpMethod, request::getSerializedEntity); + queries.add(item); + return this; + } + + /** + * Add an OData Delete request to the current OData Batch changeset. + * + * @param request + * The Delete request. + * @return The Changeset fluent helper instance. + */ + @Nonnull + public Changeset addDelete( @Nonnull final ODataRequestDelete request ) + { + final String versionIdentifier = request.getVersionIdentifier(); + request.addVersionIdentifierToHeaderIfPresent(versionIdentifier); + final BatchItemSingle item = new BatchItemSingle(originalRequest, request, "DELETE", null); + queries.add(item); + return this; + } + + /** + * Add an OData Action request to the current OData Batch changeset. + * + * @param request + * The Action request. + * @return The Changeset fluent helper instance. + */ + @Nonnull + public Changeset addAction( @Nonnull final ODataRequestAction request ) + { + final BatchItemSingle item = + new BatchItemSingle(originalRequest, request, "POST", request::getActionParameters); + queries.add(item); + return this; + } + + /** + * Finalizes the current changeset. + * + * @return The original Batch request fluent helper instance. + */ + @Nonnull + public ODataRequestBatch endChangeset() + { + final UUID changeSetId = originalRequest.uuidProvider.get(); + final BatchItem item = new BatchItemChangeset(changeSetId, queries); + originalRequest.requests.add(item); + + return originalRequest; + } + } + + @Getter + static final class BatchItemSingle implements BatchItem + { + private final int contentId; + @Nonnull + final ODataRequestGeneric request; + @Nonnull + private final String resourcePath; + @Nonnull + private final String httpMethod; + @Nullable + private final Supplier payload; + + private BatchItemSingle( + @Nonnull final ODataRequestBatch requestBatch, + @Nonnull final ODataRequestGeneric requestSingle, + @Nonnull final String httpMethod, + @Nullable final Supplier payload ) + { + final String encodedRelativeUriSingleRequest = + requestSingle.getRelativeUri(UriEncodingStrategy.BATCH).toString(); + final String encodedServicePathBatchRequest = requestBatch.getEncodedServicePath(UriEncodingStrategy.BATCH); + + assertSingleAndBatchRequestAreConsistent( + requestBatch, + requestSingle, + encodedRelativeUriSingleRequest, + encodedServicePathBatchRequest); + + this.contentId = requestBatch.contentId.getAndIncrement(); + this.request = requestSingle; + this.resourcePath = removeStart(encodedRelativeUriSingleRequest, encodedServicePathBatchRequest); + this.httpMethod = httpMethod; + this.payload = payload; + } + + @Nonnull + private String removeStart( @Nonnull final String string, @Nonnull final String prefix ) + { + return string.startsWith(prefix) ? string.substring(prefix.length()) : string; + } + + private void assertSingleAndBatchRequestAreConsistent( + final ODataRequestBatch requestBatch, + final ODataRequestGeneric requestSingle, + final String encodedRelativeUriSingleRequest, + final String encodedServicePathBatchRequest ) + { + if( !encodedRelativeUriSingleRequest.startsWith(encodedServicePathBatchRequest) ) { + throw new ODataRequestException( + requestBatch, + "Batch request contains requests to different service paths (batch request: " + + encodedServicePathBatchRequest + + ", single request: " + + encodedRelativeUriSingleRequest, + null); + } + if( !Objects.equals(requestSingle.getProtocol(), requestBatch.getProtocol()) ) { + throw new ODataRequestException( + requestBatch, + "Batch request contains requests with different protocol versions,", + null); + } + } + + @Nonnull + @Override + public List getLines() + { + final List lines = new ArrayList<>(); + lines.add("Content-Type: application/http"); + lines.add("Content-Transfer-Encoding: binary"); + lines.add("Content-ID: " + contentId); + + lines.add(""); + lines.add(String.format("%s %s HTTP/1.1", httpMethod, resourcePath)); + request.getHeaders().forEach(( k, values ) -> values.forEach(v -> lines.add(k + ": " + v))); + lines.add(""); + + if( payload != null ) { + lines.add(payload.get()); + } + lines.add(""); + return lines; + } + } + + @RequiredArgsConstructor( access = PRIVATE ) + @Getter + static final class BatchItemChangeset implements BatchItem + { + @Nonnull + final UUID changeSetId; + @Nonnull + final List requests; + + @Nonnull + @Override + public List getLines() + { + final String changesetDelimiter = "--changeset_" + changeSetId; + final String changesetDelimiterEnd = changesetDelimiter + "--"; + + final List lines = new ArrayList<>(); + lines.add("Content-Type: multipart/mixed;boundary=changeset_" + changeSetId); + lines.add(""); + + for( final BatchItem request : requests ) { + lines.add(changesetDelimiter); + lines.addAll(request.getLines()); + } + lines.add(changesetDelimiterEnd); + lines.add(""); + return lines; + } + } + + interface BatchItem + { + @Nonnull + List getLines(); + } + + /** + * Gets the position for a single request inside a batch request. This is important for mapping the individual + * responses later. + * + * @param batchRequest + * The batch request. + * @param singleRequest + * The request item that was batched. + * @return {@code null} if the position cannot be found. {@code Tuple2} if the single request is not + * part of a changeset. {@code Tuple2} if the single request is part of a changeset. + */ + @Nullable + static Tuple2 getBatchItemPosition( + @Nonnull final ODataRequestBatch batchRequest, + @Nonnull final ODataRequestGeneric singleRequest ) + { + final List batchItems = batchRequest.getRequests(); + + for( int i = 0; i < batchItems.size(); i++ ) { + final BatchItem item = batchItems.get(i); + if( item instanceof BatchItemChangeset ) { + final List nestedRequests = ((BatchItemChangeset) item).getRequests(); + final OptionalInt pos = + IntStream + .range(0, nestedRequests.size()) + .filter(p -> singleRequest == nestedRequests.get(p).getRequest()) + .findFirst(); + if( pos.isPresent() ) { + return Tuple.of(i, pos.getAsInt()); + } + } else if( item instanceof BatchItemSingle && singleRequest == ((BatchItemSingle) item).getRequest() ) { + return Tuple.of(i, null); + } + } + return null; + } + + @Nonnull + String getEncodedServicePath( @Nonnull final UriEncodingStrategy encodingStrategy ) + { + return ODataUriFactory.createAndEncodeUri(servicePath, "", null, encodingStrategy).toString(); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestCount.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestCount.java new file mode 100644 index 000000000..d30668c0b --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestCount.java @@ -0,0 +1,81 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; +import com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery; + +import lombok.EqualsAndHashCode; + +/** + * The result type of the OData Count request. + */ +@EqualsAndHashCode( callSuper = true ) +public class ODataRequestCount extends ODataRequestRead +{ + /** + * Default constructor for OData Count request. + * + * @param servicePath + * The OData service path. + * @param entityName + * The OData entity name. + * @param encodedQuery + * Optional: The encoded HTTP query, if any. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestCount( + @Nonnull final String servicePath, + @Nonnull final String entityName, + @Nullable final String encodedQuery, + @Nonnull final ODataProtocol protocol ) + { + this(servicePath, ODataResourcePath.of(entityName), encodedQuery, protocol); + } + + /** + * Default constructor for OData Count request. + * + * @param servicePath + * The OData service path. + * @param resourcePath + * The {@link ODataResourcePath} that identifies the collection to be counted. + * @param encodedQuery + * Optional: The encoded HTTP query, if any. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestCount( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath resourcePath, + @Nullable final String encodedQuery, + @Nonnull final ODataProtocol protocol ) + { + super(servicePath, resourcePath.addSegment("$count"), encodedQuery, protocol); + } + + /** + * Constructor with StructuredQuery for OData Count request. + * + * @param servicePath + * The OData service path. + * @param resourcePath + * The {@link ODataResourcePath} that identifies the collection to be counted. + * @param query + * The structured query. + */ + public ODataRequestCount( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath resourcePath, + @Nonnull final StructuredQuery query ) + { + this( + servicePath, + resourcePath.addSegment(query.getEntityOrPropertyName()), + query.getEncodedQueryString(), + query.getProtocol()); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestCreate.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestCreate.java new file mode 100644 index 000000000..e6ce7c741 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestCreate.java @@ -0,0 +1,87 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.net.URI; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.HttpHeaders; + +import com.google.common.collect.Lists; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * The executable OData create request. + */ +@Getter +@EqualsAndHashCode( callSuper = true ) +public class ODataRequestCreate extends ODataRequestGeneric +{ + @Nonnull + private final String serializedEntity; + + /** + * Convenience constructor for OData delete requests on entity collections directly. For operations on nested + * entities use {@link #ODataRequestCreate(String, ODataResourcePath, String, ODataProtocol)}. + * + * @param servicePath + * The OData service path. + * @param entityName + * The OData entity name. + * @param serializedEntity + * The serialized query payload. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestCreate( + @Nonnull final String servicePath, + @Nonnull final String entityName, + @Nonnull final String serializedEntity, + @Nonnull final ODataProtocol protocol ) + { + this(servicePath, ODataResourcePath.of(entityName), serializedEntity, protocol); + } + + /** + * Default constructor for OData Create request. + * + * @param servicePath + * The OData service path. + * @param entityPath + * The {@link ODataResourcePath path} to the OData entity. + * @param serializedEntity + * The serialized query payload. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestCreate( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath entityPath, + @Nonnull final String serializedEntity, + @Nonnull final ODataProtocol protocol ) + { + super(servicePath, entityPath, protocol); + this.serializedEntity = serializedEntity; + headers.putIfAbsent(HttpHeaders.CONTENT_TYPE, Lists.newArrayList("application/json")); + } + + @Nonnull + @Override + public URI getRelativeUri( @Nonnull final UriEncodingStrategy strategy ) + { + return ODataUriFactory.createAndEncodeUri(getServicePath(), getResourcePath(), getRequestQuery(), strategy); + } + + @Nonnull + @Override + public ODataRequestResultGeneric execute( @Nonnull final HttpClient httpClient ) + { + final ODataHttpRequest request = ODataHttpRequest.forBodyJson(this, httpClient, serializedEntity); + + return tryExecuteWithCsrfToken(httpClient, request::requestPost).get(); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestDelete.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestDelete.java new file mode 100644 index 000000000..73d058891 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestDelete.java @@ -0,0 +1,92 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.net.URI; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * The executable OData delete request. + */ +@Getter +@EqualsAndHashCode( callSuper = true ) +@Slf4j +public class ODataRequestDelete extends ODataRequestGeneric +{ + @Nullable + private final String versionIdentifier; + + /** + * Convenience constructor for OData delete requests on entity collections directly. For operations on nested + * entities use {@link #ODataRequestDelete(String, String, ODataEntityKey, String, ODataProtocol)}. + * + * @param servicePath + * The OData service path. + * @param entityName + * The name of the entity to delete. + * @param entityKey + * The {@link ODataEntityKey entity key} that identifies the entity to delete. + * @param versionIdentifier + * The version identifier. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestDelete( + @Nonnull final String servicePath, + @Nonnull final String entityName, + @Nonnull final ODataEntityKey entityKey, + @Nullable final String versionIdentifier, + @Nonnull final ODataProtocol protocol ) + { + this(servicePath, ODataResourcePath.of(entityName, entityKey), versionIdentifier, protocol); + } + + /** + * Default constructor for OData delete requests. + * + * @param servicePath + * The OData service path. + * @param entityPath + * The {@link ODataResourcePath} that identifies the entity to delete. + * @param versionIdentifier + * The version identifier. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestDelete( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath entityPath, + @Nullable final String versionIdentifier, + @Nonnull final ODataProtocol protocol ) + { + super(servicePath, entityPath, protocol); + this.versionIdentifier = versionIdentifier; + } + + @Nonnull + @Override + public URI getRelativeUri( @Nonnull final UriEncodingStrategy strategy ) + { + return ODataUriFactory.createAndEncodeUri(getServicePath(), getResourcePath(), getRequestQuery(), strategy); + } + + @Nonnull + @Override + public ODataRequestResultGeneric execute( @Nonnull final HttpClient httpClient ) + { + final ODataHttpRequest request = ODataHttpRequest.withoutBody(this, httpClient); + + addVersionIdentifierToHeaderIfPresent(versionIdentifier); + + return tryExecuteWithCsrfToken(httpClient, request::requestDelete).get(); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestExecutable.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestExecutable.java new file mode 100644 index 000000000..8d89cdb7b --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestExecutable.java @@ -0,0 +1,34 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataConnectionException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataRequestException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataResponseException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataServiceErrorException; + +/** + * General interface for executable OData Requests. + */ +public interface ODataRequestExecutable +{ + /** + * Execute the OData request with the provided HttpClient reference. + * + * @param httpClient + * The HttpClient. + * @return An OData request result. + * @throws ODataRequestException + * When the OData request could not be sent. + * @throws ODataConnectionException + * When the HTTP connection cannot be established. + * @throws ODataResponseException + * When the response code infers an unhealthy state, i.e. when >= 400 + * @throws ODataServiceErrorException + * When the response contains an OData error message according to specification. + */ + @Nonnull + ODataRequestResult execute( @Nonnull final HttpClient httpClient ); +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestFunction.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestFunction.java new file mode 100644 index 000000000..a13f0437f --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestFunction.java @@ -0,0 +1,186 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.net.URI; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.google.common.base.Strings; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; +import com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * The executable OData function request. + */ +@Getter +@EqualsAndHashCode( callSuper = true ) +public class ODataRequestFunction extends ODataRequestGeneric +{ + @Nonnull + private final String query; + + /** + * Convenience constructor for invocations of unbound functions. The function parameters will be added to the URL + * path or URL query depending on the OData protocol verison. For composable or bound functions please use + * {@link #ODataRequestFunction(String, ODataResourcePath, String, ODataProtocol)} instead. + * + * @param servicePath + * The OData service path. + * @param functionName + * The name of the unbound OData function. + * @param parameters + * The parameters of the function invocation. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestFunction( + @Nonnull final String servicePath, + @Nonnull final String functionName, + @Nonnull final ODataFunctionParameters parameters, + @Nonnull final ODataProtocol protocol ) + { + this( + servicePath, + protocol.isEqualTo(ODataProtocol.V2) + ? ODataResourcePath.of(functionName) + : ODataResourcePath.of(functionName, parameters), + protocol.isEqualTo(ODataProtocol.V2) ? parameters.toEncodedString() : null, + protocol); + } + + /** + * Convenience constructor for invocations of unbound functions. The function parameters will be added to the URL + * path or URL query depending on the OData protocol verison. For composable or bound functions please use + * {@link #ODataRequestFunction(String, ODataResourcePath, String, ODataProtocol)} instead. + * + * @param servicePath + * The OData service path. + * @param functionPath + * The full {@link ODataResourcePath} containing the function name, its parameters and possible further + * path segments. If this is a bound function the path must also contain the full path + * to the function. + * @param parameters + * The parameters of the function invocation. + * @param query + * Optional: The encoded HTTP query, if any. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestFunction( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath functionPath, + @Nonnull final ODataFunctionParameters parameters, + @Nullable final String query, + @Nonnull final ODataProtocol protocol ) + { + this( + servicePath, + appendResourcePathWithParameters(functionPath, parameters, protocol), + appendQueryWithParameters(query, parameters, protocol), + protocol); + } + + /** + * Default constructor for OData Function request. + * + * @param servicePath + * The OData service path. + * @param functionPath + * The full {@link ODataResourcePath} containing the function name, its parameters and possible further + * path segments. If this is a bound function the path must also contain the full path + * to the function. + * @param encodedQuery + * Optional: The encoded HTTP query, if any. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestFunction( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath functionPath, + @Nullable final String encodedQuery, + @Nonnull final ODataProtocol protocol ) + { + super(servicePath, functionPath, protocol); + this.query = encodedQuery != null ? encodedQuery : ""; + } + + /** + * Constructor with StructuredQuery for OData Function request. + * + * @param servicePath + * The OData service path. + * @param functionPath + * The full {@link ODataResourcePath} containing the function name, its parameters and possible further + * path segments. If this is a bound function the path must also contain the full path + * to the function. + * @param structQuery + * The structured query. + */ + public ODataRequestFunction( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath functionPath, + @Nonnull final StructuredQuery structQuery ) + { + super(servicePath, functionPath.addSegment(structQuery.getEntityOrPropertyName()), structQuery.getProtocol()); + this.query = structQuery.getEncodedQueryString(); + } + + @Nonnull + @Override + public URI getRelativeUri( @Nonnull final UriEncodingStrategy strategy ) + { + return ODataUriFactory.createAndEncodeUri(getServicePath(), getResourcePath(), getRequestQuery(), strategy); + } + + @Override + @Nonnull + public ODataRequestResultGeneric execute( @Nonnull final HttpClient httpClient ) + { + final ODataHttpRequest request = ODataHttpRequest.withoutBody(this, httpClient); + return tryExecute(request::requestGet, httpClient).get(); + } + + @Override + @Nonnull + public String getRequestQuery() + { + final String genericQueryString = super.getRequestQuery(); + if( !genericQueryString.isEmpty() && !query.isEmpty() ) { + return query + "&" + genericQueryString; + } + return query + genericQueryString; + } + + @Nonnull + private static ODataResourcePath appendResourcePathWithParameters( + @Nonnull final ODataResourcePath path, + @Nonnull final ODataFunctionParameters parameters, + @Nonnull final ODataProtocol protocol ) + { + if( protocol.isEqualTo(ODataProtocol.V2) ) { + return path; + } + final ODataResourcePath appendedPath = new ODataResourcePath(); + path.getSegments().forEach(s -> appendedPath.addSegment(s._1, s._2)); + return appendedPath.addParameterToLastSegment(parameters); + } + + @Nullable + private static String appendQueryWithParameters( + @Nullable final String query, + @Nonnull final ODataFunctionParameters parameters, + @Nonnull final ODataProtocol protocol ) + { + if( protocol.isEqualTo(ODataProtocol.V4) ) { + return query; + } + final String encodedParams = parameters.toEncodedString(); + return Strings.isNullOrEmpty(query) ? encodedParams : encodedParams + "&" + query; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestGeneric.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestGeneric.java new file mode 100644 index 000000000..7846132ac --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestGeneric.java @@ -0,0 +1,261 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.function.Supplier; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpHeaders; + +import com.google.common.base.Joiner; +import com.google.common.collect.Lists; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; + +import io.vavr.control.Try; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Generic OData request class to provide default features for service requests. + */ +@EqualsAndHashCode +@Slf4j +public abstract class ODataRequestGeneric implements ODataRequestExecutable +{ + /** + * Default {@link ODataFormat} that will be used if none is specified. + */ + private static final ODataFormat DEFAULT_FORMAT = ODataFormat.JSON; + + /** + * The service path of the targeted OData service. E.g. {@code sap/opu/odata/sap/API_BUSINESS_PARTNER} + */ + @Getter( AccessLevel.PUBLIC ) + @Nonnull + protected final String servicePath; + + /** + * The {@link ODataResourcePath} that identifies the OData resource to operate on. E.g. + * {@code /BusinessPartner('123')/BusinessPartnerAddress(456)}. + */ + @Getter( AccessLevel.PROTECTED ) + @Nonnull + protected final ODataResourcePath resourcePath; + + /** + * The OData protocol version of this request. + */ + @Getter + private final ODataProtocol protocol; + + /** + * List of listeners to observe and react on OData actions. + */ + @Getter( AccessLevel.PROTECTED ) + private final List listeners = new ArrayList<>(); + + /** + * Map of HTTP header key-values which are added to the OData request. + */ + final Map> headers = new TreeMap<>(); + + /** + * Map of additional generic HTTP query parameters. + */ + @Getter( AccessLevel.PROTECTED ) + private final Map queryParameters = new TreeMap<>(); + + /** + * The response buffer strategy to use for this request. + */ + @Nonnull + ODataRequestResultFactory requestResultFactory = ODataRequestResultFactory.WITH_BUFFER; + + ODataRequestGeneric( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath resourcePath, + @Nonnull final ODataProtocol protocol ) + { + this.protocol = protocol; + this.servicePath = servicePath; + this.resourcePath = resourcePath; + headers.putIfAbsent(HttpHeaders.ACCEPT, Lists.newArrayList(DEFAULT_FORMAT.getHttpAccept())); + } + + /** + * Get the static request URI of the OData resource. + * + * @param uriEncodingStrategy + * URI encoding strategy. + * @return The String representation of the request URI. + */ + @Nonnull + public abstract URI getRelativeUri( @Nonnull final UriEncodingStrategy uriEncodingStrategy ); + + /** + * Get the static request URI of the OData resource. + * + * @return The String representation of the request URI. + */ + @Nonnull + public URI getRelativeUri() + { + return getRelativeUri(UriEncodingStrategy.REGULAR); + } + + /** + * Use all OData query information to construct a HTTP request query String. + * + * @return The request query. + */ + @Nonnull + public String getRequestQuery() + { + return Joiner.on("&").withKeyValueSeparator("=").join(queryParameters); + } + + /** + * Attach a listener to the request process. + * + * @param listener + * The listener to react on OData request actions. + */ + public void addListener( @Nonnull final ODataRequestListener listener ) + { + listeners.add(listener); + } + + /** + * Replace a header in the OData HTTP request. + * + * @param key + * The header name. + * @param value + * The header value. + */ + public void setHeader( @Nonnull final String key, @Nullable final String value ) + { + final List values = new ArrayList<>(1); + values.add(value); + headers.put(key, values); + } + + /** + * Replace a header with multiple values in the OData HTTP request. + * + * @param key + * The header name. + * @param values + * The header values. + * @since 4.27.0 + */ + public void setHeader( @Nonnull final String key, @Nonnull final Collection values ) + { + headers.put(key, new ArrayList<>(values)); + } + + /** + * Add a header to the OData HTTP request. + * + * @param key + * The header name. + * @param value + * The header value. + */ + public void addHeader( @Nonnull final String key, @Nullable final String value ) + { + headers.computeIfAbsent(key, k -> new ArrayList<>(1)).add(value); + } + + /** + * Add a header to the OData HTTP request, if it is not included already. + * + * @param key + * The header name. + * @param value + * The header value. + */ + public void addHeaderIfAbsent( @Nonnull final String key, @Nullable final String value ) + { + headers.putIfAbsent(key, Lists.newArrayList(value)); + } + + /** + * Add a query parameter to the HTTP request. The value must be encoded. + * + * @param key + * The parameter key. + * @param value + * The encoded parameters value. + */ + public void addQueryParameter( @Nonnull final String key, @Nullable final String value ) + { + queryParameters.put(key, value); + } + + /** + * Internal execute method. It will perform the given httpOperation on the given request and ensure a healthy HTTP + * response code. Failures will always be a subtype of {@link ODataException} + * + * @param httpOperation + * The HTTP operation to perform, e.g. {@link ODataHttpRequest#requestGet()} + * @param httpClient + * The HTTP client instance that is being used to perform the operation. + * @return A {@code Try} containing either a successful {@link ODataRequestResultGeneric} or an + * {@code ODataException}. + */ + @Nonnull + protected + Try + tryExecute( @Nonnull final Supplier httpOperation, @Nonnull final HttpClient httpClient ) + { + return Try + .ofSupplier(httpOperation) + .map(response -> requestResultFactory.create(this, response, httpClient)) + .andThenTry(ODataHealthyResponseValidator::requireHealthyResponse); + } + + @Nonnull + protected Try tryExecuteWithCsrfToken( + @Nonnull final HttpClient httpClient, + @Nonnull final Supplier httpOperation ) + { + return tryExecute(httpOperation, httpClient); + } + + /** + * Get the list of headers that will be sent with this request. To add headers, please use + * {@link #addHeader(String, String) addHeader} and {@link #addHeaderIfAbsent(String, String) addHeaderIfAbsent} + * + * @return The list of headers. + */ + @Nonnull + public Map> getHeaders() + { + return new TreeMap<>(headers); + } + + void addVersionIdentifierToHeaderIfPresent( @Nullable final String versionIdentifier ) + { + if( versionIdentifier != null ) { + addHeaderIfAbsent(HttpHeaders.IF_MATCH, versionIdentifier); + } else { + log + .debug( + "Version identifier for {} is either not defined on the entity or is explicitly ignored.", + getClass().getSimpleName()); + } + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestListener.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestListener.java new file mode 100644 index 000000000..1088a2244 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestListener.java @@ -0,0 +1,35 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; + +/** + * Consumer class for the Listener Pattern to monitor and react on OData actions. + */ +public interface ODataRequestListener +{ + /** + * Handler to react before execution of an HTTP request. + * + * @param request + * The HTTP request. + */ + void listenOnRequest( @Nonnull final HttpUriRequestBase request ); + + /** + * Handler to react on an error during request generation. + * + * @param error + * The exception reference. + */ + void listenOnRequestError( @Nonnull final Exception error ); + + /** + * Handler to react on an error during response parsing. + * + * @param error + * The exception reference. + */ + void listenOnParsingError( @Nonnull final Exception error ); +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestRead.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestRead.java new file mode 100644 index 000000000..6f16f6347 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestRead.java @@ -0,0 +1,144 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.net.URI; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.google.common.annotations.Beta; +import com.google.common.net.UrlEscapers; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; +import com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * The result type of the OData Read request. + */ +@Getter +@EqualsAndHashCode( callSuper = true ) +public class ODataRequestRead extends ODataRequestGeneric +{ + @Nonnull + private final String queryString; + + /** + * Convenience constructor for OData read requests on entity collections directly. For operations on nested entity + * collections use {@link #ODataRequestRead(String, ODataResourcePath, String, ODataProtocol)}. + * + * @param servicePath + * The OData service path. + * @param entityName + * The OData entity name. + * @param encodedQuery + * Optional: The encoded HTTP query, if any. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestRead( + @Nonnull final String servicePath, + @Nonnull final String entityName, + @Nullable final String encodedQuery, + @Nonnull final ODataProtocol protocol ) + { + this(servicePath, ODataResourcePath.of(entityName), encodedQuery, protocol); + } + + /** + * Default constructor for OData Read request. + *

+ * Note: The query string {@link #queryString} must not contain characters that are forbidden in URLs, like spaces. + * If forbidden characters are present, an {@link IllegalArgumentException} is thrown. + * + *

+ * Build an instance of {@link StructuredQuery} and pass the value of + * {@link StructuredQuery#getEncodedQueryString()} as {@link #queryString} to this method. + * + *

+ * Alternatively, use {@link UrlEscapers#urlFragmentEscaper()} from the Guava library to escape the query string + * before passing it here. + * + * @param servicePath + * The OData service path. + * @param entityPath + * The {@link ODataResourcePath} that identifies the collection of entities or properties to read. + * @param encodedQuery + * Optional: The encoded HTTP query, if any. + * @param protocol + * The OData protocol to use. + * + */ + public ODataRequestRead( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath entityPath, + @Nullable final String encodedQuery, + @Nonnull final ODataProtocol protocol ) + { + super(servicePath, entityPath, protocol); + this.queryString = encodedQuery != null ? encodedQuery : ""; + } + + /** + * Constructor with StructuredQuery for OData read requests on entity collections directly. For operations on nested + * entity collections use {@link #ODataRequestRead(String, ODataResourcePath, String, ODataProtocol)}. + * + * @param servicePath + * The OData service path. + * @param entityPath + * The {@link ODataResourcePath} that identifies the collection of entities or properties to read. + * @param query + * The structured query. + */ + public ODataRequestRead( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath entityPath, + @Nonnull final StructuredQuery query ) + { + this( + servicePath, + entityPath.addSegment(query.getEntityOrPropertyName()), + query.getEncodedQueryString(), + query.getProtocol()); + } + + @Nonnull + @Override + public URI getRelativeUri( @Nonnull final UriEncodingStrategy strategy ) + { + return ODataUriFactory.createAndEncodeUri(getServicePath(), getResourcePath(), getRequestQuery(), strategy); + } + + @Override + @Nonnull + public String getRequestQuery() + { + final String genericQueryString = super.getRequestQuery(); + if( !genericQueryString.isEmpty() && !queryString.isEmpty() ) { + return queryString + "&" + genericQueryString; + } + return queryString + genericQueryString; + } + + @Override + @Nonnull + public ODataRequestResultGeneric execute( @Nonnull final HttpClient httpClient ) + { + final ODataHttpRequest request = ODataHttpRequest.withoutBody(this, httpClient); + return tryExecute(request::requestGet, httpClient).get(); + } + + /** + * Disable pre-buffering of http response entity. + */ + @Beta + @Nonnull + public ODataRequestResultResource.Executable withoutResponseBuffering() + { + requestResultFactory = ODataRequestResultFactory.WITHOUT_BUFFER; + return httpClient -> (ODataRequestResultResource) this.execute(httpClient); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestReadByKey.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestReadByKey.java new file mode 100644 index 000000000..af8302cea --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestReadByKey.java @@ -0,0 +1,141 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.net.URI; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.google.common.annotations.Beta; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; +import com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * The result type of the OData read by key request. + */ +@Getter +@EqualsAndHashCode( callSuper = true ) +public class ODataRequestReadByKey extends ODataRequestGeneric +{ + @Nonnull + private final String queryString; + + /** + * Convenience constructor for OData read requests on entity collections directly. For operations on nested entities + * use {@link #ODataRequestReadByKey(String, ODataResourcePath, String, ODataProtocol)}. + * + * @param servicePath + * The OData service path. + * @param entityName + * The OData entity name. + * @param entityKey + * The entity key. + * @param encodedQuery + * Optional: The encoded HTTP query, if any. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestReadByKey( + @Nonnull final String servicePath, + @Nonnull final String entityName, + @Nonnull final ODataEntityKey entityKey, + @Nullable final String encodedQuery, + @Nonnull final ODataProtocol protocol ) + { + this(servicePath, ODataResourcePath.of(entityName, entityKey), encodedQuery, protocol); + } + + /** + * Default constructor for OData Read requests. + * + * @param servicePath + * The OData service path. + * @param entityPath + * The {@link ODataResourcePath} that identifies the entity to read. + * @param encodedQuery + * Optional: The encoded HTTP query, if any. + * @param protocol + * The OData protocol to use. + * + */ + public ODataRequestReadByKey( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath entityPath, + @Nullable final String encodedQuery, + @Nonnull final ODataProtocol protocol ) + { + super(servicePath, entityPath, protocol); + this.queryString = encodedQuery != null ? encodedQuery : ""; + } + + /** + * Constructor with StructuredQuery for OData read requests on entity collections directly. For operations on nested + * entity collections use + * {@link ODataRequestRead#ODataRequestRead(String, ODataResourcePath, String, ODataProtocol)}. + * + * @param servicePath + * The OData service path. + * @param entityPath + * The {@link ODataResourcePath} that identifies the collection of entities or properties to read. + * @param entityKey + * The entity key. + * @param query + * The structured query. + */ + public ODataRequestReadByKey( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath entityPath, + @Nonnull final ODataEntityKey entityKey, + @Nonnull final StructuredQuery query ) + { + this( + servicePath, + entityPath.addParameterToLastSegment(entityKey), + query.getEncodedQueryString(), + query.getProtocol()); + } + + @Nonnull + @Override + public URI getRelativeUri( @Nonnull final UriEncodingStrategy strategy ) + { + return ODataUriFactory.createAndEncodeUri(getServicePath(), getResourcePath(), getRequestQuery(), strategy); + } + + @Override + @Nonnull + public String getRequestQuery() + { + final String genericQueryString = super.getRequestQuery(); + if( !genericQueryString.isEmpty() && !queryString.isEmpty() ) { + return queryString + "&" + genericQueryString; + } + return queryString + genericQueryString; + } + + @Override + @Nonnull + public ODataRequestResultGeneric execute( @Nonnull final HttpClient httpClient ) + { + final ODataHttpRequest request = ODataHttpRequest.withoutBody(this, httpClient); + return tryExecute(request::requestGet, httpClient).get(); + } + + /** + * Disable pre-buffering of http response entity. + * + * @since 5.21.0 + */ + @Beta + @Nonnull + public ODataRequestResultResource.Executable withoutResponseBuffering() + { + requestResultFactory = ODataRequestResultFactory.WITHOUT_BUFFER; + return httpClient -> (ODataRequestResultResource) this.execute(httpClient); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResult.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResult.java new file mode 100644 index 000000000..ad7e460b2 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResult.java @@ -0,0 +1,97 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.TreeMap; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.message.StatusLine; + +/** + * Generic type of an OData request result. + */ +public interface ODataRequestResult +{ + /** + * Get the original {@link ODataRequestExecutable} instance that was used for running the OData request. + * + * @return The original {@link ODataRequestExecutable} instance. + */ + @Nonnull + ODataRequestGeneric getODataRequest(); + + /** + * Get the original OData {@link HttpResponse} instance, which holds the HttpEntity and header information. + * + * @return The HttpResponse. + */ + @Nonnull + ClassicHttpResponse getHttpResponse(); + + /** + * Get the HTTP response object status line. + * + * @return the StatusLine. + */ + @Nullable + default StatusLine getStatusLine() + { + return new StatusLine(getHttpResponse()); + } + + /** + * Get the iterable list of HTTP response header names. + * + * @return An iterable set of header names. + */ + @Nonnull + default Iterable getHeaderNames() + { + return getAllHeaderValues().keySet(); + } + + /** + * Get the iterable HTTP header values for a specific header name. The lookup happens case-insensitively. + * + * @param headerName + * The header name to look for. + * @return An iterable set of header values. + */ + @Nonnull + default Iterable getHeaderValues( @Nonnull final String headerName ) + { + return getAllHeaderValues().getOrDefault(headerName, Collections.emptyList()); + } + + /** + * Get all HTTP header values, grouped by the name (case insensitive) of the HTTP header. + * + * @return A case insensitive map of HTTP header names, where each entry is an iterable set of values for the + * specific header name. + */ + @Nonnull + default Map> getAllHeaderValues() + { + final Header[] allHeaders = getHttpResponse().getHeaders(); + final Map> result = new TreeMap<>(String::compareToIgnoreCase); + + for( final Header header : allHeaders ) { + final String headerName = header.getName(); + final String headerValue = header.getValue(); + + if( headerValue == null ) { + continue; + } + + result.computeIfAbsent(headerName, key -> new ArrayList<>()).add(headerValue); + } + return Collections.unmodifiableMap(result); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultDeserializable.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultDeserializable.java new file mode 100644 index 000000000..9497bbf62 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultDeserializable.java @@ -0,0 +1,101 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataDeserializationException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataResponseException; +import com.sap.cloud.sdk.result.ResultElement; + +/** + * Generic type of an OData request result. + */ +public interface ODataRequestResultDeserializable +{ + /** + * Converts ODataRequestResult into POJO. + * + * @param objectType + * type of POJO + * @param + * The generic type of POJO + * @return T - POJO + * + * @throws ODataResponseException + * When the HTTP status indicates an erroneous response. + * @throws ODataDeserializationException + * When deserialization process failed for the OData response object. + */ + @Nonnull + T as( @Nonnull final Class objectType ); + + /** + * Converts ODataRequestResult into list of POJOs. + * + * @param objectType + * type of POJO + * @param + * Generic type of the POJO + * @return List - list of POJOs + * + * @throws ODataResponseException + * When the HTTP status indicates an erroneous response. + * @throws ODataDeserializationException + * When deserialization process failed for the OData response objects. + */ + @Nonnull + List asList( @Nonnull final Class objectType ); + + /** + * Run a consumer for fluent API type ResultElement to iterate over the OData response with a continuous data + * stream. The HttpEntity will be consumed. + * + * @param handler + * The consumer for generic ResultElement. + * + * @throws ODataResponseException + * When the HTTP status indicates an erroneous response. + * @throws ODataDeserializationException + * When deserialization process failed for the OData response objects. + */ + void streamElements( @Nonnull final Consumer handler ); + + /** + * Get the count of elements in the result set. + * + * @return The number of elements. + * + * @throws ODataResponseException + * When the HTTP status indicates an erroneous response. + */ + long getInlineCount(); + + /** + * Construct and get a key-value map from the OData response. The HttpEntity will be consumed. + * + * @return The key-value map. + * + * @throws ODataResponseException + * When the HTTP status indicates an erroneous response. + * @throws ODataDeserializationException + * When deserialization process failed for the OData response object. + */ + @Nonnull + Map asMap(); + + /** + * Construct and get a list of key-value maps from the OData response. The HttpEntity will be consumed. + * + * @return The list of key-value maps. + * + * @throws ODataResponseException + * When the HTTP status indicates an erroneous response. + * @throws ODataDeserializationException + * When deserialization process failed for the OData response objects. + */ + @Nonnull + List> asListOfMaps(); +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultFactory.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultFactory.java new file mode 100644 index 000000000..db7a1f199 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultFactory.java @@ -0,0 +1,53 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static org.slf4j.LoggerFactory.getLogger; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.io.entity.BufferedHttpEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.apache.hc.core5.http.message.StatusLine; +import org.slf4j.Logger; + +import io.vavr.control.Option; +import io.vavr.control.Try; + +/** + * Enum representing the strategy for buffering HTTP responses. + */ +@FunctionalInterface +interface ODataRequestResultFactory +{ + /** + * Strategy that does not buffer the response. + */ + ODataRequestResultFactory WITHOUT_BUFFER = ODataRequestResultResource::new; + + /** + * Strategy that buffers the response by creating a copy of it. + */ + ODataRequestResultFactory WITH_BUFFER = ( oDataRequest, httpResponse, httpClient ) -> { + final StatusLine status = new StatusLine(httpResponse); + final BasicClassicHttpResponse copy = new BasicClassicHttpResponse(status.getStatusCode()); + Option.of(httpResponse.getLocale()).peek(copy::setLocale); + Option.of(httpResponse.getHeaders()).peek(copy::setHeaders); + + final Logger log = getLogger(ODataRequestResultFactory.class); + Option + .of(httpResponse.getEntity()) + .onEmpty(() -> log.debug("HTTP response entity is empty: {}", status)) + .map(entity -> Try.run(() -> copy.setEntity(new BufferedHttpEntity(entity)))) + .peek(b -> b.onSuccess(v -> log.debug("Successfully buffered the HTTP response entity."))) + .peek(b -> b.onFailure(e -> log.warn("Failed to buffer HTTP response entity: {}", status, e))); + + return new ODataRequestResultGeneric(oDataRequest, copy, httpClient); + }; + + ODataRequestResultGeneric create( + @Nonnull final ODataRequestGeneric oDataRequest, + @Nonnull final ClassicHttpResponse httpResponse, + @Nullable final HttpClient httpClient ); +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultGeneric.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultGeneric.java new file mode 100644 index 000000000..567fc0215 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultGeneric.java @@ -0,0 +1,740 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static java.util.function.Predicate.not; + +import static com.google.common.collect.Streams.stream; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.commons.lang3.ClassUtils; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.message.StatusLine; + +import com.google.common.base.Strings; +import com.google.common.reflect.TypeToken; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.google.gson.stream.JsonToken; +import com.sap.cloud.sdk.cloudplatform.connectivity.UriQueryMerger; +import com.sap.cloud.sdk.datamodel.odata.client.JsonPath; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.ODataResponseDeserializer; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataDeserializationException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataRequestException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataResponseException; +import com.sap.cloud.sdk.result.GsonResultElementFactory; +import com.sap.cloud.sdk.result.ResultCollection; +import com.sap.cloud.sdk.result.ResultElement; +import com.sap.cloud.sdk.result.ResultObject; +import com.sap.cloud.sdk.result.ResultPrimitive; + +import io.vavr.control.Option; +import io.vavr.control.Try; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * OData request result for reading entities. + */ +@Slf4j +@EqualsAndHashCode +public class ODataRequestResultGeneric + implements + ODataRequestResult, + ODataRequestResultDeserializable, + ODataRequestResultPagination +{ + private final ODataResponseDeserializer deserializer; + + @Getter + @Nonnull + private final ODataRequestGeneric oDataRequest; + + @Nonnull + @Getter + private final ClassicHttpResponse httpResponse; + + private NumberDeserializationStrategy numberStrategy = NumberDeserializationStrategy.DOUBLE; + + @Nonnull + private final ODataProtocol protocol; + + @Getter( AccessLevel.PACKAGE ) + @Nullable + private final transient HttpClient httpClient; + + /** + * Default constructor. + * + * @param oDataRequest + * The original OData request + * @param httpResponse + * The original Http response + */ + public ODataRequestResultGeneric( + @Nonnull final ODataRequestGeneric oDataRequest, + @Nonnull final ClassicHttpResponse httpResponse ) + { + this(oDataRequest, httpResponse, null); + } + + /** + * + * Default constructor with enabled pagination. + * + * @param oDataRequest + * The original OData request + * @param httpResponse + * The original Http response + * @param httpClient + * The original Http client + */ + public ODataRequestResultGeneric( + @Nonnull final ODataRequestGeneric oDataRequest, + @Nonnull final ClassicHttpResponse httpResponse, + @Nullable final HttpClient httpClient ) + { + this.oDataRequest = oDataRequest; + this.httpResponse = httpResponse; + this.httpClient = httpClient; + this.protocol = oDataRequest.getProtocol(); + + deserializer = new ODataResponseDeserializer(protocol); + } + + @Nullable + @Override + public StatusLine getStatusLine() + { + return new StatusLine(httpResponse); + } + + /** + * Set the default number deserialization strategy for generic JSON numbers without target type mapping. + * + * @param numberStrategy + * The number deserialization strategy to use. + * @return The same instance of {@link ODataRequestGeneric}. + */ + @Nonnull + public ODataRequestResultGeneric withNumberDeserializationStrategy( + @Nonnull final NumberDeserializationStrategy numberStrategy ) + { + this.numberStrategy = numberStrategy; + return this; + } + + /** + * Method that allows consumers to disable buffering HTTP response entity. Note that once this is disabled, HTTP + * responses can only be streamed/read once + * + * @deprecated Please use {@link ODataRequestRead#withoutResponseBuffering()} or + * {@link ODataRequestReadByKey#withoutResponseBuffering()} instead. + */ + @Deprecated + public void disableBufferingHttpResponse() + { + } + + @Override + public void streamElements( @Nonnull final Consumer handler ) + { + final GsonResultElementFactory resultElementFactory = getResultElementFactory(); + + final Integer numConsumedElements = HttpEntityReader.stream(this, reader -> { + deserializer.positionReaderToResultSet(reader); + + int count = 0; + while( reader.hasNext() && reader.peek() == JsonToken.BEGIN_OBJECT ) { + final JsonElement jsonElement = JsonParser.parseReader(reader); + final ResultElement resultElement = resultElementFactory.create(jsonElement); + handler.accept(resultElement); + count++; + } + reader.close(); + return count; + }); + + log.debug("Iterated {} elements.", numConsumedElements); + } + + private GsonResultElementFactory getResultElementFactory() + { + final GsonBuilder gsonBuilder = ODataGsonBuilder.newGsonBuilder(numberStrategy); + return new GsonResultElementFactory(gsonBuilder); + } + + /** + * Try to extract a version identifier from the ETag header. + * + * @return An option holding the version identifier or {@link Option.None}, if none was found. + */ + @Nonnull + public Option getVersionIdentifierFromHeader() + { + return Option.ofOptional(stream(getHeaderValues("ETag")).filter(not(Strings::isNullOrEmpty)).findFirst()); + } + + @Nonnull + private ResultPrimitive loadPrimitiveFromResponse( + @Nonnull final Function jsonElementExtractor ) + { + final GsonResultElementFactory elementFactory = getResultElementFactory(); + final ResultPrimitive result = HttpEntityReader.read(this, element -> { + final Option single = + deserializer + .getElementToResultPrimitiveSingle(element) + .map(jsonElementExtractor) + .map(elementFactory::create) + .map(ResultElement::getAsPrimitive); + return single.getOrNull(); + }); + if( result == null ) { + log.debug("{} response cannot be read as a primitive value.", protocol); + throw new ODataDeserializationException( + getODataRequest(), + getHttpResponse(), + "Unable to read " + protocol + " response.", + null); + } + return result; + } + + @Nonnull + private ResultCollection loadPrimitiveCollectionFromResponse() + { + final GsonResultElementFactory elementFactory = getResultElementFactory(); + + final ResultCollection result = HttpEntityReader.read(this, element -> { + final Option set = + deserializer + .getElementToResultPrimitiveSet(element) + .map(elementFactory::create) + .map(ResultElement::getAsCollection); + return set.getOrNull(); + }); + if( result == null ) { + log.debug("{} response cannot be read as set of primitive values.", protocol); + throw new ODataDeserializationException( + getODataRequest(), + getHttpResponse(), + "Unable to read " + protocol + " response.", + null); + } + return result; + } + + @Nonnull + private ResultObject loadEntryFromResponse( @Nonnull final Function jsonElementExtractor ) + { + final GsonResultElementFactory elementFactory = getResultElementFactory(); + final ResultObject result = HttpEntityReader.read(this, element -> { + final Option single = + deserializer + .getElementToResultSingle(element) + .map(jsonElementExtractor) + .map(elementFactory::create) + .map(ResultElement::getAsObject); + return single.getOrNull(); + }); + if( result == null ) { + log.debug("{} response cannot be read as a single entity.", protocol); + throw new ODataDeserializationException( + getODataRequest(), + getHttpResponse(), + "Unable to read " + protocol + " response.", + null); + } + return result; + } + + @Nonnull + private ResultCollection loadEntryCollectionFromResponse() + { + final GsonResultElementFactory elementFactory = getResultElementFactory(); + + final ResultCollection result = HttpEntityReader.read(this, element -> { + final Option set = + deserializer + .getElementToResultSet(element) + .map(elementFactory::create) + .map(ResultElement::getAsCollection); + return set.getOrNull(); + }); + if( result == null ) { + log.debug("{} response cannot be read as set of entities.", protocol); + throw new ODataDeserializationException( + getODataRequest(), + getHttpResponse(), + "Unable to read " + protocol + " response.", + null); + } + return result; + } + + @Nonnull + private ResultElement getResultElement() + { + return loadEntryFromResponse(Function.identity()); + } + + @Nonnull + Iterable getResultElements() + { + return loadEntryCollectionFromResponse(); + } + + @Nonnull + @Override + public Iterator iterator() + { + assertNonEmptyPayload(); + return getResultElements().iterator(); + } + + @Override + @Nonnull + public T as( @Nonnull final Class objectType ) + { + return as(objectType, Function.identity()); + } + + /** + * Converts ODataRequestResult into a POJO based on a function extracting the relevant JSON response object. + * + * @param objectType + * type of POJO + * @param + * The generic type of POJO + * @param resultExtractor + * A function extracting the relevant result object of the JSON root object. In case of OData V2 the + * {@code "d"} object is treated as the root object. Pass {@link Function#identity()} in case no + * transformation should take place. + * @return T - POJO + * + * @throws ODataResponseException + * When the HTTP status indicates an erroneous response. + * @throws ODataDeserializationException + * When deserialization process failed for the OData response object. + */ + @Nonnull + public < + T> T as( @Nonnull final Class objectType, @Nonnull final Function resultExtractor ) + { + assertNonEmptyPayload(); + assertResultTypeIsNotVoid(objectType); + if( isPrimitiveOrWrapperOrString(objectType) ) { + @Nullable + final ContentType contentType = ContentType.parse(getHttpResponse().getEntity().getContentType()); + + // parse text/plain responses directly, do not use JSON deserializers + if( contentType != null + && Objects.equals(contentType.getMimeType(), ContentType.TEXT_PLAIN.getMimeType()) ) { + return getPrimitiveObjectFromPlainText(objectType); + } + + return getPrimitiveObjectFromJson(objectType, resultExtractor); + } else { + return getComplexObjectFromJson(objectType, resultExtractor); + } + } + + @Nonnull + private T getComplexObjectFromJson( + @Nonnull final Class objectType, + @Nonnull final Function resultExtractor ) + { + final ResultObject resultObject = loadEntryFromResponse(resultExtractor); + + final ODataRequestGeneric r = getODataRequest(); + + return Try + .of(() -> resultObject.as(objectType)) + .onFailure(e -> log.debug("Failed to deserialize {} from JSON response.", objectType)) + .getOrElseThrow( + e -> new ODataDeserializationException( + r, + getHttpResponse(), + "Failed to deserialize a complex object.", + e)); + } + + @Nonnull + private T getPrimitiveObjectFromJson( + @Nonnull final Class objectType, + @Nonnull final Function resultExtractor ) + { + final ResultPrimitive resultPrimitive = loadPrimitiveFromResponse(resultExtractor); + + final ODataRequestGeneric r = getODataRequest(); + + return Try + .of(() -> getPrimitiveAsType(resultPrimitive, objectType)) + .onFailure(e -> log.debug("Failed to deserialize {} from JSON response.", objectType)) + .getOrElseThrow( + e -> new ODataDeserializationException( + r, + getHttpResponse(), + "Failed to deserialize a primitive object.", + e)); + } + + @Nonnull + private T getPrimitiveObjectFromPlainText( @Nonnull final Class objectType ) + throws ODataDeserializationException + { + final ODataRequestGeneric r = getODataRequest(); + final ClassicHttpResponse httpResponse = getHttpResponse(); + + final String objectText = + Try + .of(() -> EntityUtils.toString(getHttpResponse().getEntity(), StandardCharsets.UTF_8)) + .getOrElseThrow( + e -> new ODataDeserializationException(r, httpResponse, "Failed to parse HTTP response.", e)); + + return Try + .of(() -> new Gson().fromJson(objectText, objectType)) + .filterTry( + Objects::nonNull, + () -> new ODataDeserializationException(r, httpResponse, "The response is null.", null)) + .onFailure(e -> log.debug("Failed to deserialize {} from text/plain response: {}", objectType, objectText)) + .getOrElseThrow( + e -> new ODataDeserializationException( + r, + httpResponse, + "Failed to deserialize a primitive object.", + e)); + } + + /* + * Helper function to check if the objectType passed in any of the primitive or wrapper types (Boolean, Byte, + * Character, Short, Integer, Long, Double, Float) or is String. + */ + private boolean isPrimitiveOrWrapperOrString( @Nonnull final Class objectType ) + { + return ClassUtils.isPrimitiveOrWrapper(objectType) || objectType == String.class; + } + + @Nonnull + @SuppressWarnings( "unchecked" ) + private T as( @Nonnull final Type objectType ) + { + final Class typeClass; + if( objectType instanceof ParameterizedType ) { + typeClass = (Class) ((ParameterizedType) objectType).getRawType(); + } else { + typeClass = (Class) objectType.getClass(); + } + return as(typeClass); + } + + @Override + @Nonnull + public List asList( @Nonnull final Class objectType ) + { + assertNonEmptyPayload(); + assertResultTypeIsNotVoid(objectType); + final ResultCollection result = + isPrimitiveOrWrapperOrString(objectType) + ? loadPrimitiveCollectionFromResponse() + : loadEntryCollectionFromResponse(); + + return Try + .of(() -> result.asList(objectType)) + .onFailure(e -> log.debug("Failed to parse {} result to a list of {}", protocol, objectType)) + .getOrElseThrow( + e -> new ODataDeserializationException( + getODataRequest(), + getHttpResponse(), + "Failed to parse " + protocol + " result to a list.", + e)); + } + + @Nonnull + @SuppressWarnings( "unchecked" ) + private List asList( @Nonnull final Type objectType ) + { + final Class typeClass; + if( objectType instanceof ParameterizedType ) { + typeClass = (Class) ((ParameterizedType) objectType).getRawType(); + } else { + typeClass = (Class) objectType.getClass(); + } + return asList(typeClass); + } + + @Override + public long getInlineCount() + { + assertNonEmptyPayload(); + for( final JsonPath path : getODataRequest().getProtocol().getPathToInlineCount().getPaths() ) { + final ResultElement resultElement = getResultElement(path); + if( resultElement != null ) { + return resultElement.getAsPrimitive().asLong(); + } + } + + final String message = "Inline count not found in " + protocol + " response payload."; + throw new ODataDeserializationException(oDataRequest, getHttpResponse(), message, null); + } + + @Override + @Nonnull + public Option getNextLink() + { + log.debug("Checking for a next link on current page."); + assertNonEmptyPayload(); + for( final JsonPath path : getODataRequest().getProtocol().getPathToNextLink().getPaths() ) { + final ResultElement resultElement = getResultElement(path); + if( resultElement != null ) { + String nextLink = resultElement.asString(); + log.debug("Found reference to next page: {}", nextLink); + nextLink = removeDuplicateQueryParameters(nextLink); + return Option.of(nextLink); + } + } + log.debug("Result does not reference any further pages."); + return Option.none(); + } + + /** + * Get the delta link of the current result-set. + * + * @return the OData protocol specific value of delta link property. + */ + @Nonnull + public Option getDeltaLink() + { + log.debug("Checking for a delta link on current page."); + assertNonEmptyPayload(); + for( final JsonPath path : getODataRequest().getProtocol().getPathToDeltaLink().getPaths() ) { + final ResultElement resultElement = getResultElement(path); + if( resultElement != null ) { + return Option + .of(resultElement) + .map(ResultElement::asString) + .peek(link -> log.debug("Found reference to delta page: {}", link)); + } + } + log.debug("Result does not contain a delta reference."); + return Option.none(); + } + + @Nullable + private ResultElement getResultElement( @Nonnull final JsonPath path ) + { + ResultElement resultElement = getResultElement(); + final List nodes = path.getNodes(); + for( int i = 0; i < nodes.size() && resultElement != null && resultElement.isResultObject(); i++ ) { + resultElement = resultElement.getAsObject().get(nodes.get(i)); + } + return resultElement; + } + + @Override + @Nonnull + public Map asMap() + { + assertNonEmptyPayload(); + final Type type = new TypeToken>() + { + private static final long serialVersionUID = 42L; + }.getType(); + return as(type); + } + + @Override + @Nonnull + public List> asListOfMaps() + { + assertNonEmptyPayload(); + final Type type = new TypeToken>() + { + private static final long serialVersionUID = 42L; + }.getType(); + return asList(type); + } + + @Nonnull + private T getPrimitiveAsType( @Nonnull final ResultPrimitive primitive, @Nonnull final Class type ) + throws IllegalArgumentException + { + final Object primitiveAsType; + try { + if( type == Boolean.class ) { + primitiveAsType = primitive.asBoolean(); + } else if( type == Byte.class ) { + primitiveAsType = primitive.asByte(); + } else if( type == Short.class ) { + primitiveAsType = primitive.asShort(); + } else if( type == Integer.class ) { + primitiveAsType = primitive.asInteger(); + } else if( type == Long.class ) { + primitiveAsType = primitive.asLong(); + } else if( type == BigInteger.class ) { + primitiveAsType = primitive.asBigInteger(); + } else if( type == Float.class ) { + primitiveAsType = primitive.asFloat(); + } else if( type == Double.class ) { + primitiveAsType = primitive.asDouble(); + } else if( type == BigDecimal.class ) { + primitiveAsType = primitive.asBigDecimal(); + } else if( type == Character.class ) { + primitiveAsType = primitive.asCharacter(); + } else if( type == String.class ) { + primitiveAsType = primitive.asString(); + } else { + throw new IllegalArgumentException( + "Failed to convert primitive '" + + primitive.asString() + + "' to unsupported type: " + + type.getName() + + "."); + } + } + catch( final UnsupportedOperationException e ) { + throw new IllegalArgumentException( + "Failed to convert primitive '" + primitive.asString() + "' to type: " + type.getName() + ".", + e); + } + + @SuppressWarnings( "unchecked" ) + final T result = (T) primitiveAsType; + return result; + } + + @Override + @Nonnull + public Try tryGetNextPage() + { + final ODataRequestGeneric rawRequest = getODataRequest(); + if( !(rawRequest instanceof ODataRequestRead) ) { + return Try.failure(new IllegalStateException("Pagination is only applicable for read requests.")); + } + final ODataRequestRead request = (ODataRequestRead) rawRequest; + + final HttpClient httpClient = getHttpClient(); + if( httpClient == null ) { + final String message = + "Unable to access response of next page: HTTP client was not provided when creating this response object."; + return Try.failure(new ODataRequestException(request, message, null)); + } + + final Try nextQuery = + getNextLink() + .toTry(() -> new IllegalStateException("Current page of result-set does not reference a next page.")) + .map(URI::create) + .map(URI::getRawQuery); + + if( nextQuery.isFailure() ) { + final String message = "Unable to extract query parameters for querying next page of result-set."; + return Try.failure(new ODataRequestException(request, message, nextQuery.getCause())); + } + + log.debug("Querying {} service for next page {}", protocol, nextQuery.get()); + + // create next read request + final ODataRequestRead nextReadRequest = + new ODataRequestRead( + request.getServicePath(), + request.getResourcePath(), + nextQuery.get(), + request.getProtocol()); + + // populate headers + request.getHeaders().forEach(nextReadRequest::setHeader); + + // execute request + return Try.of(() -> nextReadRequest.execute(httpClient)); + } + + /** + * Check whether or not the HttpResponse contains (potentially empty) payload. + * + * @return True, if the HTTP response contains an {@link HttpEntity}. + */ + public boolean hasPayload() + { + return getHttpResponse().getEntity() != null; + } + + /** + * @throws ODataDeserializationException + * if the response doesn't contain any payload + */ + private void assertNonEmptyPayload() + { + if( !hasPayload() ) { + throw new ODataDeserializationException( + getODataRequest(), + getHttpResponse(), + protocol + " response did not contain any payload.", + null); + } + } + + /** + * @throws IllegalArgumentException + * in case the passed class is {@link Void} + */ + private void assertResultTypeIsNotVoid( @Nonnull final Class cls ) + { + if( Void.class.equals(cls) ) { + throw new IllegalArgumentException("Interpreting results as Void is not allowed."); + } + + } + + @Nonnull + private String removeDuplicateQueryParameters( @Nonnull final String nextLink ) + { + if( !(httpClient instanceof UriQueryMerger) ) { + return nextLink; + } + final String query = ((UriQueryMerger) httpClient).mergeRequestUri(URI.create("")).getRawQuery(); + if( query == null ) { + return nextLink; + } + final String[] segments = nextLink.split("\\?", 2); + if( segments.length < 2 ) { + return nextLink; + } + final String[] queryArguments = query.split("&"); + for( final String argument : queryArguments ) { + if( segments[1].contains(argument) ) { + segments[1] = segments[1].replace(argument, ""); + } + } + if( nextLink.length() + 1 == segments[0].length() + segments[1].length() ) { + return nextLink; + } + // after removal of arguments clean-up query: fix "?foo=bar&&&one=1", fix "?&one=1", fix "?foo=bar&" + segments[1] = segments[1].replaceAll("&&+", "&").replace("?&", "?").replaceAll("&$", ""); + final String updatedLink = segments[0] + "?" + segments[1]; + log.debug("Updated reference to next page: {}", updatedLink); + return updatedLink; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultMultipart.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultMultipart.java new file mode 100644 index 000000000..c37ea782a --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultMultipart.java @@ -0,0 +1,19 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import javax.annotation.Nonnull; + +/** + * Generic type of an OData request result. + */ +public interface ODataRequestResultMultipart +{ + /** + * Get the result from the OData batch response for a specific sub-request. + * + * @param request + * The request to look for in the OData batch response. + * @return The OData result, that was extracted from the original OData batch response. + */ + @Nonnull + ODataRequestResult getResult( @Nonnull final ODataRequestGeneric request ); +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultMultipartGeneric.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultMultipartGeneric.java new file mode 100644 index 000000000..a93b3f8c9 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultMultipartGeneric.java @@ -0,0 +1,179 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.util.ArrayList; +import java.util.List; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.message.StatusLine; + +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataDeserializationException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataResponseException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataServiceErrorException; + +import io.vavr.Lazy; +import io.vavr.Tuple2; +import io.vavr.control.Try; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * OData request result for reading entities. + */ +@Slf4j +public class ODataRequestResultMultipartGeneric + implements + ODataRequestResultMultipart, + ODataRequestResult, + AutoCloseable +{ + @Getter( AccessLevel.PRIVATE ) + @Nonnull + private final ODataRequestBatch batchRequest; + + @Getter + @Nonnull + private final ClassicHttpResponse httpResponse; + + @Nonnull + private final Lazy>>> batchResponses = Lazy.of(this::loadBatchResponses); + + @Nonnull + private final List closeHandlers = new ArrayList<>(); + + /** + * Create an instance of OData request result for multipart/mixed responses. + * + * @param oDataRequest + * The original OData request instance. + * @param httpResponse + * The native HTTP response object. + */ + ODataRequestResultMultipartGeneric( + @Nonnull final ODataRequestBatch oDataRequest, + @Nonnull final ClassicHttpResponse httpResponse ) + { + batchRequest = oDataRequest; + this.httpResponse = httpResponse; + } + + @Nullable + @Override + public StatusLine getStatusLine() + { + return new StatusLine(httpResponse); + } + + /** + * Get the original {@link ODataRequestBatch batch request} that was used for running the OData request. + * + * @return The batch request this + */ + @Nonnull + @Override + public ODataRequestBatch getODataRequest() + { + return batchRequest; + } + + /** + * Gets the {@link ODataRequestResultGeneric} for the given {@code request}. + * + * @param request + * The request for which the result should be returned. + * @return The result for the given {@code request}. + * @throws ODataResponseException + * When the OData batch response cannot be parsed or HTTP response is not healthy. + * @throws IllegalArgumentException + * When the provided request reference could not be found in the original batch request. + * @throws ODataServiceErrorException + * When the response contains an OData error message according to specification. + */ + @Nonnull + @Override + public ODataRequestResultGeneric getResult( @Nonnull final ODataRequestGeneric request ) + throws ODataResponseException, + IllegalArgumentException + { + @Nullable + final Tuple2 responsePosition = ODataRequestBatch.getBatchItemPosition(batchRequest, request); + if( responsePosition == null ) { + throw new IllegalArgumentException( + "Incorrect API usage. Please pass the original OData request reference that was handled as batch request item."); + } + + log.debug("Looking for request {} in batch response at position {}", request, responsePosition); + final List> batchResponseItems = getBatchedResponses(); + if( responsePosition._1() >= batchResponseItems.size() ) { + String msg = "Unable to extract batch response item at position %s. The response contains only %s items."; + msg = String.format(msg, responsePosition._1() + 1, batchResponseItems.size()); + throw new ODataResponseException(batchRequest, httpResponse, msg, null); + } + final List subResponses = batchResponseItems.get(responsePosition._1()); + + final boolean isSingleResponse = responsePosition._2() == null || responsePosition._2() >= subResponses.size(); + final ClassicHttpResponse response = subResponses.get(isSingleResponse ? 0 : responsePosition._2()); + + if( response == null ) { + final String msg = "Illegal payload for " + batchRequest.getProtocol() + " batch response item."; + throw new ODataDeserializationException(batchRequest, httpResponse, msg, null); + } + + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(batchRequest, response); + ODataHealthyResponseValidator.requireHealthyResponse(result); + return result; + } + + /** + * Get the multi-part segments as raw HTTP response object. Response objects of same changesets are grouped. + *

+ * Please note: The returned list is lazily evaluated. The underlying HTTP response entity is being parsed, + * upon list access. + * + * @return The virtual HTTP response objects. + */ + @Nonnull + public List> getBatchedResponses() + { + return batchResponses + .get() + .getOrElseThrow( + e -> new ODataResponseException( + getBatchRequest(), + getHttpResponse(), + "Failed to read " + batchRequest.getProtocol() + " batch response.", + e)); + } + + @Nonnull + private Try>> loadBatchResponses() + { + return Try.of(() -> { + @SuppressWarnings( "resource" ) // resource will be registered in the close handlers + final MultipartParser parser = MultipartParser.ofHttpResponse(getHttpResponse()); + closeHandlers.add(parser::close); + return parser.toList(MultipartHttpResponse::ofHttpContent); + }); + } + + /** + * Closes the underlying HTTP response entity. + * + * @since 5.5.0 + */ + @Override + public void close() + { + // close HTTP entity + final HttpEntity entity = getHttpResponse().getEntity(); + Try.run(() -> EntityUtils.consume(entity)).onFailure(e -> log.warn("Failed to consume the HTTP entity.", e)); + + // close any additional registered handler + closeHandlers.forEach(Runnable::run); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultPagination.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultPagination.java new file mode 100644 index 000000000..90078585a --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultPagination.java @@ -0,0 +1,67 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.util.List; + +import javax.annotation.Nonnull; + +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.sap.cloud.sdk.result.ResultElement; + +import io.vavr.control.Option; +import io.vavr.control.Try; + +/** + * This class provides methods to lazily iterate through the pages of an OData read request result-set. It allows for + * memory-efficient exploration / parsing with continuous requests to the OData endpoint. It enables the consumption of + * all data through server-driven pagination. + */ +public interface ODataRequestResultPagination extends Iterable +{ + /** + * Get the next page link of result-set. + * + * @return the OData protocol specific value of next link property. Or {@code null} if last page of result-set. + */ + @Nonnull + Option getNextLink(); + + /** + * Get the next page link of result-set. + * + * @return the OData protocol specific value of next link property. Or {@code null} if last page of result-set. + */ + @Nonnull + Try tryGetNextPage(); + + /** + * Get the original {@link ODataRequestGeneric} instance that was used for running the OData request. + * + * @return The original {@link ODataRequestGeneric} instance. + */ + @Nonnull + ODataRequestGeneric getODataRequest(); + + /** + * Iterate over result-set pages. + * + * @param type + * The expected class reference to be used for deserializing the resulting items. + * @param + * The generic item type. + * @return An instance of {@link Iterable} that allows lazy iteration through OData result pages. + */ + @Nonnull + @SuppressWarnings( { "StaticPseudoFunctionalStyleMethod", "ConstantConditions" } ) + default Iterable> iteratePages( @Nonnull final Class type ) + { + // create a lazy iterable for multi-page responses + final Iterable iterable = () -> new ODataRequestResultPaginationIterator(this); + + // create a lazy iterable for multi-page result elements + final Iterable> pages = Iterables.transform(iterable, Lists::newArrayList); + + // cast items in lists of lazy-iterable + return Iterables.transform(pages, list -> Lists.transform(list, item -> item.getAsObject().as(type))); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultPaginationIterator.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultPaginationIterator.java new file mode 100644 index 000000000..48944b5cc --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultPaginationIterator.java @@ -0,0 +1,77 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.function.Supplier; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataRequestException; + +import lombok.extern.slf4j.Slf4j; + +/** + * The implementation for the pagination based iterator of OData result-set, The methods are acting lazily. + */ +@Slf4j +class ODataRequestResultPaginationIterator implements Iterator +{ + // stateful reference to access the next page request + // if null: no next page, current page is last + @Nullable + private Supplier nextPageLazy; + + /** + * Default constructor. + * + * @param firstPage + * First page of the result-set. + */ + ODataRequestResultPaginationIterator( @Nonnull final ODataRequestResultPagination firstPage ) + { + nextPageLazy = () -> firstPage; + } + + @Override + public boolean hasNext() + { + return nextPageLazy != null; + } + + @Override + @Nonnull + public ODataRequestResultPagination next() + throws NoSuchElementException, + ODataException + { + log.debug("Getting next page of OData request."); + if( !hasNext() ) { + throw new NoSuchElementException("No next page of OData result-set defined."); + } + + final ODataRequestResultPagination page = Objects.requireNonNull(nextPageLazy).get(); + + log.debug("Retrieved new page from OData service."); + nextPageLazy = page.getNextLink().isDefined() ? () -> requestNextPage(page) : null; + return page; + } + + /** + * Request the next page of the result-set. + * + * @param page + * The current page. + * @return The next page. + */ + @Nonnull + protected ODataRequestResultPagination requestNextPage( @Nonnull final ODataRequestResultPagination page ) + { + return page + .tryGetNextPage() + .andThenTry(ODataHealthyResponseValidator::requireHealthyResponse) + .getOrElseThrow(e -> new ODataRequestException(page.getODataRequest(), "Failed to handle next page.", e)); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultResource.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultResource.java new file mode 100644 index 000000000..562ebf8c6 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultResource.java @@ -0,0 +1,73 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.io.IOException; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.io.entity.EntityUtils; + +import com.google.common.annotations.Beta; + +import lombok.EqualsAndHashCode; +import lombok.extern.slf4j.Slf4j; + +/** + * OData request result for reading entities. The request is not yet closed. The response is not consumed. The + * connection is not yet released. + *

+ * Note: This class implements {@link AutoCloseable} and should be closed after use to ensure that the underlying + * connection is released. + *

+ * Note: This class is not thread-safe. The HTTP response object should not be consumed by multiple threads at + * the same time. + * + * @since 5.21.0 + */ +@Slf4j +@Beta +@EqualsAndHashCode( callSuper = true ) +public class ODataRequestResultResource extends ODataRequestResultGeneric implements AutoCloseable +{ + /** + * Default constructor. + * + * @param oDataRequest + * The original OData request + * @param httpResponse + * The original Http response + * @param httpClient + * The Http client used to execute the request + */ + ODataRequestResultResource( + @Nonnull final ODataRequestGeneric oDataRequest, + @Nonnull final ClassicHttpResponse httpResponse, + @Nullable final HttpClient httpClient ) + { + super(oDataRequest, httpResponse, httpClient); + } + + @Override + public void close() + { + try { + EntityUtils.consume(getHttpResponse().getEntity()); + } + catch( final IOException e ) { + log.warn("Failed to close the HTTP response entity.", e); + } + } + + /** + * Interface for executing OData requests that return a resource which must be closed. + */ + @FunctionalInterface + public interface Executable extends ODataRequestExecutable + { + @Nonnull + @Override + ODataRequestResultResource execute( @Nonnull final HttpClient httpClient ); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestUpdate.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestUpdate.java new file mode 100644 index 000000000..659513512 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestUpdate.java @@ -0,0 +1,223 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import static org.apache.hc.core5.http.HttpHeaders.CONTENT_TYPE; + +import java.net.URI; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; + +import com.google.common.collect.Lists; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataRequestException; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; + +import io.vavr.control.Try; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Delegate; +import lombok.extern.slf4j.Slf4j; + +/** + * The executable OData patch update request. + */ +@Getter +@EqualsAndHashCode( callSuper = true ) +@Slf4j +public class ODataRequestUpdate extends ODataRequestGeneric +{ + @Nonnull + @Getter( AccessLevel.NONE ) + private final HttpEntity requestHttpEntity; + + /** + * The {@link UpdateStrategy} determines if the entity will be changed or replaced. + */ + @Nonnull + @Setter + private UpdateStrategy updateStrategy; + + @Nullable + private final String versionIdentifier; + + /** + * Convenience constructor for OData update requests on entity collections directly. For operations on nested + * entities use + * {@link #ODataRequestUpdate(String, ODataResourcePath, String, UpdateStrategy, String, ODataProtocol)}. + * + * @param servicePath + * The OData service path. + * @param entityName + * The OData entity name. + * @param entityKey + * The OData entity key. + * @param serializedEntity + * The serialized OData entity. + * @param updateStrategy + * The update strategy. + * @param versionIdentifier + * The entity version identifier. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestUpdate( + @Nonnull final String servicePath, + @Nonnull final String entityName, + @Nonnull final ODataEntityKey entityKey, + @Nonnull final String serializedEntity, + @Nonnull final UpdateStrategy updateStrategy, + @Nullable final String versionIdentifier, + @Nonnull final ODataProtocol protocol ) + { + this( + servicePath, + ODataResourcePath.of(entityName, entityKey), + serializedEntity, + updateStrategy, + versionIdentifier, + protocol); + } + + /** + * Default constructor for OData Update requests. + * + * @param servicePath + * The OData service path. + * @param entityPath + * The {@link ODataResourcePath} that identifies the entity to update. + * @param serializedEntity + * The serialized OData entity. + * @param updateStrategy + * The update strategy. + * @param versionIdentifier + * The entity version identifier. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestUpdate( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath entityPath, + @Nonnull final String serializedEntity, + @Nonnull final UpdateStrategy updateStrategy, + @Nullable final String versionIdentifier, + @Nonnull final ODataProtocol protocol ) + { + this( + servicePath, + entityPath, + new ComparableHttpEntity(serializedEntity), + updateStrategy, + versionIdentifier, + protocol); + } + + /** + * Default constructor for OData Update requests. + * + * @param servicePath + * The OData service path. + * @param entityPath + * The {@link ODataResourcePath} that identifies the entity to update. + * @param httpEntity + * The Http entity. + * @param updateStrategy + * The update strategy. + * @param versionIdentifier + * The entity version identifier. + * @param protocol + * The OData protocol to use. + */ + public ODataRequestUpdate( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath entityPath, + @Nonnull final HttpEntity httpEntity, + @Nonnull final UpdateStrategy updateStrategy, + @Nullable final String versionIdentifier, + @Nonnull final ODataProtocol protocol ) + { + super(servicePath, entityPath, protocol); + this.requestHttpEntity = httpEntity; + this.updateStrategy = updateStrategy; + this.versionIdentifier = versionIdentifier; + + final String contentType = httpEntity.getContentType(); + if( contentType != null ) { + headers.putIfAbsent(CONTENT_TYPE, Lists.newArrayList(contentType)); + } + } + + @Nonnull + @Override + public URI getRelativeUri( @Nonnull final UriEncodingStrategy strategy ) + { + return ODataUriFactory.createAndEncodeUri(getServicePath(), getResourcePath(), getRequestQuery(), strategy); + } + + @Nonnull + @Override + public ODataRequestResultGeneric execute( @Nonnull final HttpClient httpClient ) + { + final ODataHttpRequest request = ODataHttpRequest.forHttpEntity(this, httpClient, requestHttpEntity); + addVersionIdentifierToHeaderIfPresent(versionIdentifier); + + switch( updateStrategy ) { + case MODIFY_WITH_PATCH, MODIFY_WITH_PATCH_RECURSIVE_DELTA, MODIFY_WITH_PATCH_RECURSIVE_FULL: + return tryExecuteWithCsrfToken(httpClient, request::requestPatch).get(); + case REPLACE_WITH_PUT: + return tryExecuteWithCsrfToken(httpClient, request::requestPut).get(); + default: + throw new IllegalStateException("Unexpected update Strategy: " + updateStrategy); + } + } + + /** + * Get the String representation of the update payload. + * + * @return The serialized entity. + */ + @Nonnull + public String getSerializedEntity() + { + return Try + .of(() -> EntityUtils.toString(requestHttpEntity, UTF_8)) + .getOrElseThrow(e -> new ODataRequestException(this, "Unable to serialize request payload.", e)); + } + + /** + * Wrapper for an HttpEntity instance. Enable {@code Object#equals} and {@code Object#hashCode} on original data. + */ + @EqualsAndHashCode + @RequiredArgsConstructor + private static class ComparableHttpEntity implements HttpEntity + { + @Nonnull + private final Object data; + + @EqualsAndHashCode.Exclude + @Delegate + @Nonnull + private final HttpEntity delegate; + + /** + * Custom constructor for application/json will drop the charset information until CLOUDECOSYSTEM-9450 is done. + * + * @param json + * The serialized entity json representation. + */ + private ComparableHttpEntity( final String json ) + { + this(json, new StringEntity(json, ContentType.create("application/json"))); + } + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataUriFactory.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataUriFactory.java new file mode 100644 index 000000000..30ae9ac89 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataUriFactory.java @@ -0,0 +1,243 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.common.base.Strings; +import com.google.common.net.UrlEscapers; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; + +import io.vavr.control.Option; +import lombok.extern.slf4j.Slf4j; + +/** + * Builds up OData URLs and ensures correct encoding. + */ +@Slf4j +public class ODataUriFactory +{ + private static final Pattern PATTERN_DELTA_TOKEN = + Pattern.compile("\\$deltatoken=([^&]+)", Pattern.CASE_INSENSITIVE); + + private static final Pattern PATTERN_SKIP_TOKEN = Pattern.compile("\\$skiptoken=([^&]+)", Pattern.CASE_INSENSITIVE); + + private static final Predicate VALID_URL_QUERY = + Pattern.compile("^[a-zA-Z0-9/?:@\\-._~!$&'()*+,;=%]*$").asPredicate(); + + /** + * Constructs a URI out of service path, entity path and query string. + * + * @param servicePath + * The unencoded service path. + * @param resourcePath + * The {@link ODataResourcePath path} identifying the resource to be accessed. + * @param encodedQuery + * Optional: An encoded string representing the URL query part. + * + * @return The correctly encoded URI. + */ + @Nonnull + static URI createAndEncodeUri( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath resourcePath, + @Nullable final String encodedQuery, + @Nonnull final UriEncodingStrategy strategy ) + { + return createAndEncodeUri(servicePath, resourcePath.toEncodedPathString(strategy), encodedQuery, strategy); + } + + /** + * Constructs a URI out of service path, entity path and query string. + * + * @param servicePath + * The unencoded service path. + * @param encodedResourcePath + * The encoded resource path identifying the resource to be accessed. + * @param encodedQuery + * Optional: An encoded string representing the URL query part. + * + * @return The correctly encoded URI. + */ + @Nonnull + static URI createAndEncodeUri( + @Nonnull final String servicePath, + @Nonnull final String encodedResourcePath, + @Nullable final String encodedQuery, + @Nonnull final UriEncodingStrategy strategy ) + { + String encodedPath = encodePath(servicePath, strategy); + encodedPath = sanitizeUrlPath(encodedPath); + + encodedPath += encodedResourcePath.startsWith("/") ? encodedResourcePath : "/" + encodedResourcePath; + + final Option maybeQueryEncoded = Option.of(encodedQuery).filter(s -> !Strings.isNullOrEmpty(s)); + + if( maybeQueryEncoded.isDefined() && !maybeQueryEncoded.exists(VALID_URL_QUERY) ) { + throw new IllegalArgumentException( + "The query part of OData request is not correctly encoded: \"" + encodedQuery + "\""); + } + + final String resultUrl = encodedPath + maybeQueryEncoded.map(q -> "?" + q).getOrElse(""); + + try { + return new URI(resultUrl); + } + catch( final URISyntaxException e ) { + log + .error( + "Failed to construct URI for OData request with service path '{}', resource path '{}' and query '{}'.", + servicePath, + encodedResourcePath, + encodedQuery, + e); + throw new IllegalArgumentException("Failed to construct URI.", e); + } + } + + /** + * Encodes the individual path parts of a string. Forward slashes are treated as path segment separators and thus + * not be encoded. Encoding is done by {@link UrlEscapers#urlPathSegmentEscaper()}. + * + * @param path + * The unencoded URL path. + * + * @return The percentage encoded URL path. + */ + @Nonnull + public static String encodePath( @Nonnull final String path ) + { + return encodePath(path, UriEncodingStrategy.REGULAR); + } + + /** + * Encodes the individual path parts of a string. Forward slashes are treated as path segment separators and thus + * not be encoded. Encoding is done by {@link UrlEscapers#urlPathSegmentEscaper()}. + * + * @param path + * The unencoded URL path. + * + * @param strategy + * The URI encoding strategy. + * @return The percentage encoded URL path. + */ + @Nonnull + public static String encodePath( @Nonnull final String path, @Nonnull final UriEncodingStrategy strategy ) + { + return Arrays + .stream(path.split("/")) + .map(strategy.getPathPercentEscaper()::escape) + .collect(Collectors.joining("/")); + } + + /** + * Encodes an individual part of a URL path. Any forward slashes will not be treated as path segment separators but + * instead be encoded. Encoding is done by the default {@link UrlEscapers#urlPathSegmentEscaper()}. + * + * @param path + * The unencoded URL path segment. + * + * @return The percentage encoded URL path segment. + */ + @Nonnull + public static String encodePathSegment( @Nonnull final String path ) + { + return encodePathSegment(path, UriEncodingStrategy.REGULAR); + } + + /** + * Encodes an individual part of a URL path. Any forward slashes will not be treated as path segment separators but + * instead be encoded. Encoding is done according to the passed {@link UriEncodingStrategy#getPathPercentEscaper()}. + * + * @param path + * The unencoded URL path segment. + * + * @param strategy + * The URI encoding strategy. + * @return The percentage encoded URL path segment. + */ + @Nonnull + public static String encodePathSegment( @Nonnull final String path, @Nonnull final UriEncodingStrategy strategy ) + { + return strategy.getPathPercentEscaper().escape(path); + } + + /** + * Encodes all characters according to the default encoding strategy {@link UriEncodingStrategy#REGULAR}. + * + * @param input + * + * The query string of the request + * @return The encoded query + */ + @Nonnull + public static String encodeQuery( @Nonnull final String input ) + { + return encodeQuery(input, UriEncodingStrategy.REGULAR); + } + + /** + * Encodes all characters according to the provided {@link UriEncodingStrategy}. + * + * @param input + * The query string of the request + * @param strategy + * The URI encoding strategy. + * @return The encoded query + */ + @Nonnull + public static String encodeQuery( @Nonnull final String input, @Nonnull final UriEncodingStrategy strategy ) + { + return strategy.getQueryPercentEscaper().escape(input); + } + + /** + * Get the delta-token from a URL. + * + * @param url + * The url or {@code null}. + * @return Either {@code Option.some(String)} with the delta-token or {@code Option.empty()}. + */ + @Nonnull + public static Option extractDeltaToken( @Nullable final String url ) + { + return Option.of(url).map(PATTERN_DELTA_TOKEN::matcher).filter(Matcher::find).map(m -> m.group(1)); + } + + /** + * Get the skip-token from a URL. + * + * @param url + * The url or {@code null}. + * @return Either {@code Option.some(String)} with the skip-token or {@code Option.empty()}. + */ + @Nonnull + public static Option extractSkipToken( @Nullable final String url ) + { + return Option.of(url).map(PATTERN_SKIP_TOKEN::matcher).filter(Matcher::find).map(m -> m.group(1)); + } + + /** + * Brings any string into the form "/A/B/C". The path will contain no double slashes, always start with a slash and + * never end with a slash. An empty path will stay empty. + * + * @param path + * The path to be sanitized. + * + * @return The sanitized path according to the rules above. + */ + @Nonnull + private static String sanitizeUrlPath( @Nonnull final String path ) + { + final String pathWithPrefixingSlash = "/" + path; + final String pathWithoutDoubleSlashes = pathWithPrefixingSlash.replaceAll("//+", "/"); + return pathWithoutDoubleSlashes.replaceAll("/$", ""); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/UpdateStrategy.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/UpdateStrategy.java new file mode 100644 index 000000000..be903145f --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/UpdateStrategy.java @@ -0,0 +1,43 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import com.google.common.annotations.Beta; + +/** + * The strategy to use when updating existing entities. + */ +public enum UpdateStrategy +{ + /** + * Request to update the entity is sent with the HTTP method PUT and its payload contains all fields of the entity, + * regardless which of them have been changed + */ + REPLACE_WITH_PUT, + + /** + * Request to update the entity is sent with the HTTP method PATCH and its payload contains the changed fields only. + */ + MODIFY_WITH_PATCH, + + /** + * Request to update the entity is sent with the HTTP method PATCH and its payload contains the changed fields + * including the changes in nested non-entity type fields. + * + * The request payload contains only the changed fields. Navigation properties are not supported. + * + * @since 5.16.0 + */ + @Beta + MODIFY_WITH_PATCH_RECURSIVE_DELTA, + + /** + * Request to update the entity is sent with the HTTP method PATCH and its payload contains the changed fields + * including the changes in nested non-entity type fields. + * + * The request payload contains the full value of complex fields for changes in any nested field. Navigation + * properties are not supported. + * + * @since 5.16.0 + */ + @Beta + MODIFY_WITH_PATCH_RECURSIVE_FULL; +} diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/UriEncodingStrategy.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/UriEncodingStrategy.java new file mode 100644 index 000000000..fb78bd035 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/UriEncodingStrategy.java @@ -0,0 +1,46 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import com.google.common.escape.Escaper; +import com.google.common.escape.Escapers; +import com.google.common.net.PercentEscaper; +import com.google.common.net.UrlEscapers; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Encoding strategy. + */ +@RequiredArgsConstructor +public enum UriEncodingStrategy +{ + /** + * Do not encode. + */ + NONE( + Escapers.nullEscaper(), // path + Escapers.nullEscaper() // query + ), + + /** + * Regular request. Consider allowed characters safe. + */ + REGULAR( + UrlEscapers.urlPathSegmentEscaper(), // path + new PercentEscaper("_*-:,/'().", false) // query + ), + + /** + * Batch segment. Only keep absolute safe characters. + */ + BATCH( + new PercentEscaper("_~-.", false), // path + new PercentEscaper("_~-.", false) // query + ); + + @Getter + private final Escaper pathPercentEscaper; + + @Getter + private final Escaper queryPercentEscaper; +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/ODataResponseParsingTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/ODataResponseParsingTest.java new file mode 100644 index 000000000..5a2200253 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/ODataResponseParsingTest.java @@ -0,0 +1,213 @@ +package com.sap.cloud.sdk.datamodel.odata.client; + +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataFunctionParameters; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestFunction; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestReadByKey; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; +import com.sap.cloud.sdk.result.ElementName; + +import io.vavr.control.Try; +import lombok.Data; +import lombok.SneakyThrows; + +@WireMockTest +class ODataResponseParsingTest +{ + private static final String servicePathV2 = "/v2/"; + private static final String servicePathV4 = "/v4/"; + + private static final String entityCollection = "ticket"; + private static final String functionEndpoint = "func"; + private static final ODataEntityKey entityKey = + new ODataEntityKey(ODataProtocol.V4).addKeyProperty("Ticket", "123"); + + private static final String byKeyResponseV2 = readResourceFile("Ticket_Single_V2.json"); + private static final String byKeyResponseV4 = readResourceFile("Ticket_Single_V4.json"); + private static final String getAllResponseV2 = readResourceFile("Ticket_Collection_V2.json"); + private static final String getAllResponseV4 = readResourceFile("Ticket_Collection_V4.json"); + private static final String functionEntityResponseV2 = readResourceFile("Ticket_Function_V2.json"); + private static final String primitiveResponseV2 = "{\"d\" : {\"" + functionEndpoint + "\" : \"someString\"}}"; + private static final String primitiveResponseV4 = "{\"value\" : \"someString\"}"; + + @SneakyThrows + private static String readResourceFile( final String s ) + { + final ClassLoader cl = ODataResponseParsingTest.class.getClassLoader(); + return new String(Files.readAllBytes(Paths.get(cl.getResource("ODataResponseParsingTest/" + s).toURI()))); + } + + private HttpClient client; + + @Data + public static class Ticket + { + @ElementName( "versionIdentifier" ) + private String versionIdentifier; + @ElementName( "Ticket" ) + private String ticket; + @ElementName( "TicketName" ) + private String ticketName; + @ElementName( "TicketIsBlocked" ) + private boolean ticketIsBlocked; + } + + @BeforeEach + void setupHttpClient( @Nonnull final WireMockRuntimeInfo wm ) + { + final Destination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + client = ApacheHttpClient5Accessor.getHttpClient(destination); + } + + @Test + void testByKeyV2() + { + stubFor(get(urlPathEqualTo(servicePathV2 + entityCollection + entityKey)).willReturn(okJson(byKeyResponseV2))); + + final ODataRequestReadByKey request = + new ODataRequestReadByKey(servicePathV2, entityCollection, entityKey, "", ODataProtocol.V2); + final ODataRequestResultGeneric resultGeneric = request.execute(client); + + final Try maybeTicket = Try.of(() -> resultGeneric.as(Ticket.class)); + assertThat(maybeTicket).isNotEmpty(); + + final Ticket ticket = maybeTicket.get(); + assertThat(ticket.getTicket()).isEqualTo("1000001"); + assertThat(ticket.isTicketIsBlocked()).isFalse(); + + // The client does not handle etags yet + // assertThat(ticket.getVersionIdentifier()).isEqualTo("DUEMONT20180116144654"); + } + + @Test + void testByKeyV4() + { + stubFor(get(urlPathEqualTo(servicePathV4 + entityCollection + entityKey)).willReturn(okJson(byKeyResponseV4))); + + final ODataRequestReadByKey request = + new ODataRequestReadByKey(servicePathV4, entityCollection, entityKey, "", ODataProtocol.V4); + final ODataRequestResultGeneric resultGeneric = request.execute(client); + + final Try maybeTicket = Try.of(() -> resultGeneric.as(Ticket.class)); + assertThat(maybeTicket).isNotEmpty(); + + final Ticket ticket = maybeTicket.get(); + assertThat(ticket.getTicket()).isEqualTo("1000001"); + assertThat(ticket.isTicketIsBlocked()).isFalse(); + + // The client does not handle etags yet + // assertThat(ticket.getVersionIdentifier()).isEqualTo("DUEMONT20180116144654"); + } + + @Test + void testGetAllV2() + { + stubFor(get(urlPathEqualTo(servicePathV2 + entityCollection)).willReturn(okJson(getAllResponseV2))); + + final ODataRequestRead request = new ODataRequestRead(servicePathV2, entityCollection, "", ODataProtocol.V2); + final ODataRequestResultGeneric resultGeneric = request.execute(client); + + final Try> maybeTicket = Try.of(() -> resultGeneric.asList(Ticket.class)); + assertThat(maybeTicket).isNotEmpty(); + + final List tickets = maybeTicket.get(); + assertThat(tickets).isNotEmpty().hasSize(2); + } + + @Test + void testGetAllV4() + { + stubFor(get(urlPathEqualTo(servicePathV4 + entityCollection)).willReturn(okJson(getAllResponseV4))); + + final ODataRequestRead request = new ODataRequestRead(servicePathV4, entityCollection, "", ODataProtocol.V4); + final ODataRequestResultGeneric resultGeneric = request.execute(client); + + final Try> maybeTicket = Try.of(() -> resultGeneric.asList(Ticket.class)); + assertThat(maybeTicket).isNotEmpty(); + + final List tickets = maybeTicket.get(); + assertThat(tickets).isNotEmpty().hasSize(2); + } + + @Test + void testPrimitiveV2() + { + stubFor(get(urlPathEqualTo(servicePathV2 + functionEndpoint)).willReturn(okJson(primitiveResponseV2))); + + final ODataRequestFunction function = + new ODataRequestFunction( + servicePathV2, + functionEndpoint, + ODataFunctionParameters.empty(ODataProtocol.V2), + ODataProtocol.V2); + final ODataRequestResultGeneric genericResult = function.execute(client); + + final Try maybeString = Try.of(() -> genericResult.as(String.class)); + assertThat(maybeString).isNotEmpty(); + + final String value = maybeString.get(); + assertThat(value).isEqualTo("someString"); + } + + @Test + void testFunctionEntityResponseV2() + { + stubFor(get(urlPathEqualTo(servicePathV2 + functionEndpoint)).willReturn(okJson(functionEntityResponseV2))); + + final ODataRequestFunction function = + new ODataRequestFunction( + servicePathV2, + functionEndpoint, + ODataFunctionParameters.empty(ODataProtocol.V2), + ODataProtocol.V2); + final ODataRequestResultGeneric genericResult = function.execute(client); + + final Try maybePartner = + Try.of(() -> genericResult.as(Ticket.class, e -> e.getAsJsonObject().get("Partner"))); + assertThat(maybePartner).isNotEmpty(); + + assertThat(maybePartner.get().getTicketName()).isEqualTo("TESTSUPPLIER01"); + } + + @Test + void testPrimitiveV4() + { + stubFor(get(urlPathEqualTo(servicePathV4 + functionEndpoint + "()")).willReturn(okJson(primitiveResponseV4))); + + final ODataRequestFunction function = + new ODataRequestFunction( + servicePathV4, + functionEndpoint, + ODataFunctionParameters.empty(ODataProtocol.V4), + ODataProtocol.V4); + final ODataRequestResultGeneric genericResult = function.execute(client); + + final Try maybeString = Try.of(() -> genericResult.as(String.class)); + assertThat(maybeString).isNotEmpty(); + + final String value = maybeString.get(); + assertThat(value).isEqualTo("someString"); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataResponseErrorParsingTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataResponseErrorParsingTest.java new file mode 100644 index 000000000..4a05a4e9c --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/exception/ODataResponseErrorParsingTest.java @@ -0,0 +1,298 @@ +package com.sap.cloud.sdk.datamodel.odata.client.exception; + +import static com.github.tomakehurst.wiremock.client.WireMock.badRequest; +import static com.github.tomakehurst.wiremock.client.WireMock.forbidden; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.notFound; +import static com.github.tomakehurst.wiremock.client.WireMock.serverError; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.unauthorized; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.apache.hc.core5.http.HttpStatus.SC_BAD_REQUEST; +import static org.apache.hc.core5.http.HttpStatus.SC_FORBIDDEN; +import static org.apache.hc.core5.http.HttpStatus.SC_INTERNAL_SERVER_ERROR; +import static org.apache.hc.core5.http.HttpStatus.SC_NOT_FOUND; +import static org.apache.hc.core5.http.HttpStatus.SC_UNAUTHORIZED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpUriRequest; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.google.common.collect.ImmutableMap; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead; + +import lombok.SneakyThrows; + +@WireMockTest +class ODataResponseErrorParsingTest +{ + private static final String ODATA_SERVICE_PATH = "/service/"; + private static final String ODATA_ENTITY_COLLECTION = "Entity"; + + private HttpClient httpClient; + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + httpClient = + ApacheHttpClient5Accessor + .getHttpClient((Destination) DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build()); + } + + @Test + void testHttpErrorCodes() + { + stubFor(get(urlPathEqualTo(ODATA_SERVICE_PATH + "forbidden")).willReturn(forbidden())); + stubFor(get(urlPathEqualTo(ODATA_SERVICE_PATH + "badRequest")).willReturn(badRequest())); + stubFor(get(urlPathEqualTo(ODATA_SERVICE_PATH + "unauthorized")).willReturn(unauthorized())); + stubFor(get(urlPathEqualTo(ODATA_SERVICE_PATH + "notFound")).willReturn(notFound())); + stubFor(get(urlPathEqualTo(ODATA_SERVICE_PATH + "error")).willReturn(serverError())); + + final ImmutableMap expectedRequestsAndErrors = + ImmutableMap + . builder() + .put(new ODataRequestRead(ODATA_SERVICE_PATH, "forbidden", "", ODataProtocol.V4), SC_FORBIDDEN) + .put(new ODataRequestRead(ODATA_SERVICE_PATH, "forbidden", "", ODataProtocol.V2), SC_FORBIDDEN) + .put(new ODataRequestRead(ODATA_SERVICE_PATH, "badRequest", "", ODataProtocol.V2), SC_BAD_REQUEST) + .put(new ODataRequestRead(ODATA_SERVICE_PATH, "badRequest", "", ODataProtocol.V4), SC_BAD_REQUEST) + .put(new ODataRequestRead(ODATA_SERVICE_PATH, "unauthorized", "", ODataProtocol.V2), SC_UNAUTHORIZED) + .put(new ODataRequestRead(ODATA_SERVICE_PATH, "unauthorized", "", ODataProtocol.V4), SC_UNAUTHORIZED) + .put(new ODataRequestRead(ODATA_SERVICE_PATH, "notFound", "", ODataProtocol.V2), SC_NOT_FOUND) + .put(new ODataRequestRead(ODATA_SERVICE_PATH, "notFound", "", ODataProtocol.V4), SC_NOT_FOUND) + .put(new ODataRequestRead(ODATA_SERVICE_PATH, "error", "", ODataProtocol.V2), SC_INTERNAL_SERVER_ERROR) + .put(new ODataRequestRead(ODATA_SERVICE_PATH, "error", "", ODataProtocol.V4), SC_INTERNAL_SERVER_ERROR) + .build(); + + expectedRequestsAndErrors + .forEach( + ( request, errorCode ) -> assertThatExceptionOfType(ODataResponseException.class) + .isThrownBy(() -> request.execute(httpClient)) + .satisfies(e -> { + assertThat(e.getSuppressed()).isEmpty(); + assertThat(e.getHttpCode()).isEqualTo(errorCode); + assertThat(e.getHttpBody()).containsExactly(""); + })); + } + + @SneakyThrows + @Test + void testWithoutHttpEntity() + { + final HttpClient mockedClient = mock(HttpClient.class); + doReturn(new BasicClassicHttpResponse(500, "oh")) + .when(mockedClient) + .executeOpen(isNull(), any(HttpUriRequest.class), isNull()); + + final ODataRequestRead request = new ODataRequestRead(ODATA_SERVICE_PATH, "", "", ODataProtocol.V4); + assertThatExceptionOfType(ODataResponseException.class) + .isThrownBy(() -> request.execute(mockedClient)) + .satisfies(e -> { + assertThat(e.getHttpCode()).isEqualTo(500); + assertThat(e.getHttpBody()).isEmpty(); + }); + } + + @Test + void testParsingODataV2Error() + { + final String json = + """ + { + "error":{ + "code":"005056A509B11EE1B9A8FEC11C23378E", + "message":{ + "lang":"en", + "value":"System query options '$orderby,$skip,$top,$skiptoken,$inlinecount' are not allowed in the requested URI" + }, + "innererror":{ + "transactionid":"25669476CF9401E0E005F2FA0752F574", + "timestamp":"20200813143348.4420100", + "Error_Resolution":{ + "SAP_Transaction":"For backend administrators: use ADT feed reader \\"SAP Gateway Error Log\\" or run transaction /IWFND/ERROR_LOG on SAP Gateway hub system and search for entries with the timestamp above for more details", + "SAP_Note":"See SAP Note 1797736 for error analysis (https://service.sap.com/sap/support/notes/1797736)" + }, + "errordetails": [{ + "code": "UF1", + "message": "$search query option not supported", + "target": "t1", + "additionalTargets": ["t2","t3"], + "severity": "error" + }] + } + } + } + """; + + final ODataRequestRead request = + new ODataRequestRead(ODATA_SERVICE_PATH, ODATA_ENTITY_COLLECTION, "", ODataProtocol.V2); + + stubFor( + get(urlPathEqualTo(ODATA_SERVICE_PATH + ODATA_ENTITY_COLLECTION)) + .willReturn(badRequest().withHeader("Content-Type", "application/json").withBody(json))); + + assertThatExceptionOfType(ODataServiceErrorException.class) + .isThrownBy(() -> request.execute(httpClient)) + .satisfies(e -> { + assertThat(e).hasNoSuppressedExceptions(); + assertThat(e.getOdataError()).satisfies(error -> { + assertThat(error.getODataCode()).isEqualTo("005056A509B11EE1B9A8FEC11C23378E"); + assertThat(error.getODataMessage()).startsWith("System query options"); + assertThat(error.getDetails()).isNotNull(); + assertThat(error.getDetails()) + .containsOnly(new ODataServiceError("UF1", "$search query option not supported", "t1")); + assertThat(error.getInnerError()).isNotNull(); + assertThat(error.getInnerError()).isNotEmpty(); + assertThat(error.getInnerError()).containsKeys("transactionid", "timestamp", "Error_Resolution"); + assertThat(error.getInnerError().get("Error_Resolution")).isInstanceOf(Map.class); + }); + }); + } + + @Test + void testParsingODataV4Error() + { + final String json = """ + { + "error": { + "code": "err123", + "message": "Unsupported functionality", + "target": "query", + "details": [ + { + "code": "forty-two", + "target": "$search", + "message": "$search query option not supported" + } + ], + "innererror": { + "foo": 123, + "bar": "ok" + } + } + } + """; + + final ODataRequestRead request = + new ODataRequestRead(ODATA_SERVICE_PATH, ODATA_ENTITY_COLLECTION, "", ODataProtocol.V4); + + stubFor( + get(urlPathEqualTo(ODATA_SERVICE_PATH + ODATA_ENTITY_COLLECTION)) + .willReturn(badRequest().withHeader("Content-Type", "application/json").withBody(json))); + + assertThatExceptionOfType(ODataServiceErrorException.class) + .isThrownBy(() -> request.execute(httpClient)) + .matches(e -> "err123".equals(e.getOdataError().getODataCode())) + .matches(e -> "Unsupported functionality".equals(e.getOdataError().getODataMessage())) + .satisfies(e -> { + final List details = e.getOdataError().getDetails(); + assertThat(details) + .containsExactly( + new ODataServiceError( + "forty-two", + "$search query option not supported", + "$search", + Collections.emptyList(), + Collections.emptyMap())); + }) + .satisfies(e -> { + final Map innerError = e.getOdataError().getInnerError(); + assertThat(innerError).containsEntry("foo", 123.0).containsEntry("bar", "ok"); + }); + } + + @Test + void testParsingBrokenODataErrorV2() + { + final String json = "{\"error\": {\"a\"}}"; + final ODataRequestRead request = + new ODataRequestRead(ODATA_SERVICE_PATH, ODATA_ENTITY_COLLECTION, "", ODataProtocol.V2); + + stubFor( + get(urlPathEqualTo(ODATA_SERVICE_PATH + ODATA_ENTITY_COLLECTION)) + .willReturn(badRequest().withHeader("Content-Type", "application/json").withBody(json))); + + assertThatExceptionOfType(ODataResponseException.class) + .isThrownBy(() -> request.execute(httpClient)) + .satisfies(e -> { + assertThat(e.getSuppressed()).isEmpty(); + assertThat(e.getHttpCode()).isEqualTo(400); + assertThat(e.getHttpBody()).containsExactly(json); + }); + } + + @Test + void testParsingBrokenODataErrorV4() + { + final String json = "{\"error\": {\"a\"}}"; + final ODataRequestRead request = + new ODataRequestRead(ODATA_SERVICE_PATH, ODATA_ENTITY_COLLECTION, "", ODataProtocol.V4); + + stubFor( + get(urlPathEqualTo(ODATA_SERVICE_PATH + ODATA_ENTITY_COLLECTION)) + .willReturn(badRequest().withHeader("Content-Type", "application/json").withBody(json))); + + assertThatExceptionOfType(ODataResponseException.class) + .isThrownBy(() -> request.execute(httpClient)) + .satisfies(e -> { + assertThat(e.getSuppressed()).isEmpty(); + assertThat(e.getHttpCode()).isEqualTo(400); + assertThat(e.getHttpBody()).containsExactly(json); + }); + } + + @Test + void testEmptyODataErrorV2() + { + final ODataRequestRead request = + new ODataRequestRead(ODATA_SERVICE_PATH, ODATA_ENTITY_COLLECTION, "", ODataProtocol.V2); + + stubFor( + get(urlPathEqualTo(ODATA_SERVICE_PATH + ODATA_ENTITY_COLLECTION)).willReturn(badRequest().withBody("foo"))); + + assertThatExceptionOfType(ODataResponseException.class) + .isThrownBy(() -> request.execute(httpClient)) + .satisfies(e -> { + assertThat(e.getSuppressed()).isEmpty(); + assertThat(e.getHttpCode()).isEqualTo(400); + assertThat(e.getHttpBody()).containsExactly("foo"); + }); + } + + @Test + void testEmptyODataErrorV4() + { + final ODataRequestRead request = + new ODataRequestRead(ODATA_SERVICE_PATH, ODATA_ENTITY_COLLECTION, "", ODataProtocol.V4); + + stubFor( + get(urlPathEqualTo(ODATA_SERVICE_PATH + ODATA_ENTITY_COLLECTION)).willReturn(badRequest().withBody("foo"))); + + assertThatExceptionOfType(ODataResponseException.class) + .isThrownBy(() -> request.execute(httpClient)) + .satisfies(e -> { + assertThat(e.getSuppressed()).isEmpty(); + assertThat(e.getHttpCode()).isEqualTo(400); + assertThat(e.getHttpBody()).containsExactly("foo"); + }); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ExpressionsFilterTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ExpressionsFilterTest.java new file mode 100644 index 000000000..fd2a1066d --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ExpressionsFilterTest.java @@ -0,0 +1,204 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import static com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionLogical.not; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead; + +class ExpressionsFilterTest +{ + private static final FieldUntyped field = FieldReference.of("Field"); + + @Test + void testUntypedExpressions() + { + final FieldUntyped field1 = FieldReference.of("FirstName"); + final FieldUntyped field2 = FieldReference.of("Age"); + + final String expression = field1.equalTo("Foobar").and(field2.greaterThan(42)).getExpression(ODataProtocol.V4); + assertThat(expression).isEqualTo("((FirstName eq 'Foobar') and (Age gt 42))"); + + final ODataRequestRead read = + new ODataRequestRead("/service/path", "EntityName", "$filter=" + expression, ODataProtocol.V4); + assertThat(read.getRequestQuery()).isEqualTo("$filter=((FirstName eq 'Foobar') and (Age gt 42))"); + } + + @Test + void testTypedExpressions() + { + final FieldUntyped field1 = FieldReference.of("FirstName"); + final FieldUntyped field2 = FieldReference.of("Age"); + final FieldUntyped field3 = FieldReference.of("IsRetired"); + + assertThat(field1.asBoolean()).isInstanceOf(ValueBoolean.class); + assertThat(field1.asBinary()).isInstanceOf(ValueBinary.class); + assertThat(field1.asString()).isInstanceOf(ValueString.class); + assertThat(field1.asNumber()).isInstanceOf(ValueNumeric.class); + assertThat(field1.asCollection()).isInstanceOf(ValueCollection.class); + assertThat(field1.asDate()).isInstanceOf(ValueDate.class); + assertThat(field1.asDateTimeOffset()).isInstanceOf(ValueDateTimeOffset.class); + assertThat(field1.asDuration()).isInstanceOf(ValueDuration.class); + assertThat(field1.asTimeOfDay()).isInstanceOf(ValueTimeOfDay.class); + + final ValueBoolean condition1 = field1.asString().substring(1, 5).equalTo("ooba"); + final ValueBoolean condition2 = field2.asNumber().modulo(10).equalTo(0); + final ValueBoolean condition3 = field3.asBoolean().or(false); + + final String expression1 = condition1.and(condition2).getExpression(ODataProtocol.V4); + assertThat(expression1).isEqualTo("((substring(FirstName,1,5) eq 'ooba') and ((Age mod 10) eq 0))"); + + final String expression2 = condition1.or(condition3).getExpression(ODataProtocol.V4); + assertThat(expression2).isEqualTo("((substring(FirstName,1,5) eq 'ooba') or (IsRetired or false))"); + } + + @Test + void testNumberExpressions() + { + assertThat(field.asNumber().divide(3).getExpression(ODataProtocol.V4)).isEqualTo("(Field divby 3)"); + assertThat(field.asNumber().divide(field.asNumber()).getExpression(ODataProtocol.V4)) + .isEqualTo("(Field divby Field)"); + + assertThat(field.asNumber().multiply(3).getExpression(ODataProtocol.V4)).isEqualTo("(Field mul 3)"); + assertThat(field.asNumber().multiply(field.asNumber()).getExpression(ODataProtocol.V4)) + .isEqualTo("(Field mul Field)"); + + assertThat(field.asNumber().add(3).getExpression(ODataProtocol.V4)).isEqualTo("(Field add 3)"); + assertThat(field.asNumber().add(field.asNumber()).getExpression(ODataProtocol.V4)) + .isEqualTo("(Field add Field)"); + + assertThat(field.asNumber().subtract(3).getExpression(ODataProtocol.V4)).isEqualTo("(Field sub 3)"); + assertThat(field.asNumber().subtract(field.asNumber()).getExpression(ODataProtocol.V4)) + .isEqualTo("(Field sub Field)"); + + assertThat(field.asNumber().modulo(3).getExpression(ODataProtocol.V4)).isEqualTo("(Field mod 3)"); + assertThat(field.asNumber().modulo(field.asNumber()).getExpression(ODataProtocol.V4)) + .isEqualTo("(Field mod Field)"); + } + + @Test + void testBinaryExpressions() + { + assertThat(field.asBinary().equalTo(new byte[] { 1, 2, 3 }).getExpression(ODataProtocol.V4)) + .isEqualTo("(Field eq binary'AQID')"); + } + + @Test + void testBooleanExpressions() + { + assertThat(field.asBoolean().and(true).getExpression(ODataProtocol.V4)).isEqualTo("(Field and true)"); + assertThat(field.asBoolean().and(field.asBoolean()).getExpression(ODataProtocol.V4)) + .isEqualTo("(Field and Field)"); + + assertThat(field.asBoolean().or(true).getExpression(ODataProtocol.V4)).isEqualTo("(Field or true)"); + assertThat(field.asBoolean().or(field.asBoolean()).getExpression(ODataProtocol.V4)) + .isEqualTo("(Field or Field)"); + + assertThat(field.asBoolean().not().getExpression(ODataProtocol.V4)).isEqualTo("(not Field)"); + + } + + @Test + void testStringExpressions() + { + assertThat(field.asString().concat("abc").getExpression(ODataProtocol.V4)).isEqualTo("concat(Field,'abc')"); + assertThat(field.asString().concat(field.asString()).getExpression(ODataProtocol.V4)) + .isEqualTo("concat(Field,Field)"); + + assertThat(field.asString().startsWith("abc").getExpression(ODataProtocol.V4)) + .isEqualTo("startswith(Field,'abc')"); + assertThat(field.asString().startsWith(field.asString()).getExpression(ODataProtocol.V4)) + .isEqualTo("startswith(Field,Field)"); + + assertThat(field.asString().endsWith("abc").getExpression(ODataProtocol.V4)).isEqualTo("endswith(Field,'abc')"); + assertThat(field.asString().endsWith(field.asString()).getExpression(ODataProtocol.V4)) + .isEqualTo("endswith(Field,Field)"); + + assertThat(field.asString().contains("abc").getExpression(ODataProtocol.V4)).isEqualTo("contains(Field,'abc')"); + assertThat(field.asString().contains(field.asString()).getExpression(ODataProtocol.V4)) + .isEqualTo("contains(Field,Field)"); + + assertThat(field.asString().substringOf("abc").getExpression(ODataProtocol.V2)) + .isEqualTo("substringof(Field,'abc')"); + assertThat(field.asString().substringOf(field.asString()).getExpression(ODataProtocol.V2)) + .isEqualTo("substringof(Field,Field)"); + + assertThat(field.asString().toLower().getExpression(ODataProtocol.V4)).isEqualTo("tolower(Field)"); + assertThat(field.asString().toUpper().getExpression(ODataProtocol.V4)).isEqualTo("toupper(Field)"); + + assertThat(field.asString().indexOf("abc").getExpression(ODataProtocol.V4)).isEqualTo("indexof(Field,'abc')"); + assertThat(field.asString().indexOf(field.asString()).getExpression(ODataProtocol.V4)) + .isEqualTo("indexof(Field,Field)"); + + assertThat(field.asString().substring(1).getExpression(ODataProtocol.V4)).isEqualTo("substring(Field,1)"); + assertThat(field.asString().substring(1, 3).getExpression(ODataProtocol.V4)).isEqualTo("substring(Field,1,3)"); + + assertThat(field.asString().matches("abc").getExpression(ODataProtocol.V4)) + .isEqualTo("matchesPattern(Field,'abc')"); + assertThat(field.asString().length().getExpression(ODataProtocol.V4)).isEqualTo("length(Field)"); + assertThat(field.asString().trim().getExpression(ODataProtocol.V4)).isEqualTo("trim(Field)"); + + } + + @Test + void testLogicalExpressions() + { + assertThat(field.equalTo("abc").getExpression(ODataProtocol.V4)).isEqualTo("(Field eq 'abc')"); + assertThat(field.notEqualTo(123).getExpression(ODataProtocol.V4)).isEqualTo("(Field ne 123)"); + + assertThat(field.equalTo(field).getExpression(ODataProtocol.V4)).isEqualTo("(Field eq Field)"); + assertThat(field.notEqualTo(field).getExpression(ODataProtocol.V4)).isEqualTo("(Field ne Field)"); + + assertThat(field.greaterThan(field).getExpression(ODataProtocol.V4)).isEqualTo("(Field gt Field)"); + assertThat(field.greaterThanEqual(field).getExpression(ODataProtocol.V4)).isEqualTo("(Field ge Field)"); + + assertThat(field.greaterThan("abc").getExpression(ODataProtocol.V4)).isEqualTo("(Field gt 'abc')"); + assertThat(field.greaterThanEqual(123).getExpression(ODataProtocol.V4)).isEqualTo("(Field ge 123)"); + + assertThat(field.lessThan(field).getExpression(ODataProtocol.V4)).isEqualTo("(Field lt Field)"); + assertThat(field.lessThanEqual(field).getExpression(ODataProtocol.V4)).isEqualTo("(Field le Field)"); + + assertThat(field.lessThan("abc").getExpression(ODataProtocol.V4)).isEqualTo("(Field lt 'abc')"); + assertThat(field.lessThanEqual(123).getExpression(ODataProtocol.V4)).isEqualTo("(Field le 123)"); + + assertThat(field.in("abc", "foo").getExpression(ODataProtocol.V4)).isEqualTo("(Field in ('abc','foo'))"); + assertThat(field.in(Arrays.asList(1, 2, 3)).getExpression(ODataProtocol.V4)).isEqualTo("(Field in (1,2,3))"); + assertThat(field.in(field.asCollection()).getExpression(ODataProtocol.V4)).isEqualTo("(Field in Field)"); + } + + @Test + void testLogicalComplexExpressions() + { + final ValueBoolean cond1 = field.equalTo(1); + final ValueBoolean cond2 = field.equalTo(2); + final String cond1Exp = cond1.getExpression(ODataProtocol.V2); + final String cond2Exp = cond2.getExpression(ODataProtocol.V2); + + String expected; + String actual; + + expected = String.format("(not %s)", cond1Exp); + assertThat(cond1.not().getExpression(ODataProtocol.V2)).isEqualTo(expected); + assertThat(not(cond1).getExpression(ODataProtocol.V2)).isEqualTo(expected); + + expected = String.format("((not %s) and %s)", cond1Exp, cond2Exp); + actual = cond1.not().and(cond2).getExpression(ODataProtocol.V2); + assertThat(actual).isEqualTo(expected); + + expected = String.format("((not %s) or %s)", cond1Exp, cond2Exp); + actual = cond1.not().or(cond2).getExpression(ODataProtocol.V2); + assertThat(actual).isEqualTo(expected); + + expected = String.format("(not (%s or %s))", cond1Exp, cond2Exp); + actual = cond1.or(cond2).not().getExpression(ODataProtocol.V2); + assertThat(actual).isEqualTo(expected); + + expected = String.format("(not (%s or %s))", cond1Exp, cond2Exp); + actual = not(cond1.or(cond2)).getExpression(ODataProtocol.V2); + assertThat(actual).isEqualTo(expected); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ExpressionsTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ExpressionsTest.java new file mode 100644 index 000000000..607c4fb0f --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ExpressionsTest.java @@ -0,0 +1,341 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Collections; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.google.common.collect.Lists; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; + +class ExpressionsTest +{ + private static final LocalDate localDate = LocalDate.of(2001, 1, 1); + private static final LocalTime localTime = LocalTime.of(13, 37, 00); + private static final LocalTime localTimeWith3Nanos = LocalTime.of(13, 37, 00, 111); + private static final LocalTime localTimeWith9Nanos = LocalTime.of(13, 37, 00, 111111111); + private static final LocalDateTime localDateTime = LocalDateTime.of(2001, 1, 1, 13, 37, 00); + private static final LocalDateTime localDateTimeWith3Nanos = LocalDateTime.of(2001, 1, 1, 13, 37, 00, 111); + private static final LocalDateTime localDateTimeWith9Nanos = LocalDateTime.of(2001, 1, 1, 13, 37, 00, 111111111); + + private static final ValueDate date1 = ValueDate.literal(localDate); + private static final ValueDate date2 = ValueDate.literal(localDate.plusDays(1)); + private static final ValueDateTime dateTime1 = ValueDateTime.literal(localDateTime); + private static final ValueDateTime dateTimeWith3Nanos = ValueDateTime.literal(localDateTimeWith3Nanos); + private static final ValueDateTime dateTimeWith9Nanos = ValueDateTime.literal(localDateTimeWith9Nanos); + private static final ValueDuration duration1 = ValueDuration.literal(Duration.ofDays(1)); + private static final ValueDuration duration2 = ValueDuration.literal(Duration.ofDays(2)); + private static final ValueNumeric numberInt = ValueNumeric.literal(1); + private static final ValueNumeric number2 = ValueNumeric.literal(2); + private static final ValueDateTimeOffset dateTimeOffset = + ValueDateTimeOffset.literal(LocalDateTime.of(localDate, localTime).atOffset(ZoneOffset.UTC)); + private static final ValueDateTimeOffset dateTimeOffsetWith3Nanos = + ValueDateTimeOffset.literal(LocalDateTime.of(localDate, localTimeWith3Nanos).atOffset(ZoneOffset.UTC)); + private static final ValueDateTimeOffset dateTimeOffsetWith9Nanos = + ValueDateTimeOffset.literal(LocalDateTime.of(localDate, localTimeWith9Nanos).atOffset(ZoneOffset.UTC)); + private static final ValueTimeOfDay timeOfDay = ValueTimeOfDay.literal(localTime); + private static final ValueNumeric numberFloat = ValueNumeric.literal(0.9f); + // the below example covers that we transform the exponential notation to a plain value in case of OData V2 + // don't randomly change this since it doesn't work with all values, e.g. 234.5E-2 doesn't work + private static final ValueNumeric numberDecimal = ValueNumeric.literal(new BigDecimal("1.1E+2")); + private static final ValueNumeric numberDouble = ValueNumeric.literal(Double.parseDouble("1E+10d")); + + private final ValueCollection multiple1 = ValueCollection.literal(Lists.newArrayList("A", "B")); + private final ValueCollection multiple2 = ValueCollection.literal(Lists.newArrayList("C", "D")); + private final ValueBoolean boolean1 = ValueBoolean.literal(true); + private final ValueBoolean boolean2 = ValueBoolean.literal(false); + private final ValueString string1 = ValueString.literal("Aaa"); + private final ValueString string2 = ValueString.literal("Bbb"); + private final ValueCollection fieldCollection = FieldReference.of("Friends").asCollection(); + private final ValueCollection fieldCollection2 = FieldReference.of("Feet").asCollection(); + private final FieldUntyped fieldUntyped = FieldReference.of("BestFriend"); + private final FieldReference fieldComplex = () -> "OperatingSystem"; + private final FieldReference fieldPrimitive = () -> "ShowSize"; + private final ValueGuid guid = ValueGuid.literal(UUID.fromString("b3e130fe-d72c-4a5b-8dcf-463b497f985c")); + + private final ValueEnum fieldEnum = ValueEnum.literal("EnumType", "EnumValue"); + private final ValueEnum fieldEnumAnonymous = ValueEnum.literal("Debian"); + + @Test + void testLiteralsV2() + { + assertThat(numberInt.getExpression(ODataProtocol.V2)).isEqualTo("1"); + assertThat(numberFloat.getExpression(ODataProtocol.V2)).isEqualTo("0.9f"); + assertThat(numberDecimal.getExpression(ODataProtocol.V2)).isEqualTo("110M"); + assertThat(numberDouble.getExpression(ODataProtocol.V2)).isEqualTo("1.0E10d"); + + assertThat(guid.getExpression(ODataProtocol.V2)).isEqualTo("guid'b3e130fe-d72c-4a5b-8dcf-463b497f985c'"); + assertThat(timeOfDay.getExpression(ODataProtocol.V2)).isEqualTo("time'PT13H37M'"); + assertThat(dateTime1.getExpression(ODataProtocol.V2)).isEqualTo("datetime'2001-01-01T13:37:00'"); + assertThat(dateTimeWith3Nanos.getExpression(ODataProtocol.V2)) + .isEqualTo("datetime'2001-01-01T13:37:00.0000001'"); + assertThat(dateTimeWith9Nanos.getExpression(ODataProtocol.V2)) + .isEqualTo("datetime'2001-01-01T13:37:00.1111111'"); + assertThat(dateTimeOffset.getExpression(ODataProtocol.V2)).isEqualTo("datetimeoffset'2001-01-01T13:37:00Z'"); + } + + @Test + void testLiteralsV4() + { + assertThat(numberInt.getExpression(ODataProtocol.V4)).isEqualTo("1"); + assertThat(numberFloat.getExpression(ODataProtocol.V4)).isEqualTo("0.9"); + assertThat(numberDecimal.getExpression(ODataProtocol.V4)).isEqualTo("1.1E+2"); + assertThat(numberDouble.getExpression(ODataProtocol.V4)).isEqualTo("1.0E10"); + + assertThat(guid.getExpression(ODataProtocol.V4)).isEqualTo("b3e130fe-d72c-4a5b-8dcf-463b497f985c"); + assertThat(fieldEnum.getExpression(ODataProtocol.V4)).isEqualTo("EnumType'EnumValue'"); + + assertThat(duration1.getExpression(ODataProtocol.V4)).isEqualTo("duration'PT24H'"); + assertThat(timeOfDay.getExpression(ODataProtocol.V4)).isEqualTo("13:37:00"); + assertThat(date1.getExpression(ODataProtocol.V4)).isEqualTo("2001-01-01"); + assertThat(dateTimeOffset.getExpression(ODataProtocol.V4)).isEqualTo("2001-01-01T13:37:00Z"); + assertThat(dateTimeOffsetWith3Nanos.getExpression(ODataProtocol.V4)) + .isEqualTo("2001-01-01T13:37:00.000000111Z"); + assertThat(dateTimeOffsetWith9Nanos.getExpression(ODataProtocol.V4)) + .isEqualTo("2001-01-01T13:37:00.111111111Z"); + } + + @Test + void testArithmethic() + { + assertThat(numberInt.add(number2).getExpression(ODataProtocol.V4)).isEqualTo("(1 add 2)"); + assertThat(date1.add(duration1).getExpression(ODataProtocol.V4)).isEqualTo("(2001-01-01 add duration'PT24H')"); + assertThat(date1.add(Duration.ofHours(24)).getExpression(ODataProtocol.V4)) + .isEqualTo("(2001-01-01 add duration'PT24H')"); + assertThat(dateTimeOffset.add(duration1).getExpression(ODataProtocol.V4)) + .isEqualTo("(2001-01-01T13:37:00Z add duration'PT24H')"); + assertThat(dateTimeOffset.add(Duration.ofHours(24)).getExpression(ODataProtocol.V4)) + .isEqualTo("(2001-01-01T13:37:00Z add duration'PT24H')"); + assertThat(duration1.add(duration2).getExpression(ODataProtocol.V4)) + .isEqualTo("(duration'PT24H' add duration'PT48H')"); + assertThat(duration1.add(Duration.ofHours(48)).getExpression(ODataProtocol.V4)) + .isEqualTo("(duration'PT24H' add duration'PT48H')"); + assertThat(numberFloat.ceil().getExpression(ODataProtocol.V4)).isEqualTo("ceiling(0.9)"); + assertThat(numberInt.divide(number2).getExpression(ODataProtocol.V4)).isEqualTo("(1 divby 2)"); + assertThat(duration1.divide(numberInt).getExpression(ODataProtocol.V4)).isEqualTo("(duration'PT24H' div 1)"); + assertThat(duration1.divide(1).getExpression(ODataProtocol.V4)).isEqualTo("(duration'PT24H' div 1)"); + assertThat(numberFloat.floor().getExpression(ODataProtocol.V4)).isEqualTo("floor(0.9)"); + assertThat(numberInt.modulo(number2).getExpression(ODataProtocol.V4)).isEqualTo("(1 mod 2)"); + assertThat(numberInt.multiply(number2).getExpression(ODataProtocol.V4)).isEqualTo("(1 mul 2)"); + assertThat(duration1.multiply(numberInt).getExpression(ODataProtocol.V4)).isEqualTo("(duration'PT24H' mul 1)"); + assertThat(duration1.multiply(1).getExpression(ODataProtocol.V4)).isEqualTo("(duration'PT24H' mul 1)"); + assertThat(numberInt.negate().getExpression(ODataProtocol.V4)).isEqualTo("-(1)"); + assertThat(duration1.negate().getExpression(ODataProtocol.V4)).isEqualTo("-(duration'PT24H')"); + assertThat(numberFloat.round().getExpression(ODataProtocol.V4)).isEqualTo("round(0.9)"); + assertThat(numberInt.subtract(number2).getExpression(ODataProtocol.V4)).isEqualTo("(1 sub 2)"); + assertThat(date1.difference(date2).getExpression(ODataProtocol.V4)).isEqualTo("(2001-01-01 sub 2001-01-02)"); + assertThat(dateTimeOffset.subtract(duration1).getExpression(ODataProtocol.V4)) + .isEqualTo("(2001-01-01T13:37:00Z sub duration'PT24H')"); + assertThat(dateTimeOffset.subtract(Duration.ofHours(24)).getExpression(ODataProtocol.V4)) + .isEqualTo("(2001-01-01T13:37:00Z sub duration'PT24H')"); + assertThat(duration1.subtract(duration2).getExpression(ODataProtocol.V4)) + .isEqualTo("(duration'PT24H' sub duration'PT48H')"); + assertThat(duration1.subtract(Duration.ofHours(48)).getExpression(ODataProtocol.V4)) + .isEqualTo("(duration'PT24H' sub duration'PT48H')"); + assertThat(date1.subtract(duration2).getExpression(ODataProtocol.V4)) + .isEqualTo("(2001-01-01 sub duration'PT48H')"); + assertThat(date1.subtract(Duration.ofHours(48)).getExpression(ODataProtocol.V4)) + .isEqualTo("(2001-01-01 sub duration'PT48H')"); + + // not exposed directly + assertThat(FilterExpressionArithmetic.divideEuclidean(numberInt, number2).getExpression(ODataProtocol.V4)) + .isEqualTo("(1 div 2)"); + } + + @Test + void testCollection() + { + assertThat(multiple1.concat(multiple2).getExpression(ODataProtocol.V4)) + .isEqualTo("concat(['A','B'],['C','D'])"); + assertThat(multiple1.concat(Arrays.asList("C", "D")).getExpression(ODataProtocol.V4)) + .isEqualTo("concat(['A','B'],['C','D'])"); + + assertThat(multiple1.contains(multiple2).getExpression(ODataProtocol.V4)) + .isEqualTo("contains(['A','B'],['C','D'])"); + assertThat(multiple1.contains(Arrays.asList("C", "D")).getExpression(ODataProtocol.V4)) + .isEqualTo("contains(['A','B'],['C','D'])"); + + assertThat(multiple1.endsWith(multiple2).getExpression(ODataProtocol.V4)) + .isEqualTo("endswith(['A','B'],['C','D'])"); + assertThat(multiple1.endsWith(Arrays.asList("C", "D")).getExpression(ODataProtocol.V4)) + .isEqualTo("endswith(['A','B'],['C','D'])"); + + assertThat(multiple1.hasSubSequence(multiple2).getExpression(ODataProtocol.V4)) + .isEqualTo("hassubsequence(['A','B'],['C','D'])"); + assertThat(multiple1.hasSubSequence(Arrays.asList("C", "D")).getExpression(ODataProtocol.V4)) + .isEqualTo("hassubsequence(['A','B'],['C','D'])"); + + assertThat(multiple1.hasSubset(multiple2).getExpression(ODataProtocol.V4)) + .isEqualTo("hassubset(['A','B'],['C','D'])"); + assertThat(multiple1.hasSubset(Arrays.asList("C", "D")).getExpression(ODataProtocol.V4)) + .isEqualTo("hassubset(['A','B'],['C','D'])"); + + assertThat(multiple1.indexOf(multiple2).getExpression(ODataProtocol.V4)) + .isEqualTo("indexof(['A','B'],['C','D'])"); + assertThat(multiple1.indexOf(Arrays.asList("C", "D")).getExpression(ODataProtocol.V4)) + .isEqualTo("indexof(['A','B'],['C','D'])"); + + assertThat(multiple1.startsWith(multiple2).getExpression(ODataProtocol.V4)) + .isEqualTo("startswith(['A','B'],['C','D'])"); + assertThat(multiple1.startsWith(Arrays.asList("C", "D")).getExpression(ODataProtocol.V4)) + .isEqualTo("startswith(['A','B'],['C','D'])"); + + assertThat(multiple1.length().getExpression(ODataProtocol.V4)).isEqualTo("length(['A','B'])"); + + assertThat(multiple1.substring(1).getExpression(ODataProtocol.V4)).isEqualTo("substring(['A','B'],1)"); + assertThat(multiple1.substring(1, 2).getExpression(ODataProtocol.V4)).isEqualTo("substring(['A','B'],1,2)"); + + // test lambdas + final ValueBoolean.Expression c = FilterExpressionLogical.equalTo(fieldPrimitive, numberInt); + assertThat(fieldCollection.all(c).getExpression(ODataProtocol.V4)) + .isEqualTo("Friends/all(a:(a/ShowSize eq 1))"); + assertThat(fieldCollection.any(c).getExpression(ODataProtocol.V4)) + .isEqualTo("Friends/any(a:(a/ShowSize eq 1))"); + assertThat(fieldCollection.any(fieldCollection2.any(c)).getExpression(ODataProtocol.V4)) + .isEqualTo("Friends/any(a:a/Feet/any(b:(b/ShowSize eq 1)))"); + assertThat(fieldCollection.any().getExpression(ODataProtocol.V4)).isEqualTo("Friends/any()"); + } + + @Test + void testLogical() + { + assertThat(boolean1.in(boolean1, boolean2).getExpression(ODataProtocol.V4)).isEqualTo("(true in (true,false))"); + assertThat(numberInt.in(13.37, 42, "foo").getExpression(ODataProtocol.V4)).isEqualTo("(1 in (13.37,42,'foo'))"); + assertThat(numberInt.in(Arrays.asList(1, 2, 3)).getExpression(ODataProtocol.V4)).isEqualTo("(1 in (1,2,3))"); + assertThat(numberInt.in(fieldCollection).getExpression(ODataProtocol.V4)).isEqualTo("(1 in Friends)"); + assertThat(fieldUntyped.in(fieldCollection).getExpression(ODataProtocol.V4)) + .isEqualTo("(BestFriend in Friends)"); + assertThat(boolean1.and(boolean2).getExpression(ODataProtocol.V4)).isEqualTo("(true and false)"); + assertThat(multiple1.equalTo(multiple2).getExpression(ODataProtocol.V4)).isEqualTo("(['A','B'] eq ['C','D'])"); + assertThat(guid.equalTo(guid).getExpression(ODataProtocol.V4)) + .isEqualTo("(b3e130fe-d72c-4a5b-8dcf-463b497f985c eq b3e130fe-d72c-4a5b-8dcf-463b497f985c)"); + assertThat(numberInt.greaterThan(number2).getExpression(ODataProtocol.V4)).isEqualTo("(1 gt 2)"); + assertThat(numberInt.lessThan(number2).getExpression(ODataProtocol.V4)).isEqualTo("(1 lt 2)"); + assertThat(numberInt.greaterThanEqual(number2).getExpression(ODataProtocol.V4)).isEqualTo("(1 ge 2)"); + assertThat(numberInt.lessThanEqual(number2).getExpression(ODataProtocol.V4)).isEqualTo("(1 le 2)"); + assertThat(boolean1.not().getExpression(ODataProtocol.V4)).isEqualTo("(not true)"); + assertThat(multiple1.notEqualTo(numberFloat).getExpression(ODataProtocol.V4)).isEqualTo("(['A','B'] ne 0.9)"); + assertThat(boolean1.or(boolean2).getExpression(ODataProtocol.V4)).isEqualTo("(true or false)"); + assertThat(string1.equalToNull().getExpression(ODataProtocol.V4)).isEqualTo("('Aaa' eq null)"); + assertThat(string1.equalToNull().not().getExpression(ODataProtocol.V4)).isEqualTo("(not ('Aaa' eq null))"); + assertThat(string1.notEqualToNull().getExpression(ODataProtocol.V4)).isEqualTo("('Aaa' ne null)"); + + // not exposed directly + assertThat(FilterExpressionLogical.has(fieldComplex, fieldEnumAnonymous).getExpression(ODataProtocol.V4)) + .isEqualTo("(OperatingSystem has 'Debian')"); + } + + @Test + void testString() + { + assertThat(string1.concat(string2).getExpression(ODataProtocol.V4)).isEqualTo("concat('Aaa','Bbb')"); + assertThat(string1.contains(string2).getExpression(ODataProtocol.V4)).isEqualTo("contains('Aaa','Bbb')"); + assertThat(string1.substringOf(string2).getExpression(ODataProtocol.V2)).isEqualTo("substringof('Aaa','Bbb')"); + assertThat(string1.substringOf(string2).getExpression(ODataProtocol.V2)).isEqualTo("substringof('Aaa','Bbb')"); + assertThat(string1.endsWith(string2).getExpression(ODataProtocol.V4)).isEqualTo("endswith('Aaa','Bbb')"); + assertThat(string1.indexOf(string2).getExpression(ODataProtocol.V4)).isEqualTo("indexof('Aaa','Bbb')"); + assertThat(string1.startsWith(string2).getExpression(ODataProtocol.V4)).isEqualTo("startswith('Aaa','Bbb')"); + assertThat(string1.length().getExpression(ODataProtocol.V4)).isEqualTo("length('Aaa')"); + assertThat(string1.matches(string2).getExpression(ODataProtocol.V4)).isEqualTo("matchesPattern('Aaa','Bbb')"); + assertThat(string1.substring(1, 2).getExpression(ODataProtocol.V4)).isEqualTo("substring('Aaa',1,2)"); + assertThat(string1.substring(1).getExpression(ODataProtocol.V4)).isEqualTo("substring('Aaa',1)"); + assertThat(string1.toLower().getExpression(ODataProtocol.V4)).isEqualTo("tolower('Aaa')"); + assertThat(string1.toUpper().getExpression(ODataProtocol.V4)).isEqualTo("toupper('Aaa')"); + assertThat(string1.trim().getExpression(ODataProtocol.V4)).isEqualTo("trim('Aaa')"); + } + + @Test + void testTemporal() + { + assertThat(dateTimeOffset.date().getExpression(ODataProtocol.V4)).isEqualTo("date(2001-01-01T13:37:00Z)"); + assertThat(date1.dateDay().getExpression(ODataProtocol.V4)).isEqualTo("day(2001-01-01)"); + assertThat(dateTimeOffset.dateDay().getExpression(ODataProtocol.V4)).isEqualTo("day(2001-01-01T13:37:00Z)"); + assertThat(dateTimeOffset.timeFractionalSeconds().getExpression(ODataProtocol.V4)) + .isEqualTo("fractionalseconds(2001-01-01T13:37:00Z)"); + assertThat(timeOfDay.timeFractionalSeconds().getExpression(ODataProtocol.V4)) + .isEqualTo("fractionalseconds(13:37:00)"); + assertThat(dateTimeOffset.timeHour().getExpression(ODataProtocol.V4)).isEqualTo("hour(2001-01-01T13:37:00Z)"); + assertThat(dateTimeOffset.timeMinute().getExpression(ODataProtocol.V4)) + .isEqualTo("minute(2001-01-01T13:37:00Z)"); + assertThat(timeOfDay.timeMinute().getExpression(ODataProtocol.V4)).isEqualTo("minute(13:37:00)"); + assertThat(date1.dateMonth().getExpression(ODataProtocol.V4)).isEqualTo("month(2001-01-01)"); + assertThat(dateTimeOffset.dateMonth().getExpression(ODataProtocol.V4)).isEqualTo("month(2001-01-01T13:37:00Z)"); + assertThat(timeOfDay.timeHour().getExpression(ODataProtocol.V4)).isEqualTo("hour(13:37:00)"); + assertThat(dateTimeOffset.timeSecond().getExpression(ODataProtocol.V4)) + .isEqualTo("second(2001-01-01T13:37:00Z)"); + assertThat(timeOfDay.timeSecond().getExpression(ODataProtocol.V4)).isEqualTo("second(13:37:00)"); + assertThat(dateTimeOffset.time().getExpression(ODataProtocol.V4)).isEqualTo("time(2001-01-01T13:37:00Z)"); + assertThat(dateTimeOffset.offsetMinutes().getExpression(ODataProtocol.V4)) + .isEqualTo("totaloffsetminutes(2001-01-01T13:37:00Z)"); + assertThat(duration1.offsetSeconds().getExpression(ODataProtocol.V4)) + .isEqualTo("totaloffsetseconds(duration'PT24H')"); + assertThat(date1.dateYear().getExpression(ODataProtocol.V4)).isEqualTo("year(2001-01-01)"); + assertThat(dateTimeOffset.dateYear().getExpression(ODataProtocol.V4)).isEqualTo("year(2001-01-01T13:37:00Z)"); + + // custom time zone + final LocalDateTime localDateTime = LocalDateTime.of(localDate, localTime); + final OffsetDateTime offsetDateTime = OffsetDateTime.of(localDateTime, ZoneOffset.ofHoursMinutes(1, 0)); + final ValueDateTimeOffset dateTimeOffset2 = ValueDateTimeOffset.literal(offsetDateTime); + assertThat(dateTimeOffset2.getExpression(ODataProtocol.V4)).isEqualTo("2001-01-01T13:37:00+01:00"); + } + + @Test + void testGlobalFunctions() + { + assertThat(FilterExpressionTemporal.maxDateTime().getExpression(ODataProtocol.V4)).isEqualTo("maxdatetime()"); + assertThat(FilterExpressionTemporal.minDateTime().getExpression(ODataProtocol.V4)).isEqualTo("mindatetime()"); + assertThat(FilterExpressionTemporal.now().getExpression(ODataProtocol.V4)).isEqualTo("now()"); + } + + @Test + void testPrimitives() + { + assertThat(Expressions.createOperand("A")).isInstanceOf(ValueString.class); + assertThat(Expressions.createOperand(true)).isInstanceOf(ValueBoolean.class); + assertThat(Expressions.createOperand(UUID.randomUUID())).isInstanceOf(ValueGuid.class); + assertThat(Expressions.createOperand(42)).isInstanceOf(ValueNumeric.class); + assertThat(Expressions.createOperand(42.0)).isInstanceOf(ValueNumeric.class); + assertThat(Expressions.createOperand(Duration.ofMinutes(1))).isInstanceOf(ValueDuration.class); + assertThat(Expressions.createOperand(OffsetDateTime.now())).isInstanceOf(ValueDateTimeOffset.class); + assertThat(Expressions.createOperand(LocalDateTime.now())).isInstanceOf(ValueDateTime.class); + assertThat(Expressions.createOperand(LocalDate.now())).isInstanceOf(ValueDate.class); + assertThat(Expressions.createOperand(LocalTime.now())).isInstanceOf(ValueTimeOfDay.class); + assertThat(Expressions.createOperand(null)).isInstanceOf(Expressions.OperandSingle.class); + assertThat(Expressions.createOperand(numberFloat)).isEqualTo(numberFloat); + assertThatCode(() -> Expressions.createOperand(this)).isInstanceOf(IllegalArgumentException.class); + + assertThat(FieldReference.ofPath("Friends", "NestedFriends").getFieldName()) + .isEqualTo(FieldReference.ofPath("Friends/NestedFriends").getFieldName()); + } + + @Test + void testOperands() + { + final FilterExpression f2 = FilterExpressionTemporal.now(); + assertThat(f2.getOperands()).isEqualTo(Collections.emptyList()); + assertThat(f2.getOperator()).isEqualTo("now"); + + final FilterExpression f3 = FilterExpressionTemporal.date(dateTimeOffset); + assertThat(f3.getOperands()).isEqualTo(Collections.singletonList(dateTimeOffset)); + + final FilterExpression f4 = FilterExpressionString.substring(string1, numberInt); + assertThat(f4.getOperands()).isEqualTo(Lists.newArrayList(string1, numberInt)); + + final FilterExpression f5 = FilterExpressionCollection.substring(fieldCollection, numberInt, number2); + assertThat(f5.getOperands()).isEqualTo(Lists.newArrayList(fieldCollection, numberInt, number2)); + + final ValueBoolean.Expression f6 = FilterExpressionLogical.and(boolean1, boolean2); + assertThat(f6.getOperands()).isEqualTo(Lists.newArrayList(boolean1, boolean2)); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionsWithNullTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionsWithNullTest.java new file mode 100644 index 000000000..0397e04bc --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/expression/FilterExpressionsWithNullTest.java @@ -0,0 +1,149 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import com.google.common.collect.Lists; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead; + +class FilterExpressionsWithNullTest +{ + private static final FieldUntyped field1 = FieldReference.of("FirstName"); + private static final FieldUntyped field2 = FieldReference.of("Age"); + + @Test + void testNullInComplexExpression() + { + final String expression = + field1 + .equalTo(ValueString.NULL) + .and(field2.greaterThan(ValueNumeric.NULL)) + .or(ValueBoolean.NULL) + .getExpression(ODataProtocol.V4); + assertThat(expression).isEqualTo("(((FirstName eq null) and (Age gt null)) or null)"); + + final ODataRequestRead read = + new ODataRequestRead("/service/path", "EntityName", "$filter=" + expression, ODataProtocol.V4); + assertThat(read.getRequestQuery()).isEqualTo("$filter=(((FirstName eq null) and (Age gt null)) or null)"); + } + + @Test + void testNullInBooleanExpression() + { + final String andExpression = + field1.equalTo(ValueString.NULL).and(ValueBoolean.NULL).getExpression(ODataProtocol.V4); + assertThat(andExpression).isEqualTo("((FirstName eq null) and null)"); + + final String orExpression = + field1.asString().contains(ValueString.NULL).or(ValueBoolean.NULL).getExpression(ODataProtocol.V4); + assertThat(orExpression).isEqualTo("(contains(FirstName,null) or null)"); + } + + @Test + void testNullInLogicalExpression() + { + assertThat(field1.equalTo(ValueString.NULL).getExpression(ODataProtocol.V4)).isEqualTo("(FirstName eq null)"); + assertThat(ValueNumeric.NULL.notEqualTo(123).getExpression(ODataProtocol.V4)).isEqualTo("(null ne 123)"); + + assertThat(ValueNumeric.NULL.greaterThan(23).getExpression(ODataProtocol.V4)).isEqualTo("(null gt 23)"); + assertThat(ValueString.NULL.greaterThanEqual("abc").getExpression(ODataProtocol.V4)) + .isEqualTo("(null ge 'abc')"); + + assertThat(ValueNumeric.NULL.lessThan(ValueNumeric.NULL).getExpression(ODataProtocol.V4)) + .isEqualTo("(null lt null)"); + assertThat(ValueString.NULL.lessThanEqual("abc").getExpression(ODataProtocol.V4)).isEqualTo("(null le 'abc')"); + } + + @Test + void testNullInStringExpressions() + { + final String expressionWithContains = + field1.asString().contains(ValueString.NULL).getExpression(ODataProtocol.V4); + assertThat(expressionWithContains).isEqualTo("contains(FirstName,null)"); + + final String expressionWithSubStringOf = + field1.asString().substringOf(ValueString.NULL).getExpression(ODataProtocol.V2); + assertThat(expressionWithSubStringOf).isEqualTo("substringof(FirstName,null)"); + + final String expressionWithSubStringOf1 = + field1.asString().substringOf(ValueString.NULL).getExpression(ODataProtocol.V4); + assertThat(expressionWithSubStringOf1).isEqualTo("substringof(FirstName,null)"); + + final String expressionWithConcat = field1.asString().concat(ValueString.NULL).getExpression(ODataProtocol.V4); + assertThat(expressionWithConcat).isEqualTo("concat(FirstName,null)"); + + final String expressionWithStartsWith = + field1.asString().startsWith(ValueString.NULL).getExpression(ODataProtocol.V4); + assertThat(expressionWithStartsWith).isEqualTo("startswith(FirstName,null)"); + + final String expressionWithEndsWith = + field1.asString().endsWith(ValueString.NULL).getExpression(ODataProtocol.V4); + assertThat(expressionWithEndsWith).isEqualTo("endswith(FirstName,null)"); + + final String expressionWithToLower = ValueString.NULL.toLower().getExpression(ODataProtocol.V4); + assertThat(expressionWithToLower).isEqualTo("tolower(null)"); + + final String expressionWithToUpper = ValueString.NULL.toUpper().getExpression(ODataProtocol.V4); + assertThat(expressionWithToUpper).isEqualTo("toupper(null)"); + + final String expressionWithIndexOf = + field1.asString().indexOf(ValueString.NULL).getExpression(ODataProtocol.V4); + assertThat(expressionWithIndexOf).isEqualTo("indexof(FirstName,null)"); + + final String expressionWithSubstring = ValueString.NULL.substring(1).getExpression(ODataProtocol.V4); + assertThat(expressionWithSubstring).isEqualTo("substring(null,1)"); + + final String expressionWithMatches = + field1.asString().matches(ValueString.NULL).getExpression(ODataProtocol.V4); + assertThat(expressionWithMatches).isEqualTo("matchesPattern(FirstName,null)"); + } + + @Test + void testNullInCollectionExpression() + { + final ValueCollection collection = ValueCollection.literal(Lists.newArrayList("A", "B")); + + assertThat(ValueCollection.NULL.concat(ValueCollection.NULL).getExpression(ODataProtocol.V4)) + .isEqualTo("concat(null,null)"); + assertThat(collection.contains(ValueCollection.NULL).getExpression(ODataProtocol.V4)) + .isEqualTo("contains(['A','B'],null)"); + assertThat(collection.endsWith(ValueCollection.NULL).getExpression(ODataProtocol.V4)) + .isEqualTo("endswith(['A','B'],null)"); + assertThat(collection.hasSubSequence(ValueCollection.NULL).getExpression(ODataProtocol.V4)) + .isEqualTo("hassubsequence(['A','B'],null)"); + assertThat(collection.hasSubset(ValueCollection.NULL).getExpression(ODataProtocol.V4)) + .isEqualTo("hassubset(['A','B'],null)"); + assertThat(collection.indexOf(ValueCollection.NULL).getExpression(ODataProtocol.V4)) + .isEqualTo("indexof(['A','B'],null)"); + assertThat(collection.startsWith(ValueCollection.NULL).getExpression(ODataProtocol.V4)) + .isEqualTo("startswith(['A','B'],null)"); + assertThat(ValueCollection.NULL.substring(1).getExpression(ODataProtocol.V4)).isEqualTo("substring(null,1)"); + } + + @Test + void testNullInArithmeticExpression() + { + assertThat(ValueNumeric.NULL.divide(3).getExpression(ODataProtocol.V4)).isEqualTo("(null divby 3)"); + assertThat(ValueNumeric.NULL.divide(ValueNumeric.NULL).getExpression(ODataProtocol.V4)) + .isEqualTo("(null divby null)"); + + assertThat(ValueNumeric.NULL.multiply(3).getExpression(ODataProtocol.V4)).isEqualTo("(null mul 3)"); + assertThat(ValueNumeric.NULL.multiply(ValueNumeric.NULL).getExpression(ODataProtocol.V4)) + .isEqualTo("(null mul null)"); + + assertThat(field2.asNumber().add(ValueNumeric.NULL).getExpression(ODataProtocol.V4)) + .isEqualTo("(Age add null)"); + assertThat(ValueNumeric.NULL.add(ValueNumeric.NULL).getExpression(ODataProtocol.V4)) + .isEqualTo("(null add null)"); + + assertThat(field2.asNumber().subtract(ValueNumeric.NULL).getExpression(ODataProtocol.V4)) + .isEqualTo("(Age sub null)"); + assertThat(ValueNumeric.NULL.subtract(ValueNumeric.NULL).getExpression(ODataProtocol.V4)) + .isEqualTo("(null sub null)"); + + assertThat(field2.asNumber().modulo(ValueNumeric.NULL).getExpression(ODataProtocol.V4)) + .isEqualTo("(Age mod null)"); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ODataEntityKeyTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ODataEntityKeyTest.java new file mode 100644 index 000000000..113fb46ee --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ODataEntityKeyTest.java @@ -0,0 +1,115 @@ +package com.sap.cloud.sdk.datamodel.odata.client.expression; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; + +class ODataEntityKeyTest +{ + ODataEntityKey keyV2; + ODataEntityKey keyV4; + + @BeforeEach + void setupKey() + { + keyV2 = new ODataEntityKey(ODataProtocol.V2); + keyV4 = new ODataEntityKey(ODataProtocol.V4); + } + + @Test + void testEncoding() + { + keyV2.addKeyProperty("key", "/? #&value%$"); + keyV4.addKeyProperty("key", "/? #&value%$"); + + // $ and & don't have to be encoded because they are not part of the query here + // Instead they are part of the path. That's why they are not interpreted as query parameter delimiters + assertThat(keyV2.toEncodedString()).isEqualTo("('%2F%3F%20%23&value%25$')"); + assertThat(keyV4.toEncodedString()).isEqualTo("('%2F%3F%20%23&value%25$')"); + } + + @Test + void testEncodingForComplexKeys() + { + keyV2.addKeyProperty("key1#&%$", "/? #&value%$"); + keyV2.addKeyProperty("key2", "value"); + keyV4.addKeyProperty("key1#&%$", "/? #&value%$"); + keyV4.addKeyProperty("key2", "value"); + + assertThat(keyV2.toEncodedString()).isEqualTo("(key1#&%$='%2F%3F%20%23&value%25$',key2='value')"); + assertThat(keyV4.toEncodedString()).isEqualTo("(key1#&%$='%2F%3F%20%23&value%25$',key2='value')"); + } + + @Test + void testNoEncodingForSafeChars() + { + keyV2.addKeyProperty("key1", "-._~!$'("); + keyV4.addKeyProperty("key1", "-._~!$'("); + keyV2.addKeyProperty("key2", ")*,;&=@:"); + keyV4.addKeyProperty("key2", ")*,;&=@:"); + + assertThat(keyV2.toEncodedString()).isEqualTo("(key1='-._~!$''(',key2=')*,;&=@:')"); + assertThat(keyV4.toEncodedString()).isEqualTo("(key1='-._~!$''(',key2=')*,;&=@:')"); + } + + @Test + void testQuoteEscaping() + { + keyV2.addKeyProperty("key", "valu'e"); + keyV4.addKeyProperty("key", "valu'e"); + + assertThat(keyV2.toEncodedString()).isEqualTo("('valu''e')"); + assertThat(keyV4.toEncodedString()).isEqualTo("('valu''e')"); + } + + @Test + void testEmptyKey() + { + assertThat(keyV2).hasToString("()"); + assertThat(keyV2.toEncodedString()).isEqualTo("()"); + + assertThat(keyV4).hasToString("()"); + assertThat(keyV4.toEncodedString()).isEqualTo("()"); + } + + @Test + void testNullKey() + { + keyV2.addKeyProperty("key", null); + assertThat(keyV2.toEncodedString()).isEqualTo("(null)"); + keyV4.addKeyProperty("key", null); + assertThat(keyV4.toEncodedString()).isEqualTo("(null)"); + } + + @Test + void testDataTypeSerialisationV4() + { + + final String expected = """ + (\ + stringParameter='test',\ + booleanParameter=true,\ + integerParameter=9000,\ + decimalParameter=3.14,\ + durationParameter=duration'PT8H',\ + dateTimeParameter=2019-12-25T08:00:00Z\ + )\ + """; + + keyV4.addKeyProperty("stringParameter", "test"); + keyV4.addKeyProperty("booleanParameter", true); + keyV4.addKeyProperty("integerParameter", 9000); + keyV4.addKeyProperty("decimalParameter", 3.14); + keyV4.addKeyProperty("durationParameter", Duration.ofHours(8)); + keyV4.addKeyProperty("dateTimeParameter", LocalDateTime.of(2019, 12, 25, 8, 0, 0)); + + assertThat(keyV4).hasToString(expected); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/BoundFunctionsTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/BoundFunctionsTest.java new file mode 100644 index 000000000..290a030bd --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/BoundFunctionsTest.java @@ -0,0 +1,180 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; + +class BoundFunctionsTest +{ + private static final String SERVICE_PATH = "/service/"; + private static final String FUNCTION = "Model.Function"; + private static final String ENTITY = "Entity"; + private static final ODataEntityKey ENTITY_KEY = new ODataEntityKey(ODataProtocol.V4); + private static final ODataFunctionParameters FUNCTION_KEY = new ODataFunctionParameters(ODataProtocol.V4); + + @BeforeAll + static void setup() + { + ENTITY_KEY.addKeyProperty("key1", "foo/bar"); + ENTITY_KEY.addKeyProperty("key2", 123); + + FUNCTION_KEY.addParameter("param1", "foo/bar"); + FUNCTION_KEY.addParameter("param2", 123); + } + + // Reference service support for bound functions is terrible + + // working only on https://services.odata.org/V4/(S(hash))/TripPinServiceRW/: + + // /People('russellwhyte')/Microsoft.OData.SampleService.Models.TripPin.GetFavoriteAirline() + // /People('russellwhyte')/Microsoft.OData.SampleService.Models.TripPin.GetFavoriteAirline + // /People('russellwhyte')/Microsoft.OData.SampleService.Models.TripPin.GetFavoriteAirline()/Name + + // working only on http://services.odata.org/TripPinRESTierService/(S(3mslpb2bc0k5ufk24olpghzx))/: + + // /People('russellwhyte')/Trips(0)/Microsoft.OData.Service.Sample.TrippinInMemory.Models.GetInvolvedPeople() + // /People('russellwhyte')/Trips(0)/Microsoft.OData.Service.Sample.TrippinInMemory.Models.GetInvolvedPeople + // /People('russellwhyte')/Trips(0)/Microsoft.OData.Service.Sample.TrippinInMemory.Models.GetInvolvedPeople()?$top=1 + // /People('russellwhyte')/Trips(0)/Microsoft.OData.Service.Sample.TrippinInMemory.Models.GetInvolvedPeople()?$filter=FirstName%20eq%20'Russell' + + // not working: + // /People('russellwhyte')/GetFavoriteAirline() + // /People('russellwhyte')/GetFavoriteAirline + + // not supported in general: + // $each + // /People/$each/Microsoft.OData.SampleService.Models.TripPin.GetFavoriteAirline() + + @Test + void testFunctionOnEntityCollection() + { + final String expected = "/service/Entity/Model.Function()"; + + final ODataResourcePath functionPath = + ODataResourcePath.of(ENTITY).addSegment(FUNCTION, ODataFunctionParameters.empty(ODataProtocol.V4)); + + final ODataRequestFunction request = + new ODataRequestFunction(SERVICE_PATH, functionPath, null, ODataProtocol.V4); + + final String actual = request.getRelativeUri().toString(); + + assertThat(actual).isEqualTo(expected); + } + + @Test + void testFunctionOnEntity() + { + final String expected = "/service/Entity(key1='foo%2Fbar',key2=123)/Model.Function()"; + + final ODataResourcePath functionPath = + new ODataResourcePath() + .addSegment(ENTITY, ENTITY_KEY) + .addSegment(FUNCTION, ODataFunctionParameters.empty(ODataProtocol.V4)); + + final ODataRequestFunction request = + new ODataRequestFunction(SERVICE_PATH, functionPath, null, ODataProtocol.V4); + + final String actual = request.getRelativeUri().toString(); + + assertThat(actual).isEqualTo(expected); + } + + @Test + void testFunctionOnPrimitive() + { + final String expected = "/service/Entity(key1='foo%2Fbar',key2=123)/SimpleProperty/Model.Function()"; + + final ODataResourcePath functionPath = + ODataResourcePath + .of(ENTITY, ENTITY_KEY) + .addSegment("SimpleProperty") + .addSegment(FUNCTION, ODataFunctionParameters.empty(ODataProtocol.V4)); + + final ODataRequestFunction request = + new ODataRequestFunction(SERVICE_PATH, functionPath, null, ODataProtocol.V4); + + final String actual = request.getRelativeUri().toString(); + + assertThat(actual).isEqualTo(expected); + } + + @Test + void testFunctionOnNavigationalProperty() + { + final String expected = + "/service/Entity(key1='foo%2Fbar',key2=123)/NavigationPropertyCollection(0)/Model.Function()"; + + final ODataEntityKey key = new ODataEntityKey(ODataProtocol.V4); + key.addKeyProperty("key1", 0); + + final ODataResourcePath functionPath = + ODataResourcePath + .of(ENTITY, ENTITY_KEY) + .addSegment("NavigationPropertyCollection", key) + .addSegment(FUNCTION, ODataFunctionParameters.empty(ODataProtocol.V4)); + + final ODataRequestFunction request = + new ODataRequestFunction(SERVICE_PATH, functionPath, null, ODataProtocol.V4); + + final String actual = request.getRelativeUri().toString(); + + assertThat(actual).isEqualTo(expected); + } + + @Test + void testFunctionWithParameters() + { + final String expected = "/service/Entity/Model.Function(param1='foo%2Fbar',param2=123)"; + + final ODataResourcePath functionPath = ODataResourcePath.of(ENTITY).addSegment(FUNCTION, FUNCTION_KEY); + + final ODataRequestFunction request = + new ODataRequestFunction(SERVICE_PATH, functionPath, null, ODataProtocol.V4); + + final String actual = request.getRelativeUri().toString(); + + assertThat(actual).isEqualTo(expected); + } + + @Test + void testComposableFunction() + { + final String expected = "/service/Entity/Model.Function()/ResultProperty?$top=5"; + + final ODataResourcePath functionPath = + ODataResourcePath + .of(ENTITY) + .addSegment(FUNCTION, ODataFunctionParameters.empty(ODataProtocol.V4)) + .addSegment("ResultProperty"); + + final ODataRequestFunction request = + new ODataRequestFunction(SERVICE_PATH, functionPath, "$top=5", ODataProtocol.V4); + + final String actual = request.getRelativeUri().toString(); + + assertThat(actual).isEqualTo(expected); + } + + @Test + void testFunctionOnEachEntity() + { + final String expected = "/service/Entity/$each/Model.Function()"; + + final ODataResourcePath functionPath = + ODataResourcePath + .of(ENTITY) + .addSegment("$each") + .addSegment(FUNCTION, ODataFunctionParameters.empty(ODataProtocol.V4)); + + final ODataRequestFunction request = + new ODataRequestFunction(SERVICE_PATH, functionPath, null, ODataProtocol.V4); + + final String actual = request.getRelativeUri().toString(); + + assertThat(actual).isEqualTo(expected); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/HttpEntityReaderTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/HttpEntityReaderTest.java new file mode 100644 index 000000000..ff9cd83ac --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/HttpEntityReaderTest.java @@ -0,0 +1,136 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; + +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; +import com.google.gson.stream.MalformedJsonException; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataDeserializationException; + +class HttpEntityReaderTest +{ + private final ODataRequestResult odataResult = mock(ODataRequestResult.class); + private final ODataRequestGeneric odataRequest = mock(ODataRequestGeneric.class); + private final BasicClassicHttpResponse httpResponse = mock(BasicClassicHttpResponse.class); + // private final StatusLine httpResponseStatusLine = mock(StatusLine.class); + + @BeforeEach + void adjustMocks() + { + lenient().when(odataRequest.getProtocol()).thenReturn(ODataProtocol.V2); + lenient().when(odataResult.getHttpResponse()).thenReturn(httpResponse); + lenient().when(odataResult.getODataRequest()).thenReturn(odataRequest); + // lenient().when(httpResponse.getStatusLine()).thenReturn(httpResponseStatusLine); + lenient().when(httpResponse.getHeaders()).thenReturn(new Header[0]); + // lenient().when(httpResponseStatusLine.getStatusCode()).thenReturn(HttpStatus.SC_OK); + } + + @Test + void testSuccessStream() + { + when(httpResponse.getEntity()).thenReturn(new StringEntity("{\"foo\":\"bar\"}", StandardCharsets.UTF_8)); + + final boolean result = HttpEntityReader.stream(odataResult, reader -> { + reader.beginObject(); + assertThat(reader.nextName()).isEqualTo("foo"); + assertThat(reader.nextString()).isEqualTo("bar"); + reader.endObject(); + return true; + }); + + assertThat(result).isTrue(); + } + + @Test + void testSuccessRead() + { + when(httpResponse.getEntity()).thenReturn(new StringEntity("{\"foo\":\"bar\"}", StandardCharsets.UTF_8)); + + final boolean result = HttpEntityReader.read(odataResult, element -> { + final JsonObject expected = new JsonObject(); + expected.addProperty("foo", "bar"); + assertThat(element).isEqualTo(expected); + return true; + }); + + assertThat(result).isTrue(); + } + + @Test + void testErrorStreamInside() + { + when(httpResponse.getEntity()).thenReturn(new StringEntity("{\"b ro ken", StandardCharsets.UTF_8)); + + HttpEntityReader.stream(odataResult, reader -> { + reader.beginObject(); + assertThatCode(reader::nextName).isInstanceOf(MalformedJsonException.class); + assertThatCode(reader::nextString).isInstanceOf(IllegalStateException.class); + assertThatCode(reader::endObject).isInstanceOf(IllegalStateException.class); + return null; + }); + } + + @Test + void testErrorStreamOutside() + { + when(httpResponse.getEntity()).thenReturn(new StringEntity("{\"b ro ken", StandardCharsets.UTF_8)); + + assertThatExceptionOfType(ODataDeserializationException.class) + .isThrownBy(() -> HttpEntityReader.stream(odataResult, reader -> { + reader.beginObject(); // success + reader.nextName(); // failure + reader.nextString(); // not reachable code + reader.endObject(); // not reachable code + return null; + })) + .withCauseExactlyInstanceOf(MalformedJsonException.class); + } + + @Test + void testErrorRead() + { + when(httpResponse.getEntity()).thenReturn(new StringEntity("{\"b ro ken", StandardCharsets.UTF_8)); + + assertThatExceptionOfType(ODataDeserializationException.class) + .isThrownBy(() -> HttpEntityReader.read(odataResult, element -> { + // already failed + return null; + })) + .withCauseExactlyInstanceOf(JsonSyntaxException.class) + .withRootCauseExactlyInstanceOf(MalformedJsonException.class); + } + + @Test + void testNoEntityRead() + { + when(httpResponse.getEntity()).thenReturn(null); + + assertThatExceptionOfType(ODataDeserializationException.class) + .isThrownBy(() -> HttpEntityReader.read(odataResult, element -> true)) + .withNoCause(); + } + + @Test + void testNoEntityStream() + { + when(httpResponse.getEntity()).thenReturn(null); + + assertThatExceptionOfType(ODataDeserializationException.class) + .isThrownBy(() -> HttpEntityReader.stream(odataResult, reader -> true)) + .withNoCause(); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartParserTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartParserTest.java new file mode 100644 index 000000000..a0b35c955 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartParserTest.java @@ -0,0 +1,258 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import static com.sap.cloud.sdk.datamodel.odata.client.request.MultipartParser.Entry; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + +import javax.annotation.Nonnull; + +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpVersion; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.apache.hc.core5.http.message.StatusLine; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import com.google.common.io.Resources; + +import lombok.SneakyThrows; + +class MultipartParserTest +{ + static List getNewLineDelimiters() + { + return Arrays.asList("\r\n", "\n"); + } + + @SneakyThrows + @ParameterizedTest + @MethodSource( "getNewLineDelimiters" ) + void testSimpleReadSuccess( @Nonnull final String newLine ) + { + final String responseText = + newLine // sanity check + + ("--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef" + newLine) + + ("Content-Type: application/http" + newLine) + + ("Content-Transfer-Encoding: binary" + newLine) + + ("" + newLine) + + ("HTTP/1.1 200 OK" + newLine) + + ("Content-Type: application/json; odata.metadata=minimal; odata.streaming=true" + newLine) + + ("OData-Version: 4.0" + newLine) + + ("" + newLine) + + ("{\"foØ\":\"bär\"}" + newLine) + + ("--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef--" + newLine); + + final ByteArrayInputStream response = new ByteArrayInputStream(responseText.getBytes(UTF_8)); + final String delimiter = "--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef"; + + // user code + final List> result = MultipartParser.ofInputStream(response, UTF_8, delimiter).toList(); + final MultipartHttpResponse httpResponse = MultipartHttpResponse.ofHttpContent(result.get(0).get(0)); + + assertThat(result).hasSize(1); + assertThat(result.get(0)).hasSize(1); + assertThat(result.get(0).get(0).getPayload()).startsWith("HTTP/1.1 200 OK"); + + assertThat(httpResponse.getHeaders()).satisfiesExactly(header -> { + assertThat(header.getName()).isEqualTo("Content-Type"); + assertThat(header.getValue()).isEqualTo("application/json; odata.metadata=minimal; odata.streaming=true"); + }, header -> { + assertThat(header.getName()).isEqualTo("OData-Version"); + assertThat(header.getValue()).isEqualTo("4.0"); + }); + + assertThat(new StatusLine(httpResponse).getProtocolVersion()).isEqualTo(HttpVersion.HTTP_1_1); + assertThat(new StatusLine(httpResponse).getStatusCode()).isEqualTo(200); + assertThat(new StatusLine(httpResponse).getReasonPhrase()).isEqualTo("OK"); + assertThat(httpResponse.getEntity().getContent()).hasContent("{\"foØ\":\"bär\"}"); + assertThat(httpResponse.getEntity().getContentType()) + .isEqualTo("application/json; odata.metadata=minimal; odata.streaming=true; charset=UTF-8"); + } + + @ParameterizedTest + @MethodSource( "getNewLineDelimiters" ) + void testEmptyReadSuccess( @Nonnull final String newLine ) + { + final String responseText = "--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef--" + newLine; + + final ByteArrayInputStream response = new ByteArrayInputStream(responseText.getBytes(UTF_8)); + final String delimiter = "--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef"; + + // user code + final List> result = MultipartParser.ofInputStream(response, UTF_8, delimiter).toList(); + assertThat(result).isEmpty(); + } + + @Test + void testEmpty() + { + final String responseText = ""; + + final ByteArrayInputStream response = new ByteArrayInputStream(responseText.getBytes(UTF_8)); + final String delimiter = "--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef"; + + // user code + final List> result = MultipartParser.ofInputStream(response, UTF_8, delimiter).toList(); + assertThat(result).isEmpty(); + } + + @ParameterizedTest + @MethodSource( "getNewLineDelimiters" ) + void testWrongDelimiterSuccess( @Nonnull final String newLine ) + { + final String responseText = "--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef--" + newLine; + + final ByteArrayInputStream response = new ByteArrayInputStream(responseText.getBytes(UTF_8)); + final String delimiter = "--some-delimiter"; + + // user code + final List> result = MultipartParser.ofInputStream(response, UTF_8, delimiter).toList(); + assertThat(result).isEmpty(); + } + + @ParameterizedTest + @MethodSource( "getNewLineDelimiters" ) + void testNewLineBeforeReadSuccess( @Nonnull final String newLine ) + { + final String responseText = "\n--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef--" + newLine; + + final ByteArrayInputStream response = new ByteArrayInputStream(responseText.getBytes(UTF_8)); + final String delimiter = "--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef"; + + // user code + final List> result = MultipartParser.ofInputStream(response, UTF_8, delimiter).toList(); + assertThat(result).isEmpty(); + } + + @Test + void testEmptyHttpResponse() + { + final BasicClassicHttpResponse httpResponse = mock(BasicClassicHttpResponse.class); + assertThatCode(() -> MultipartParser.ofHttpResponse(httpResponse)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("HTTP response does not contain a content."); + } + + @SneakyThrows + @Test + void testEmptyInputStream() + { + final HttpEntity httpEntity = mock(HttpEntity.class); + when(httpEntity.getContent()).thenThrow(IOException.class); + + final BasicClassicHttpResponse httpResponse = mock(BasicClassicHttpResponse.class); + when(httpResponse.getEntity()).thenReturn(httpEntity); + + assertThatCode(() -> MultipartParser.ofHttpResponse(httpResponse)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Unable to read HTTP content."); + } + + @Test + void testMissingDelimiter() + { + final BasicClassicHttpResponse httpResponse = new BasicClassicHttpResponse(200, "OK"); + httpResponse.setEntity(new StringEntity("", Charset.defaultCharset())); + httpResponse.setHeader(HttpHeaders.CONTENT_TYPE, "multipart/mixed"); + + assertThatCode(() -> MultipartParser.ofHttpResponse(httpResponse)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("No delimiter found in HTTP header."); + } + + @ParameterizedTest + @MethodSource( "getNewLineDelimiters" ) + void testReadResultUncached( @Nonnull final String newLine ) + { + final String responseText = readResourceFileClrf("BatchReadResponseBody.txt", newLine); + final ByteArrayInputStream response = new ByteArrayInputStream(responseText.getBytes(UTF_8)); + + final String delimiter = "--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef"; + + // user code + final Stream> result = MultipartParser.ofInputStream(response, UTF_8, delimiter).toStream(); + + // assert behavior + assertThat(result.count()).isEqualTo(2); // first access successful + + assertThatCode(result::count) // second access error + .isInstanceOf(IllegalStateException.class) + .hasMessage("stream has already been operated upon or closed"); + } + + @SneakyThrows + @ParameterizedTest + @MethodSource( "getNewLineDelimiters" ) + void testWriteResultUncached( @Nonnull final String newLine ) + { + final BasicClassicHttpResponse resp = new BasicClassicHttpResponse(200, "Ok"); + resp.setEntity(new StringEntity(readResourceFileClrf("BatchWriteResponseBody.txt", newLine))); + resp.setHeader("Content-Type", "multipart/mixed; boundary=batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef"); + + // user code + final Stream> result = MultipartParser.ofHttpResponse(resp).toStream(); + + // assert behavior + assertThat(result.count()).isEqualTo(2); // first access successful + + assertThatCode(result::count) // second access error + .isInstanceOf(IllegalStateException.class) + .hasMessage("stream has already been operated upon or closed"); + } + + @SneakyThrows + @ParameterizedTest + @MethodSource( "getNewLineDelimiters" ) + void testWriteResultCached( @Nonnull final String newLine ) + { + final BasicClassicHttpResponse resp = new BasicClassicHttpResponse(200, "Ok"); + resp.setEntity(new StringEntity(readResourceFileClrf("BatchWriteResponseBody.txt", newLine))); + resp.setHeader("Content-Type", "multipart/mixed; boundary=batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef"); + + // user code + final List> result = MultipartParser.ofHttpResponse(resp).toList(); + + // assert behavior + assertThat(result).isNotEmpty(); + + for( final List segments : result ) { + for( final Entry segment : segments ) { + // ignore + } + } + assertThat(result).isNotEmpty(); + + assertThat(result) + .satisfiesExactly( + segments1 -> assertThat(segments1) + .satisfiesExactly( + entry1 -> assertThat(entry1.getPayload()).contains("HTTP/1.1 201 Created"), + entry2 -> assertThat(entry2.getPayload()).contains("HTTP/1.1 201 Created")), + segments2 -> assertThat(segments2) + .satisfiesExactly(entry1 -> assertThat(entry1.getPayload()).contains("HTTP/1.1 404 Not Found"))); + } + + @SneakyThrows + private String readResourceFileClrf( final String resourceFileName, @Nonnull final String newLine ) + { + final Class cl = MultipartParserTest.class; + final URL resourceUrl = cl.getClassLoader().getResource(cl.getSimpleName() + "/" + resourceFileName); + final String fileText = Resources.toString(resourceUrl, UTF_8); + return fileText.replaceAll("\\R", newLine); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataClientQueryBatchUnitTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataClientQueryBatchUnitTest.java new file mode 100644 index 000000000..be40a00a3 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataClientQueryBatchUnitTest.java @@ -0,0 +1,431 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.head; +import static com.github.tomakehurst.wiremock.client.WireMock.headRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.noContent; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.google.common.io.Resources; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataConnectionException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataRequestException; + +class ODataClientQueryBatchUnitTest +{ + private static final WireMockConfiguration WIREMOCK_CONFIGURATION = wireMockConfig().dynamicPort(); + private static final String SERVICE_PATH = "/service/"; + private static final String ENTITY_COLLECTION = "Entity"; + private static final String SERVICE_PATH_BATCH = SERVICE_PATH + "$batch"; + private static final ODataEntityKey ENTITY_KEY = + new ODataEntityKey(ODataProtocol.V4).addKeyProperty("key", "the-key#&!%"); + + private static ODataRequestDelete SAMPLE_REQUEST_DELETE; + private static ODataRequestRead SAMPLE_REQUEST_READ_MULTIPLE; + private static ODataRequestReadByKey SAMPLE_REQUEST_READ_BY_KEY; + private static ODataRequestCreate SAMPLE_REQUEST_CREATE; + private static ODataRequestUpdate SAMPLE_REQUEST_UPDATE; + + private WireMockServer wireMockServer; + private Destination destination; + private final AtomicInteger uuidCounter = new AtomicInteger(0); + private final Supplier uuidProvider = () -> new UUID(0, uuidCounter.incrementAndGet()); + + @BeforeEach + void setup() + { + wireMockServer = new WireMockServer(WIREMOCK_CONFIGURATION); + wireMockServer.start(); + wireMockServer + .stubFor(head(urlPathEqualTo(SERVICE_PATH)).willReturn(noContent().withHeader("x-csrf-token", "foobar"))); + + destination = DefaultHttpDestination.builder(wireMockServer.baseUrl()).build(); + } + + @AfterEach + void teardown() + { + wireMockServer.stop(); + } + + @BeforeAll + static void setupRequests() + { + SAMPLE_REQUEST_READ_MULTIPLE = + new ODataRequestRead(SERVICE_PATH, ENTITY_COLLECTION, "$filter=Fieldname%20eq%20'hello'", ODataProtocol.V4); + + SAMPLE_REQUEST_READ_BY_KEY = + new ODataRequestReadByKey(SERVICE_PATH, ENTITY_COLLECTION, ENTITY_KEY, null, ODataProtocol.V4); + + SAMPLE_REQUEST_CREATE = + new ODataRequestCreate(SERVICE_PATH, ENTITY_COLLECTION, "{\"foo\": \"bar\"}", ODataProtocol.V4); + SAMPLE_REQUEST_CREATE.addHeader("Set-Cookie", "foo"); + SAMPLE_REQUEST_CREATE.addHeader("Set-Cookie", "bar"); + + SAMPLE_REQUEST_UPDATE = + new ODataRequestUpdate( + SERVICE_PATH, + ENTITY_COLLECTION, + ENTITY_KEY, + "{\"foo\": \"bar\"}", + UpdateStrategy.MODIFY_WITH_PATCH, + "version-identifier", + ODataProtocol.V4); + + SAMPLE_REQUEST_DELETE = + new ODataRequestDelete(SERVICE_PATH, ENTITY_COLLECTION, ENTITY_KEY, "version-identifier", ODataProtocol.V4); + } + + @Test + void testEmptyBatch() + { + final String requestBody = + readResourceFileClrf(ODataClientQueryBatchUnitTest.class, "BatchEmptyRequestBody.txt"); + + // create batch request + final ODataRequestBatch request = new ODataRequestBatch(SERVICE_PATH, ODataProtocol.V4, uuidProvider); + + assertThat(request.getBatchRequestBody()).isEqualTo(requestBody); + + // check request execution + final HttpClient client = ApacheHttpClient5Accessor.getHttpClient(destination); + wireMockServer.stubFor(post(urlPathEqualTo(SERVICE_PATH_BATCH)).willReturn(okJson("{}"))); + + final ODataRequestResult result = request.execute(client); + assertThat(result).isNotNull(); + + wireMockServer.verify(headRequestedFor(urlPathEqualTo(SERVICE_PATH))); + wireMockServer + .verify( + postRequestedFor(urlPathEqualTo(SERVICE_PATH_BATCH)) + .withRequestBody(equalTo(requestBody)) + .withoutHeader("Accept") + .withHeader("Content-Type", containing("multipart/mixed;boundary=batch_")) + .withHeader("OData-Version", equalTo("4.0"))); + } + + @Test + void testEmptyChangesetBatch() + { + final String requestBody = + readResourceFileClrf(ODataClientQueryBatchUnitTest.class, "BatchEmptyChangesetRequestBody.txt"); + + // create batch request + final ODataRequestBatch request = + new ODataRequestBatch(SERVICE_PATH, ODataProtocol.V4, uuidProvider).beginChangeset().endChangeset(); + + assertThat(request.getBatchRequestBody()).isEqualTo(requestBody); + + // check request execution + final HttpClient client = ApacheHttpClient5Accessor.getHttpClient(destination); + wireMockServer.stubFor(post(urlPathEqualTo(SERVICE_PATH_BATCH)).willReturn(okJson("{}"))); + + final ODataRequestResult result = request.execute(client); + assertThat(result).isNotNull(); + + wireMockServer.verify(headRequestedFor(urlPathEqualTo(SERVICE_PATH))); + wireMockServer + .verify( + postRequestedFor(urlPathEqualTo(SERVICE_PATH_BATCH)) + .withRequestBody(equalTo(requestBody)) + .withoutHeader("Accept") + .withHeader("Content-Type", containing("multipart/mixed;boundary=batch_")) + .withHeader("OData-Version", equalTo("4.0"))); + } + + @Test + void testReadOnlyBatch() + { + final String requestBody = + readResourceFileClrf(ODataClientQueryBatchUnitTest.class, "BatchReadRequestBody.txt"); + + // create batch request + final ODataRequestBatch request = + new ODataRequestBatch(SERVICE_PATH, ODataProtocol.V4, uuidProvider).addRead(SAMPLE_REQUEST_READ_MULTIPLE); + + assertThat(request.getBatchRequestBody()).isEqualTo(requestBody); + + // check request execution + final HttpClient client = ApacheHttpClient5Accessor.getHttpClient(destination); + wireMockServer.stubFor(post(urlPathEqualTo(SERVICE_PATH_BATCH)).willReturn(okJson("{}"))); + + final ODataRequestResult result = request.execute(client); + assertThat(result).isNotNull(); + + wireMockServer.verify(headRequestedFor(urlPathEqualTo(SERVICE_PATH))); + wireMockServer + .verify( + postRequestedFor(urlPathEqualTo(SERVICE_PATH_BATCH)) + .withRequestBody(equalTo(requestBody)) + .withoutHeader("Accept") + .withHeader("Content-Type", containing("multipart/mixed;boundary=batch_")) + .withHeader("OData-Version", equalTo("4.0"))); + } + + @Test + @Disabled( "Test triggers a ConnectionPoolTimeoutException. Use it only to manually verify behaviour." ) + void testReadOnlyBatchForceConnectionLeaks() + { + final String requestBody = + readResourceFileClrf(ODataClientQueryBatchUnitTest.class, "BatchReadRequestBody.txt"); + + // create batch request + final ODataRequestBatch request = + new ODataRequestBatch(SERVICE_PATH, ODataProtocol.V4, uuidProvider).addRead(SAMPLE_REQUEST_READ_MULTIPLE); + + assertThat(request.getBatchRequestBody()).isEqualTo(requestBody); + + // check request execution + final HttpClient client = ApacheHttpClient5Accessor.getHttpClient(destination); + wireMockServer.stubFor(post(urlPathEqualTo(SERVICE_PATH_BATCH)).willReturn(okJson("{}"))); + + try { + //Try executing multiple batch requests re-using the same client to exhaust the connection pool + for( int i = 0; i < 200; i++ ) { + final ODataRequestResult result = request.execute(client); + assertThat(result).isNotNull(); + } + } + catch( final Exception e ) { + assertThat(e) + .isInstanceOf(ODataConnectionException.class) + .hasRootCauseExactlyInstanceOf(IOException.class) + .hasMessageContaining( + "Please execute your request with try-with-resources to ensure resources are properly closed."); + } + + } + + @Test + void testCombinedBatch() + { + final String requestBody = readResourceFileClrf(ODataClientQueryBatchUnitTest.class, "BatchAllRequestBody.txt"); + + // create batch request + final ODataRequestBatch request = + new ODataRequestBatch(SERVICE_PATH, ODataProtocol.V4, uuidProvider) + .addRead(SAMPLE_REQUEST_READ_MULTIPLE) + .beginChangeset() + .addCreate(SAMPLE_REQUEST_CREATE) + .addUpdate(SAMPLE_REQUEST_UPDATE) + .addDelete(SAMPLE_REQUEST_DELETE) + .endChangeset() + .addReadByKey(SAMPLE_REQUEST_READ_BY_KEY); + + assertThat(request.getBatchRequestBody()).isEqualTo(requestBody); + + // check request execution + final HttpClient client = ApacheHttpClient5Accessor.getHttpClient(destination); + wireMockServer.stubFor(post(urlPathEqualTo(SERVICE_PATH_BATCH)).willReturn(okJson("{}"))); + + final ODataRequestResult result = request.execute(client); + assertThat(result).isNotNull(); + + wireMockServer.verify(headRequestedFor(urlPathEqualTo(SERVICE_PATH))); + wireMockServer + .verify( + postRequestedFor(urlPathEqualTo(SERVICE_PATH_BATCH)) + .withRequestBody(equalTo(requestBody)) + .withoutHeader("Accept") + .withHeader("Content-Type", containing("multipart/mixed;boundary=batch_")) + .withHeader("OData-Version", equalTo("4.0"))); + } + + @Test + void testBatchErrorWithDifferentServicePath() + { + final HttpClient httpClient = mock(HttpClient.class); + + assertThatCode( + () -> new ODataRequestBatch("this/", ODataProtocol.V4, uuidProvider) + .addRead(new ODataRequestRead("this/", "People", "$top=1", ODataProtocol.V4)) + .addRead(new ODataRequestRead("other/", "People", "$top=2", ODataProtocol.V4)) + .execute(httpClient)) + .isInstanceOf(ODataRequestException.class); + } + + @Test + void testServicePathLacksLeadingSlashAndHasTrailingSlash() + { + final String servicePath = "service-path/"; + final String entityPath = "entity-path"; + + final ODataRequestBatch batchRequest = + new ODataRequestBatch(servicePath, ODataProtocol.V4, uuidProvider) + .beginChangeset() + .addCreate(new ODataRequestCreate(servicePath, entityPath, "{}", ODataProtocol.V4)) + .endChangeset(); + + assertThat(batchRequest.getEncodedServicePath(UriEncodingStrategy.REGULAR)).isEqualTo("/service-path/"); + + final ODataRequestBatch.BatchItemSingle singleRequest = + ((ODataRequestBatch.BatchItemChangeset) batchRequest.getRequests().get(0)).getRequests().get(0); + assertThat(singleRequest.getResourcePath()).isEqualTo(entityPath); + } + + @Test + void testServicePathHasLeadingSlashAndLacksTrailingSlash() + { + final String servicePath = "/service-path"; + final String entityPath = "entity-path"; + + final ODataRequestBatch batchRequest = + new ODataRequestBatch(servicePath, ODataProtocol.V4, uuidProvider) + .beginChangeset() + .addCreate(new ODataRequestCreate(servicePath, entityPath, "{}", ODataProtocol.V4)) + .endChangeset(); + + assertThat(batchRequest.getEncodedServicePath(UriEncodingStrategy.REGULAR)).isEqualTo("/service-path/"); + + final ODataRequestBatch.BatchItemSingle singleRequest = + ((ODataRequestBatch.BatchItemChangeset) batchRequest.getRequests().get(0)).getRequests().get(0); + assertThat(singleRequest.getResourcePath()).isEqualTo(entityPath); + } + + @Test + void testServicePathHasLeadingAndTrailingSlash() + { + final String servicePath = "/service-path/"; + final String entityPath = "entity-path"; + + final ODataRequestBatch batchRequest = + new ODataRequestBatch(servicePath, ODataProtocol.V4, uuidProvider) + .beginChangeset() + .addCreate(new ODataRequestCreate(servicePath, entityPath, "{}", ODataProtocol.V4)) + .endChangeset(); + + assertThat(batchRequest.getEncodedServicePath(UriEncodingStrategy.REGULAR)).isEqualTo("/service-path/"); + + final ODataRequestBatch.BatchItemSingle singleRequest = + ((ODataRequestBatch.BatchItemChangeset) batchRequest.getRequests().get(0)).getRequests().get(0); + assertThat(singleRequest.getResourcePath()).isEqualTo(entityPath); + } + + @Test + void testEntityPathContainsSpecialCharacter() + { + final String servicePath = "/service-path/"; + final String entityPath = "entity-päth"; + + final ODataRequestBatch batchRequest = + new ODataRequestBatch(servicePath, ODataProtocol.V4, uuidProvider) + .beginChangeset() + .addCreate(new ODataRequestCreate(servicePath, entityPath, "{}", ODataProtocol.V4)) + .endChangeset(); + + assertThat(batchRequest.getEncodedServicePath(UriEncodingStrategy.REGULAR)).isEqualTo("/service-path/"); + + final ODataRequestBatch.BatchItemSingle singleRequest = + ((ODataRequestBatch.BatchItemChangeset) batchRequest.getRequests().get(0)).getRequests().get(0); + assertThat(singleRequest.getResourcePath()).isEqualTo("entity-p%C3%A4th"); + } + + @Test + void testServicePathContainsSpecialCharacter() + { + final String servicePath = "service-päth;v=001"; + final String entityPath = "entity-path"; + + final ODataRequestBatch batchRequest = + new ODataRequestBatch(servicePath, ODataProtocol.V4, uuidProvider) + .beginChangeset() + .addCreate(new ODataRequestCreate(servicePath, entityPath, "{}", ODataProtocol.V4)) + .endChangeset(); + + assertThat(batchRequest.getEncodedServicePath(UriEncodingStrategy.REGULAR)) + .isEqualTo("/service-p%C3%A4th;v=001/"); + assertThat(batchRequest.getEncodedServicePath(UriEncodingStrategy.BATCH)) + .isEqualTo("/service-p%C3%A4th%3Bv%3D001/"); + + final ODataRequestBatch.BatchItemSingle singleRequest = + ((ODataRequestBatch.BatchItemChangeset) batchRequest.getRequests().get(0)).getRequests().get(0); + assertThat(singleRequest.getResourcePath()).isEqualTo(entityPath); + } + + @Test + void testSingleRequestPathWithSpecialCharacterUnlikeBatchRequestServicePath() + { + assertThatExceptionOfType(ODataRequestException.class) + .isThrownBy( + () -> new ODataRequestBatch("service-path", ODataProtocol.V4, uuidProvider) + .beginChangeset() + .addCreate(new ODataRequestCreate("service-päth", "entity-path", "{}", ODataProtocol.V4)) + .endChangeset()); + } + + @Test + void testDifferentLeadingSlashesOnBatchAndSingleRequestServicePath() + { + final String batchRequestServicePath = "service-path"; + final String singleRequestServicePath = "/service-path"; + final String entityPath = "entity-path"; + + final ODataRequestBatch batchRequest = + new ODataRequestBatch(batchRequestServicePath, ODataProtocol.V4, uuidProvider) + .beginChangeset() + .addCreate(new ODataRequestCreate(singleRequestServicePath, entityPath, "{}", ODataProtocol.V4)) + .endChangeset(); + + assertThat(batchRequest.getEncodedServicePath(UriEncodingStrategy.REGULAR)).isEqualTo("/service-path/"); + + final ODataRequestBatch.BatchItemSingle singleRequest = + ((ODataRequestBatch.BatchItemChangeset) batchRequest.getRequests().get(0)).getRequests().get(0); + assertThat(singleRequest.getResourcePath()).isEqualTo(entityPath); + } + + @Test + void testBatchErrorWithDifferentServiceVersions() + { + final HttpClient httpClient = mock(HttpClient.class); + + assertThatCode( + () -> new ODataRequestBatch("this/", ODataProtocol.V4, uuidProvider) + .addRead(new ODataRequestRead("this/", "People", "$top=1", ODataProtocol.V2)) + .execute(httpClient)) + .isInstanceOf(ODataRequestException.class); + } + + private static String readResourceFileClrf( final Class cls, final String resourceFileName ) + { + return readResourceFile(cls, resourceFileName).replaceAll("(? cls, final String resourceFileName ) + { + try { + final URL resourceUrl = cls.getClassLoader().getResource(cls.getSimpleName() + "/" + resourceFileName); + return Resources.toString(resourceUrl, StandardCharsets.UTF_8); + } + catch( final IOException e ) { + throw new IllegalStateException(e); + } + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataDeltaLinkTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataDeltaLinkTest.java new file mode 100644 index 000000000..b3a71e55e --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataDeltaLinkTest.java @@ -0,0 +1,98 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpVersion; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; + +class ODataDeltaLinkTest +{ + private static final String PAYLOAD_DELTA_LINK = """ + { + "@odata.context": "$metadata#FooBar", + "value": [], + "@odata.deltaLink": "/v1/foo/bar/endpoint?$deltatoken=s3cReT-t0k3n&foo=bar" + } + """; + + @Test + void testEmptyDeltaLinkV2() + { + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + when(request.getProtocol()).thenReturn(ODataProtocol.V2); + + final BasicClassicHttpResponse httpResponse = new BasicClassicHttpResponse(200, "Ok"); + httpResponse.setVersion(HttpVersion.HTTP_1_1); + httpResponse.setEntity(new StringEntity("{}", ContentType.APPLICATION_JSON)); + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, httpResponse); + + assertThat(result.getDeltaLink()).isEmpty(); + } + + @Test + void testEmptyDeltaLinkV4() + { + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + when(request.getProtocol()).thenReturn(ODataProtocol.V4); + + final BasicClassicHttpResponse httpResponse = new BasicClassicHttpResponse(200, "Ok"); + httpResponse.setVersion(HttpVersion.HTTP_1_1); + httpResponse.setEntity(new StringEntity("{}", ContentType.APPLICATION_JSON)); + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, httpResponse); + + assertThat(result.getDeltaLink()).isEmpty(); + } + + @Test + void testNotParsedDeltaLinkV2() + { + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + when(request.getProtocol()).thenReturn(ODataProtocol.V2); + + final BasicClassicHttpResponse httpResponse = new BasicClassicHttpResponse(200, "Ok"); + httpResponse.setVersion(HttpVersion.HTTP_1_1); + httpResponse.setEntity(new StringEntity(PAYLOAD_DELTA_LINK, ContentType.APPLICATION_JSON)); + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, httpResponse); + + assertThat(result.getDeltaLink()).isEmpty(); + } + + @Test + void testParsedDeltaLinkV4() + { + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + when(request.getProtocol()).thenReturn(ODataProtocol.V4); + + final BasicClassicHttpResponse httpResponse = new BasicClassicHttpResponse(200, "Ok"); + httpResponse.setVersion(HttpVersion.HTTP_1_1); + httpResponse.setEntity(new StringEntity(PAYLOAD_DELTA_LINK, ContentType.APPLICATION_JSON)); + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, httpResponse); + + assertThat(result.getDeltaLink()).containsExactly("/v1/foo/bar/endpoint?$deltatoken=s3cReT-t0k3n&foo=bar"); + assertThat(result.getDeltaLink().flatMap(ODataUriFactory::extractDeltaToken)).containsExactly("s3cReT-t0k3n"); + } + + @Test + void testEmptyDeltaTokenV4() + { + final String emptyToken = "{\"@odata.deltaLink\": \"/v1/foo/bar/endpoint?$deltatoken=&foo=bar\"}"; + + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + when(request.getProtocol()).thenReturn(ODataProtocol.V4); + + final BasicClassicHttpResponse httpResponse = new BasicClassicHttpResponse(200, "Ok"); + httpResponse.setVersion(HttpVersion.HTTP_1_1); + httpResponse.setEntity(new StringEntity(emptyToken, ContentType.APPLICATION_JSON)); + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, httpResponse); + + assertThat(result.getDeltaLink()).containsExactly("/v1/foo/bar/endpoint?$deltatoken=&foo=bar"); + assertThat(result.getDeltaLink().flatMap(ODataUriFactory::extractDeltaToken)).isEmpty(); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataFetchAsStreamTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataFetchAsStreamTest.java new file mode 100644 index 000000000..5ab74291e --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataFetchAsStreamTest.java @@ -0,0 +1,180 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol.V2; +import static com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol.V4; +import static com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath.of; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; + +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; + +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import com.google.common.io.Resources; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +//import com.sap.cloud.sdk.cloudplatform.connectivity.HttpEntityUtil; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; + +import lombok.SneakyThrows; + +class ODataFetchAsStreamTest +{ + private static final String URL = "/service"; + private static final String TEXT_FILE_NAME = "test.txt"; + private static final String IMAGE_FILE_NAME = "SAP_logo.png"; + private static final String PDF_FILE_NAME = "POT01.pdf"; + + private static final Consumer VALIDATOR_BUFFERED = + result -> assertThat(result.getHttpResponse().getEntity().isRepeatable()).isTrue(); + + private static final Consumer VALIDATOR_LAZY = + result -> assertThat(result.getHttpResponse().getEntity().isRepeatable()).isFalse(); + + @RegisterExtension + static final WireMockExtension SERVER = + WireMockExtension.newInstance().options(wireMockConfig().dynamicPort()).build(); + + @SneakyThrows + private ODataRequestResult testStreamedFileForRequest( + final String fileName, + final ODataRequestExecutable request, + final Consumer resultValidator ) + { + final String responseBody = readResourceFile(fileName); + SERVER.stubFor(get(WireMock.anyUrl()).willReturn(ok().withBody(responseBody))); + + final Destination destination = DefaultHttpDestination.builder(SERVER.baseUrl()).build(); + + final ODataRequestResult result = request.execute(ApacheHttpClient5Accessor.getHttpClient(destination)); + resultValidator.accept(result); + + try( InputStream actualFileStream = result.getHttpResponse().getEntity().getContent(); ) { + assertThat(actualFileStream).isNotNull(); + assertThat(actualFileStream.available()).isGreaterThan(0); + + try( InputStream expectedFileStream = readResourceStream(fileName) ) { + assertThat(actualFileStream).hasSameContentAs(expectedFileStream); + } + } + + SERVER.verify(getRequestedFor(urlEqualTo(URL + "/Airports('BER')/$value"))); + + return result; + } + + @Test + void testFetchTextFileAsStreamODataV2() + { + testStreamedFileForRequest(TEXT_FILE_NAME, createRequest(V2), VALIDATOR_BUFFERED); + } + + @Test + void testFetchImageFileAsStreamODataV2() + { + testStreamedFileForRequest(IMAGE_FILE_NAME, createRequest(V2), VALIDATOR_BUFFERED); + } + + @Test + void testFetchPdfFileAsStreamODataV2() + { + testStreamedFileForRequest(PDF_FILE_NAME, createRequest(V2), VALIDATOR_BUFFERED); + } + + @Test + void testFetchTextFileAsStreamODataV4() + { + testStreamedFileForRequest(TEXT_FILE_NAME, createRequest(V4), VALIDATOR_BUFFERED); + } + + @Test + void testFetchImageFileAsStreamODataV4() + { + testStreamedFileForRequest(IMAGE_FILE_NAME, createRequest(V4), VALIDATOR_BUFFERED); + } + + @Test + void testFetchPdfFileAsStreamODataV4() + { + testStreamedFileForRequest(PDF_FILE_NAME, createRequest(V4), VALIDATOR_BUFFERED); + } + + @SneakyThrows + @Test + void testLazyResponseAsStreamODataV2() + { + final ODataRequestReadByKey request = createRequest(V2); + final ODataRequestExecutable lazyRequest = request.withoutResponseBuffering(); + final ODataRequestResult result = testStreamedFileForRequest(TEXT_FILE_NAME, lazyRequest, VALIDATOR_LAZY); + + assertThatIOException() + .isThrownBy(() -> EntityUtils.toString(result.getHttpResponse().getEntity(), StandardCharsets.UTF_8)) + .withMessage("Stream closed"); + } + + @SneakyThrows + @Test + void testLazyResponseAsStreamODataV4() + { + final ODataRequestReadByKey request = createRequest(V4); + final ODataRequestExecutable lazyRequest = request.withoutResponseBuffering(); + final ODataRequestResult result = testStreamedFileForRequest(TEXT_FILE_NAME, lazyRequest, VALIDATOR_LAZY); + + assertThatIOException() + .isThrownBy(() -> EntityUtils.toString(result.getHttpResponse().getEntity(), StandardCharsets.UTF_8)) + .withMessage("Stream closed"); + } + + @SneakyThrows + @Test + void testLazyResponseWithWorkaround() + { + final ODataRequestReadByKey request = createRequest(V4); + ODataRequestReadByKey.class.getMethod("withoutResponseBuffering").invoke(request); // workaround + final ODataRequestResult result = testStreamedFileForRequest(TEXT_FILE_NAME, request, VALIDATOR_LAZY); + + assertThatIOException() + .isThrownBy(() -> EntityUtils.toString(result.getHttpResponse().getEntity(), StandardCharsets.UTF_8)) + .withMessage("Stream closed"); + } + + @SneakyThrows + private static String readResourceFile( final String resourceFileName ) + { + final String fileName = ODataFetchAsStreamTest.class.getSimpleName() + "/files/" + resourceFileName; + final URL resourceUrl = ODataFetchAsStreamTest.class.getClassLoader().getResource(fileName); + + if( resourceUrl == null ) { + throw new IllegalStateException("Cannot find resource file with name \"" + resourceFileName + "\"."); + } + return Resources.toString(resourceUrl, StandardCharsets.UTF_8); + } + + @SneakyThrows + private static InputStream readResourceStream( final String resourceFileName ) + { + final String fileName = ODataFetchAsStreamTest.class.getSimpleName() + "/files/" + resourceFileName; + return ODataFetchAsStreamTest.class.getClassLoader().getResourceAsStream(fileName); + } + + private static ODataRequestReadByKey createRequest( final ODataProtocol protocol ) + { + final ODataEntityKey key = new ODataEntityKey(protocol).addKeyProperty("Name", "BER"); + final ODataResourcePath path = of("Airports", key).addSegment("$value"); + return new ODataRequestReadByKey(URL, path, "", protocol); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHealthyResponseValidatorTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHealthyResponseValidatorTest.java new file mode 100644 index 000000000..9c7001add --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHealthyResponseValidatorTest.java @@ -0,0 +1,82 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import java.nio.charset.StandardCharsets; + +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataResponseException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataServiceErrorException; + +class ODataHealthyResponseValidatorTest +{ + private static final ODataRequestGeneric REQUEST = + new ODataRequestRead("service-path", "EntitySet", null, ODataProtocol.V2); + + @Test + void testSuccess() + { + final BasicClassicHttpResponse httpResponse = new BasicClassicHttpResponse(HttpStatus.SC_OK, "OK"); + final ODataRequestResult odataResult = new ODataRequestResultGeneric(REQUEST, httpResponse); + + ODataHealthyResponseValidator.requireHealthyResponse(odataResult); + } + + @Test + void testNotFound() + { + final BasicClassicHttpResponse httpResponse = + new BasicClassicHttpResponse(HttpStatus.SC_NOT_FOUND, "Not Found"); + final ODataRequestResult odataResult = new ODataRequestResultGeneric(REQUEST, httpResponse); + + assertThatExceptionOfType(ODataResponseException.class) + .isThrownBy(() -> ODataHealthyResponseValidator.requireHealthyResponse(odataResult)) + .withMessageContaining(HttpStatus.SC_NOT_FOUND + ""); + } + + @Test + void testODataError() + { + final String odata_error_json = """ + { + "error": { + "code": "err123", + "message": "Unsupported functionality", + "target": "query", + "details": [ + { + "code": "forty-two", + "target": "$search", + "message": "$search query option not supported" + } + ], + "innererror": { + "foo": 123, + "bar": "ok" + } + } + } + """; + + final BasicClassicHttpResponse httpResponse = + new BasicClassicHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR, "Oh!"); + httpResponse.setEntity(new StringEntity(odata_error_json, StandardCharsets.UTF_8)); + final ODataRequestResult odataResult = new ODataRequestResultGeneric(REQUEST, httpResponse); + + assertThatExceptionOfType(ODataServiceErrorException.class) + .isThrownBy(() -> ODataHealthyResponseValidator.requireHealthyResponse(odataResult)) + .withMessageContaining(HttpStatus.SC_INTERNAL_SERVER_ERROR + "") + .satisfies(e -> { + assertThat(e.getHttpBody()).containsExactly(odata_error_json); + assertThat(e.getHttpCode()).isEqualTo(HttpStatus.SC_INTERNAL_SERVER_ERROR); + assertThat(e.getOdataError().getODataCode()).isEqualTo("err123"); + assertThat(e.getOdataError().getODataMessage()).isEqualTo("Unsupported functionality"); + }); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHttpRequestTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHttpRequestTest.java new file mode 100644 index 000000000..74910c539 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHttpRequestTest.java @@ -0,0 +1,106 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.isNull; + +import java.io.IOException; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpHeaders; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; + +class ODataHttpRequestTest +{ + + private static final String SERVICE_PATH = "/service/"; + private static final String ENTITY_COLLECTION = "Entity"; + private static final ODataEntityKey ENTITY_KEY = + new ODataEntityKey(ODataProtocol.V4).addKeyProperty("EntityKey", "key"); + private static final String QUERY_STRING = "$select=select1&$top=1"; + private final HttpClient httpClient = Mockito.mock(HttpClient.class); + + @Test + void testAcceptHeaderForRead() + throws IOException + { + final ODataRequestRead odataRequest = + new ODataRequestRead(SERVICE_PATH, ENTITY_COLLECTION, QUERY_STRING, ODataProtocol.V2); + + final ODataHttpRequest httpRequest = ODataHttpRequest.withoutBody(odataRequest, httpClient); + httpRequest.requestGet(); + + final ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(HttpUriRequestBase.class); + Mockito.verify(httpClient).executeOpen(isNull(), argumentCaptor.capture(), isNull()); + + final Header[] acceptHeader = argumentCaptor.getValue().getHeaders("Accept"); + assertThat(acceptHeader).isNotEmpty(); + assertThat(acceptHeader[0].getValue()).isEqualTo("application/json"); + } + + @Test + void testAcceptHeaderForReadByKey() + throws IOException + { + final ODataRequestReadByKey odataRequest = + new ODataRequestReadByKey(SERVICE_PATH, ENTITY_COLLECTION, ENTITY_KEY, QUERY_STRING, ODataProtocol.V2); + + final ODataHttpRequest httpRequest = ODataHttpRequest.withoutBody(odataRequest, httpClient); + httpRequest.requestGet(); + + final ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(HttpUriRequestBase.class); + Mockito.verify(httpClient).executeOpen(isNull(), argumentCaptor.capture(), isNull()); + + final Header[] acceptHeader = argumentCaptor.getValue().getHeaders("Accept"); + assertThat(acceptHeader).isNotEmpty(); + assertThat(acceptHeader[0].getValue()).isEqualTo("application/json"); + } + + @Test + void testCustomAcceptHeaderPrecedesDefaultAcceptHeader() + throws IOException + { + final ODataRequestReadByKey odataRequest = + new ODataRequestReadByKey(SERVICE_PATH, ENTITY_COLLECTION, ENTITY_KEY, QUERY_STRING, ODataProtocol.V2); + odataRequest.setHeader(HttpHeaders.ACCEPT, ODataFormat.XML.getHttpAccept()); + + final ODataHttpRequest httpRequest = ODataHttpRequest.withoutBody(odataRequest, httpClient); + + httpRequest.requestGet(); + + final ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(HttpUriRequestBase.class); + Mockito.verify(httpClient).executeOpen(isNull(), argumentCaptor.capture(), isNull()); + + final Header[] acceptHeader = argumentCaptor.getValue().getHeaders("Accept"); + assertThat(acceptHeader).isNotEmpty(); + assertThat(acceptHeader[0].getValue()).isEqualTo("application/xml"); + } + + @Test + void testAddCustomHeaderExtendsExistingHeaders() + throws IOException + { + final ODataRequestReadByKey odataRequest = + new ODataRequestReadByKey(SERVICE_PATH, ENTITY_COLLECTION, ENTITY_KEY, QUERY_STRING, ODataProtocol.V2); + // this header will be added with the existing Accept application/json header, because we used addHeader and not setHeader + odataRequest.addHeader(HttpHeaders.ACCEPT, ODataFormat.XML.getHttpAccept()); + + final ODataHttpRequest httpRequest = ODataHttpRequest.withoutBody(odataRequest, httpClient); + + httpRequest.requestGet(); + + final ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(HttpUriRequestBase.class); + Mockito.verify(httpClient).executeOpen(isNull(), argumentCaptor.capture(), isNull()); + + final Header[] acceptHeader = argumentCaptor.getValue().getHeaders("Accept"); + assertThat(acceptHeader).isNotEmpty(); + assertThat(acceptHeader[0].getValue()).isEqualTo("application/json"); + assertThat(acceptHeader[1].getValue()).isEqualTo("application/xml"); + } + +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataInlineCountTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataInlineCountTest.java new file mode 100644 index 000000000..0cce8e987 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataInlineCountTest.java @@ -0,0 +1,156 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import javax.annotation.Nonnull; + +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.google.common.io.Resources; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataDeserializationException; + +@WireMockTest +class ODataInlineCountTest +{ + @Test + void testInlineCountODataV2( @Nonnull final WireMockRuntimeInfo wm ) + { + final String response = + readResourceFile(ODataInlineCountTest.class, "odata-v2-response-with-inline-count.json"); + stubFor(get(anyUrl()).willReturn(okJson(response))); + + final Destination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + final ODataRequestRead request = + new ODataRequestRead( + "V2/Northwind/Northwind.svc", + "Customers", + "$inlinecount=allpages&$format=json", + ODataProtocol.V2); + + final ODataRequestResultGeneric result = request.execute(ApacheHttpClient5Accessor.getHttpClient(destination)); + + assertThat(result.getInlineCount()).isEqualTo(2); + } + + @Test + void testInlineCountWithBufferedHttpEntityODataV2( @Nonnull final WireMockRuntimeInfo wm ) + { + final String response = + readResourceFile(ODataInlineCountTest.class, "odata-v2-response-with-inline-count.json"); + stubFor(get(anyUrl()).willReturn(okJson(response))); + + final Destination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + final ODataRequestRead request = + new ODataRequestRead( + "V2/Northwind/Northwind.svc", + "Customers", + "$inlinecount=allpages&$format=json", + ODataProtocol.V2); + + final ODataRequestResultGeneric result = request.execute(ApacheHttpClient5Accessor.getHttpClient(destination)); + assertThat(result.getInlineCount()).isEqualTo(2); + assertThat(result.asMap()).containsKeys("results", "__count"); + assertThat(result.asMap().get("results")).isNotNull(); + } + + @Test + void testInlineCountODataV4( @Nonnull final WireMockRuntimeInfo wm ) + { + final String response = + readResourceFile(ODataInlineCountTest.class, "odata-v4-response-with-inline-count.json"); + stubFor(get(anyUrl()).willReturn(okJson(response))); + + final Destination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + final ODataRequestRead request = + new ODataRequestRead("TripPinRESTierService", "People", "$count=true", ODataProtocol.V4); + + final ODataRequestResultGeneric result = request.execute(ApacheHttpClient5Accessor.getHttpClient(destination)); + + assertThat(result.getInlineCount()).isEqualTo(2); + } + + @Test + void testInlineCountWithBufferedHttpEntityODataV4( @Nonnull final WireMockRuntimeInfo wm ) + { + final String response = + readResourceFile(ODataInlineCountTest.class, "odata-v4-response-with-inline-count.json"); + stubFor(get(anyUrl()).willReturn(okJson(response))); + + final Destination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + final ODataRequestRead request = + new ODataRequestRead("TripPinRESTierService", "People", "$count=true", ODataProtocol.V4); + + final ODataRequestResultGeneric result = request.execute(ApacheHttpClient5Accessor.getHttpClient(destination)); + assertThat(result.getInlineCount()).isEqualTo(2); + assertThat(result.asMap()).containsKeys("value", "@odata.count"); + assertThat(result.asMap().get("value")).isNotNull(); + } + + @Test + void testNoInlineCountInResponseODataV4( @Nonnull final WireMockRuntimeInfo wm ) + { + final String response = + readResourceFile(ODataInlineCountTest.class, "odata-v4-response-without-inline-count.json"); + stubFor(get(anyUrl()).willReturn(okJson(response))); + + final Destination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + final ODataRequestRead request = + new ODataRequestRead("TripPinRESTierService", "People", "$count=true", ODataProtocol.V4); + + final ODataRequestResultGeneric result = request.execute(ApacheHttpClient5Accessor.getHttpClient(destination)); + + assertThatExceptionOfType(ODataDeserializationException.class).isThrownBy(result::getInlineCount); + } + + @Test + void testNoInlineCountInResponseODataV2( @Nonnull final WireMockRuntimeInfo wm ) + { + final String response = + readResourceFile(ODataInlineCountTest.class, "odata-v2-response-without-inline-count.json"); + stubFor(get(anyUrl()).willReturn(okJson(response))); + + final Destination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + final ODataRequestRead request = + new ODataRequestRead( + "V2/Northwind/Northwind.svc", + "Customers", + "$inlinecount=allpages&$format=json", + ODataProtocol.V2); + + final ODataRequestResultGeneric result = request.execute(ApacheHttpClient5Accessor.getHttpClient(destination)); + + assertThatExceptionOfType(ODataDeserializationException.class).isThrownBy(result::getInlineCount); + } + + private static String readResourceFile( final Class cls, final String resourceFileName ) + { + try { + final URL resourceUrl = cls.getClassLoader().getResource(cls.getSimpleName() + "/" + resourceFileName); + return Resources.toString(resourceUrl, StandardCharsets.UTF_8); + } + catch( final IOException e ) { + throw new IllegalStateException(e); + } + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataNextLinkTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataNextLinkTest.java new file mode 100644 index 000000000..4e6eea8d4 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataNextLinkTest.java @@ -0,0 +1,290 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.FieldSource; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; + +import io.vavr.control.Try; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.Accessors; + +class ODataNextLinkTest +{ + private static final String PAYLOAD_NEXT_LINK = """ + { + "d": { + "results": [], + "__next": "/v1/foo/bar/endpoint?$skiptoken=s3cReT-t0k3n&foo=bar" + } + } + """; + + @RequiredArgsConstructor( staticName = "named" ) + @AllArgsConstructor + @Accessors( fluent = true ) + @Setter + @ToString( includeFieldNames = false, exclude = { "with", "expects" } ) + static class QueryParameterCase + { + final String label; + Setup with; + Expectation expects; + + @Builder + static class Setup + { + String destinationQuery; + String propertiesQuery; + String initialQuery; + String nextLinkQuery; + } + + @Builder + static class Expectation + { + String initialQuerySent; + String nextLinkQueryParsed; + String nextLinkQuerySent; + } + } + + private static QueryParameterCase[] QUERY_PARAMETERS_CASES = + { + // case 1: query-parameters from destination uri, destination properties, next-link and odata-request are distinct + QueryParameterCase + .named("DISTINCT") + .with( + QueryParameterCase.Setup + .builder() + .destinationQuery("dest1=one&dest2=two") + .propertiesQuery("prop1=one&prop2=two") + .initialQuery("odata1=one&odata2=two") + .nextLinkQuery("next1=one&next2=two") + .build()) + .expects( + QueryParameterCase.Expectation + .builder() + .initialQuerySent("dest1=one&dest2=two&odata1=one&odata2=two&prop1=one&prop2=two") + .nextLinkQueryParsed("$skiptoken=42&next1=one&next2=two") + .nextLinkQuerySent("dest1=one&dest2=two&$skiptoken=42&next1=one&next2=two&prop1=one&prop2=two") + .build()), + + // case 2: query-parameters from destination uri, destination properties, next-link and odata-request with equal values + QueryParameterCase + .named("EQUAL") + .with( + QueryParameterCase.Setup + .builder() + .destinationQuery("dest1=one&dest2=two") + .propertiesQuery("prop1=one&prop2=two") + .initialQuery("odata1=one&odata2=two") + .nextLinkQuery("next1=one&dest1=one&prop1=one&odata1=one") + .build()) + .expects( + QueryParameterCase.Expectation + .builder() + .initialQuerySent("dest1=one&dest2=two&odata1=one&odata2=two&prop1=one&prop2=two") + .nextLinkQueryParsed("$skiptoken=42&next1=one&odata1=one") + .nextLinkQuerySent("dest1=one&dest2=two&$skiptoken=42&next1=one&odata1=one&prop1=one&prop2=two") + .build()), + + // case 3: query-parameters from next link may be in conflict with destination uri or destination properties + QueryParameterCase + .named("CONFLICT") + .with( + QueryParameterCase.Setup + .builder() + .destinationQuery("dest1=one&dest2=two") + .propertiesQuery("prop1=one&prop2=two") + .initialQuery("odata1=one&odata2=two") + .nextLinkQuery("next1=eins&dest1=eins&prop1=eins&odata1=eins") + .build()) + .expects( + QueryParameterCase.Expectation + .builder() + .initialQuerySent("dest1=one&dest2=two&odata1=one&odata2=two&prop1=one&prop2=two") + .nextLinkQueryParsed("$skiptoken=42&next1=eins&dest1=eins&prop1=eins&odata1=eins") + .nextLinkQuerySent( + "dest1=one&dest2=two" // destination parameters + + "&$skiptoken=42&next1=eins&dest1=eins&prop1=eins&odata1=eins" // next-link parameters + + "&prop1=one&prop2=two" // properties parameters + ) + .build()), + + // case 4: sanity check for same query-parameters + QueryParameterCase + .named("SANITY") + .with( + QueryParameterCase.Setup + .builder() + .destinationQuery("foo=bar") + .propertiesQuery("foo=bar") + .initialQuery("foo=bar") + .nextLinkQuery("foo=bar") + .build()) + .expects( + QueryParameterCase.Expectation + .builder() + .initialQuerySent("foo=bar&foo=bar&foo=bar") // 3x due to initial OData request parameter + .nextLinkQueryParsed("$skiptoken=42") + .nextLinkQuerySent("foo=bar&$skiptoken=42&foo=bar") + .build()), + + }; + + @ParameterizedTest + @FieldSource( "QUERY_PARAMETERS_CASES" ) + void testQueryArgumentMerging( @Nonnull final QueryParameterCase testCase ) + { + final WireMockConfiguration wiremockConfig = wireMockConfig().dynamicPort(); + final WireMockServer wiremock = new WireMockServer(wiremockConfig); + wiremock.start(); + + // TEST SETUP: Mock first request and response + final String initialRequest = "/v1/%s/endpoint?%s".formatted(testCase.label, testCase.expects.initialQuerySent); + final String initialResponse = + "{\"d\":{\"results\":[],\"__next\":\"/v1/%s/endpoint?$skiptoken=42&%s\"}}" + .formatted(testCase.label, testCase.with.nextLinkQuery); + wiremock.stubFor(get(urlEqualTo(initialRequest)).willReturn(okJson(initialResponse))); + + // TEST SETUP: Mock second request and response + final String secondRequest = "/v1/%s/endpoint?%s".formatted(testCase.label, testCase.expects.nextLinkQuerySent); + final String secondResponse = "{\"d\":{\"results\":[]}}"; + wiremock.stubFor(get(urlEqualTo(secondRequest)).willReturn(okJson(secondResponse))); + + // TEST SETUP: construct destination and HttpClient + final String destinationUrl = wiremock.baseUrl() + "/?" + testCase.with.destinationQuery; + final DefaultHttpDestination.Builder destinationBuilder = DefaultHttpDestination.builder(destinationUrl); + for( final String queryArg : testCase.with.propertiesQuery.split("&") ) { + final String[] queryArgParts = queryArg.split("=", 2); + destinationBuilder.property("URL.queries." + queryArgParts[0], queryArgParts[1]); + } + final HttpClient client = ApacheHttpClient5Accessor.getHttpClient(destinationBuilder.build()); + + // TEST EXECUTION: Run OData request on behalf of HttpClient + final ODataRequestRead request = + new ODataRequestRead("/v1/" + testCase.label, "endpoint", testCase.with.initialQuery, ODataProtocol.V2); + final ODataRequestResultGeneric resultFirst = request.execute(client); + + // TEST VALIDATION: Validate first actual request uri + assertThat(resultFirst.getNextLink()) + .contains("/v1/%s/endpoint?%s".formatted(testCase.label, testCase.expects.nextLinkQueryParsed)); + wiremock.verify(getRequestedFor(urlEqualTo(initialRequest))); + + // TEST VALIDATION: Validate second actual request uri + final Try resultSecond = resultFirst.tryGetNextPage(); + assertThat(resultSecond).isNotEmpty(); + wiremock.verify(getRequestedFor(urlEqualTo(secondRequest))); + + wiremock.shutdown(); + } + + @Test + void testRemoveDuplicateQueryArguments() + { + final ODataRequestGeneric request = + new ODataRequestRead("/v1/foo/bar/", "endpoint", "blub=42", ODataProtocol.V2); + + final BasicClassicHttpResponse httpResponse = new BasicClassicHttpResponse(200, "Ok"); + httpResponse.setEntity(new StringEntity(PAYLOAD_NEXT_LINK, ContentType.APPLICATION_JSON)); + + final String baseUrl = "http://blub/?high=five"; + + // case 1: query parameters are EQUAL in destination and in nextLink -> remove redundant query parameter + final Destination dest1 = DefaultHttpDestination.builder(baseUrl).property("URL.queries.foo", "bar").build(); + final HttpClient client1 = ApacheHttpClient5Accessor.getHttpClient(dest1); + final ODataRequestResultGeneric result1 = new ODataRequestResultGeneric(request, httpResponse, client1); + assertThat(result1.getNextLink()).contains("/v1/foo/bar/endpoint?$skiptoken=s3cReT-t0k3n"); + + // case 2: query parameters are NOT EQUAL in destination and in nextLink -> retain query parameter + final Destination dest2 = DefaultHttpDestination.builder(baseUrl).property("URL.queries.foo", "SAP").build(); + final HttpClient client2 = ApacheHttpClient5Accessor.getHttpClient(dest2); + final ODataRequestResultGeneric result2 = new ODataRequestResultGeneric(request, httpResponse, client2); + assertThat(result2.getNextLink()).contains("/v1/foo/bar/endpoint?$skiptoken=s3cReT-t0k3n&foo=bar"); + } + + @Test + void testNotParsedNextLinkV4() + { + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + when(request.getProtocol()).thenReturn(ODataProtocol.V4); + + final BasicClassicHttpResponse httpResponse = new BasicClassicHttpResponse(200, "Ok"); + httpResponse.setEntity(new StringEntity(PAYLOAD_NEXT_LINK, ContentType.APPLICATION_JSON)); + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, httpResponse); + + assertThat(result.getNextLink()).isEmpty(); + } + + @Test + void testParsedNextLinkV2() + { + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + when(request.getProtocol()).thenReturn(ODataProtocol.V2); + + final BasicClassicHttpResponse httpResponse = new BasicClassicHttpResponse(200, "Ok"); + httpResponse.setEntity(new StringEntity(PAYLOAD_NEXT_LINK, ContentType.APPLICATION_JSON)); + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, httpResponse); + + assertThat(result.getNextLink()).containsExactly("/v1/foo/bar/endpoint?$skiptoken=s3cReT-t0k3n&foo=bar"); + assertThat(result.getNextLink().flatMap(ODataUriFactory::extractSkipToken)).containsExactly("s3cReT-t0k3n"); + } + + @Test + void testEmptySkipTokenV2() + { + final String emptyToken = "{\"d\": {\"__next\": \"/v1/foo/bar/endpoint?$skiptoken=&foo=bar\"}}"; + + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + when(request.getProtocol()).thenReturn(ODataProtocol.V2); + + final BasicClassicHttpResponse httpResponse = new BasicClassicHttpResponse(200, "Ok"); + httpResponse.setEntity(new StringEntity(emptyToken, ContentType.APPLICATION_JSON)); + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, httpResponse); + + assertThat(result.getNextLink()).containsExactly("/v1/foo/bar/endpoint?$skiptoken=&foo=bar"); + assertThat(result.getNextLink().flatMap(ODataUriFactory::extractSkipToken)).isEmpty(); + } + + @Test + void testNoNextLinkV2() + { + final String noLink = "{\"d\": {}}"; + + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + when(request.getProtocol()).thenReturn(ODataProtocol.V2); + + final BasicClassicHttpResponse httpResponse = new BasicClassicHttpResponse(200, "Ok"); + httpResponse.setEntity(new StringEntity(noLink, ContentType.APPLICATION_JSON)); + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, httpResponse); + + assertThat(result.getNextLink()).isEmpty(); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataPaginationIntegrationTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataPaginationIntegrationTest.java new file mode 100644 index 000000000..0670ae94f --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataPaginationIntegrationTest.java @@ -0,0 +1,46 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; + +@Disabled( "Test runs against a reference service on odata.org. Use it only to manually verify behaviour." ) +class ODataPaginationIntegrationTest +{ + private static final Destination destination = + DefaultHttpDestination.builder("https://services.odata.org/").build(); + + private static final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + + @Test + void testCountOverPages() + { + final ODataRequestRead request = + new ODataRequestRead("V4/Northwind/Northwind.svc", "Customers", "$count=true", ODataProtocol.V4); + + final ODataRequestResultGeneric initialResponse = request.execute(httpClient); + final int initialCount = initialResponse.asListOfMaps().size(); + + // assertion: entity count of initial response is less than inline-count + final long overallCount = initialResponse.getInlineCount(); + assertThat(initialCount).isLessThan((int) overallCount); + + // iterate through pages and increment item count + int countItems = 0; + for( final List nextPage : initialResponse.iteratePages(Object.class) ) { + countItems += nextPage.size(); + } + + // assertion: aggregated item count is equal to inline-count + assertThat(countItems).isEqualTo(overallCount); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataPaginationUnitTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataPaginationUnitTest.java new file mode 100644 index 000000000..dc561c73e --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataPaginationUnitTest.java @@ -0,0 +1,144 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.List; + +import org.apache.hc.client5.http.ConnectTimeoutException; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpUriRequest; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataRequestException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataResponseException; + +import lombok.SneakyThrows; + +class ODataPaginationUnitTest +{ + // corresponds to https://services.odata.org/V4/Northwind/Northwind.svc/Customers?$select=CustomerID&$count=true + private static final String page1 = + "{\"@odata.context\":\"https://services.odata.org/V4/Northwind/Northwind.svc/$metadata#Customers(CustomerID)\",\"@odata.count\":91,\"value\":[{\"CustomerID\":\"ALFKI\"},{\"CustomerID\":\"ANATR\"},{\"CustomerID\":\"ANTON\"},{\"CustomerID\":\"AROUT\"},{\"CustomerID\":\"BERGS\"},{\"CustomerID\":\"BLAUS\"},{\"CustomerID\":\"BLONP\"},{\"CustomerID\":\"BOLID\"},{\"CustomerID\":\"BONAP\"},{\"CustomerID\":\"BOTTM\"},{\"CustomerID\":\"BSBEV\"},{\"CustomerID\":\"CACTU\"},{\"CustomerID\":\"CENTC\"},{\"CustomerID\":\"CHOPS\"},{\"CustomerID\":\"COMMI\"},{\"CustomerID\":\"CONSH\"},{\"CustomerID\":\"DRACD\"},{\"CustomerID\":\"DUMON\"},{\"CustomerID\":\"EASTC\"},{\"CustomerID\":\"ERNSH\"}],\"@odata.nextLink\":\"Customers?$count=true&$select=CustomerID&$skiptoken='ERNSH'\"}"; + private static final String page2 = + "{\"@odata.context\":\"https://services.odata.org/V4/Northwind/Northwind.svc/$metadata#Customers(CustomerID)\",\"@odata.count\":91,\"value\":[{\"CustomerID\":\"FAMIA\"},{\"CustomerID\":\"FISSA\"},{\"CustomerID\":\"FOLIG\"},{\"CustomerID\":\"FOLKO\"},{\"CustomerID\":\"FRANK\"},{\"CustomerID\":\"FRANR\"},{\"CustomerID\":\"FRANS\"},{\"CustomerID\":\"FURIB\"},{\"CustomerID\":\"GALED\"},{\"CustomerID\":\"GODOS\"},{\"CustomerID\":\"GOURL\"},{\"CustomerID\":\"GREAL\"},{\"CustomerID\":\"GROSR\"},{\"CustomerID\":\"HANAR\"},{\"CustomerID\":\"HILAA\"},{\"CustomerID\":\"HUNGC\"},{\"CustomerID\":\"HUNGO\"},{\"CustomerID\":\"ISLAT\"},{\"CustomerID\":\"KOENE\"},{\"CustomerID\":\"LACOR\"}],\"@odata.nextLink\":\"Customers?$count=true&$select=CustomerID&$skiptoken='LACOR'\"}"; + private static final String page3 = + "{\"@odata.context\":\"https://services.odata.org/V4/Northwind/Northwind.svc/$metadata#Customers(CustomerID)\",\"@odata.count\":91,\"value\":[{\"CustomerID\":\"LAMAI\"},{\"CustomerID\":\"LAUGB\"},{\"CustomerID\":\"LAZYK\"},{\"CustomerID\":\"LEHMS\"},{\"CustomerID\":\"LETSS\"},{\"CustomerID\":\"LILAS\"},{\"CustomerID\":\"LINOD\"},{\"CustomerID\":\"LONEP\"},{\"CustomerID\":\"MAGAA\"},{\"CustomerID\":\"MAISD\"},{\"CustomerID\":\"MEREP\"},{\"CustomerID\":\"MORGK\"},{\"CustomerID\":\"NORTS\"},{\"CustomerID\":\"OCEAN\"},{\"CustomerID\":\"OLDWO\"},{\"CustomerID\":\"OTTIK\"},{\"CustomerID\":\"PARIS\"},{\"CustomerID\":\"PERIC\"},{\"CustomerID\":\"PICCO\"},{\"CustomerID\":\"PRINI\"}],\"@odata.nextLink\":\"Customers?$count=true&$select=CustomerID&$skiptoken='PRINI'\"}"; + private static final String page4 = + "{\"@odata.context\":\"https://services.odata.org/V4/Northwind/Northwind.svc/$metadata#Customers(CustomerID)\",\"@odata.count\":91,\"value\":[{\"CustomerID\":\"QUEDE\"},{\"CustomerID\":\"QUEEN\"},{\"CustomerID\":\"QUICK\"},{\"CustomerID\":\"RANCH\"},{\"CustomerID\":\"RATTC\"},{\"CustomerID\":\"REGGC\"},{\"CustomerID\":\"RICAR\"},{\"CustomerID\":\"RICSU\"},{\"CustomerID\":\"ROMEY\"},{\"CustomerID\":\"SANTG\"},{\"CustomerID\":\"SAVEA\"},{\"CustomerID\":\"SEVES\"},{\"CustomerID\":\"SIMOB\"},{\"CustomerID\":\"SPECD\"},{\"CustomerID\":\"SPLIR\"},{\"CustomerID\":\"SUPRD\"},{\"CustomerID\":\"THEBI\"},{\"CustomerID\":\"THECR\"},{\"CustomerID\":\"TOMSP\"},{\"CustomerID\":\"TORTU\"}],\"@odata.nextLink\":\"Customers?$count=true&$select=CustomerID&$skiptoken='TORTU'\"}"; + private static final String page5 = + "{\"@odata.context\":\"https://services.odata.org/V4/Northwind/Northwind.svc/$metadata#Customers(CustomerID)\",\"@odata.count\":91,\"value\":[{\"CustomerID\":\"TRADH\"},{\"CustomerID\":\"TRAIH\"},{\"CustomerID\":\"VAFFE\"},{\"CustomerID\":\"VICTE\"},{\"CustomerID\":\"VINET\"},{\"CustomerID\":\"WANDK\"},{\"CustomerID\":\"WARTH\"},{\"CustomerID\":\"WELLI\"},{\"CustomerID\":\"WHITC\"},{\"CustomerID\":\"WILMK\"},{\"CustomerID\":\"WOLZA\"}]}"; + + @Test + void testCountOverPages() + throws IOException + { + final HttpClient httpClient = mock(HttpClient.class); + doReturn( + createHttpResponse(page1), + createHttpResponse(page2), + createHttpResponse(page3), + createHttpResponse(page4), + createHttpResponse(page5)).when(httpClient).executeOpen(isNull(), any(HttpUriRequest.class), isNull()); + + final ODataRequestRead request = + new ODataRequestRead("V4/Northwind/Northwind.svc", "Customers", "$count=true", ODataProtocol.V4); + + // prepare check whether subsequent page requests also have this header + request.addHeader("foo", "bar"); + final ArgumentMatcher headerMatcher = q -> "bar".equals(q.getHeaders("foo")[0].getValue()); + + // initial request + final ODataRequestResultGeneric initialResponse = request.execute(httpClient); + final int initialCount = initialResponse.asListOfMaps().size(); + + // assertion: entity count of initial response is less than inline-count + final long overallCount = initialResponse.getInlineCount(); + assertThat(initialCount).isLessThan((int) overallCount); + + // critical code: iterate through pages and increment item count + int countItems = 0; + int countRequests = 1; + for( final List nextPage : initialResponse.iteratePages(Object.class) ) { + verify(httpClient, times(countRequests++)).executeOpen(isNull(), argThat(headerMatcher), isNull()); + countItems += nextPage.size(); + } + + // assertion: aggregated item count is equal to inline-count + assertThat(countItems).isEqualTo(overallCount); + } + + @Test + void testErrorForResponse() + throws IOException + { + final HttpClient httpClient = mock(HttpClient.class); + doReturn(createHttpResponse(page1), createHttpResponse(page2), createHttpResponseError("Something went wrong!")) + .when(httpClient) + .executeOpen(isNull(), any(HttpUriRequest.class), isNull()); + + final ODataRequestRead request = + new ODataRequestRead("V4/Northwind/Northwind.svc", "Customers", "$count=true", ODataProtocol.V4); + + final ODataRequestResultGeneric result = request.execute(httpClient); + + assertThatExceptionOfType(ODataException.class).isThrownBy(() -> { + for( final List next : result.iteratePages(Object.class) ) { + // iterate + } + }).withCauseInstanceOf(ODataResponseException.class); + } + + @Test + void testErrorForRequest() + throws IOException + { + final HttpClient httpClient = mock(HttpClient.class); + + when(httpClient.executeOpen(isNull(), any(HttpUriRequest.class), isNull())) + .thenReturn(createHttpResponse(page1), createHttpResponse(page2), createHttpResponse(page3)) + .thenThrow(ConnectTimeoutException.class); + + final ODataRequestRead request = + new ODataRequestRead("V4/Northwind/Northwind.svc", "Customers", "$count=true", ODataProtocol.V4); + + final ODataRequestResultGeneric result = request.execute(httpClient); + + assertThatExceptionOfType(ODataException.class).isThrownBy(() -> { + for( final List next : result.iteratePages(Object.class) ) { + // iterate + } + }).withCauseInstanceOf(ODataRequestException.class); + } + + @SneakyThrows + private BasicClassicHttpResponse createHttpResponse( final String message ) + { + final BasicClassicHttpResponse page = new BasicClassicHttpResponse(200, "OK"); + page.setEntity(new StringEntity(message)); + return page; + } + + @SneakyThrows + private HttpResponse createHttpResponseError( final String message ) + { + final BasicClassicHttpResponse page = new BasicClassicHttpResponse(500, "Internal Server Error"); + page.setEntity(new StringEntity(message)); + return page; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataQueryPropertyTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataQueryPropertyTest.java new file mode 100644 index 000000000..8ed47bb7f --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataQueryPropertyTest.java @@ -0,0 +1,262 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.head; +import static com.github.tomakehurst.wiremock.client.WireMock.noContent; +import static com.github.tomakehurst.wiremock.client.WireMock.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static com.sap.cloud.sdk.datamodel.odata.client.request.UpdateStrategy.REPLACE_WITH_PUT; +import static org.apache.hc.core5.http.HttpHeaders.ACCEPT; +import static org.apache.hc.core5.http.HttpHeaders.CONTENT_TYPE; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayInputStream; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.io.entity.InputStreamEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; + +import lombok.SneakyThrows; + +@WireMockTest +class ODataQueryPropertyTest +{ + private static final String SERVICE_URL = "/service"; + private static final String JSON = "application/json"; + private static final String XML = "application/xml"; + + private static final ODataResourcePath resourceV2 = + ODataResourcePath + .of("Products", new ODataEntityKey(ODataProtocol.V2).addKeyProperty("Id", 0)) + .addSegment("Description"); + private static final ODataResourcePath resourceV4 = + ODataResourcePath + .of("Products", new ODataEntityKey(ODataProtocol.V4).addKeyProperty("Id", 0)) + .addSegment("Description"); + + private HttpClient httpClient; + + @BeforeEach + void setupHttpClient( @Nonnull final WireMockRuntimeInfo wm ) + { + + final Destination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + } + + @Test + void getSimplePropertyV2() + { + final String payloadV2 = "{\"d\":{\"Description\": \"Whole grain bread\"}}"; + stubFor(get(WireMock.anyUrl()).willReturn(okJson(payloadV2))); + + // user code + final ODataRequestReadByKey request = new ODataRequestReadByKey(SERVICE_URL, resourceV2, "", ODataProtocol.V2); + final ODataRequestResultGeneric result = request.execute(httpClient); + + // assertions + verify(getRequestedFor(urlEqualTo(SERVICE_URL + resourceV2))); + + final String data = result.as(String.class); + assertThat(data).isEqualTo("Whole grain bread"); + } + + @Test + void setSimplePropertyV2() + { + stubFor(put(WireMock.anyUrl()).willReturn(noContent())); + stubFor(head(WireMock.anyUrl()).willReturn(ok())); + + // user code + final String payloadV2 = "{\"Description\": \"Whole grain bread\"}"; + final ODataRequestUpdate request = + new ODataRequestUpdate(SERVICE_URL, resourceV2, payloadV2, REPLACE_WITH_PUT, null, ODataProtocol.V2); + final ODataRequestResultGeneric result = request.execute(httpClient); + + // assertions + verify(putRequestedFor(urlEqualTo(SERVICE_URL + resourceV2)).withHeader(CONTENT_TYPE, containing(JSON))); + assertThat(result).isNotNull(); + } + + @Test + void deleteSimplePropertyV2() + { + stubFor(delete(WireMock.anyUrl()).willReturn(noContent())); + stubFor(head(WireMock.anyUrl()).willReturn(ok())); + + // user code + final ODataRequestDelete request = new ODataRequestDelete(SERVICE_URL, resourceV2, null, ODataProtocol.V2); + final ODataRequestResultGeneric result = request.execute(httpClient); + + // assertions + verify(deleteRequestedFor(urlEqualTo(SERVICE_URL + "/Products(0)/Description"))); + assertThat(result).isNotNull(); + } + + @SneakyThrows + @Test + void getStreamPropertyV2() + { + final String payloadV2 = "This is a large document"; + final byte[] payloadBytes = payloadV2.getBytes(UTF_8); + stubFor(get(WireMock.anyUrl()).willReturn(ok().withBody(payloadBytes).withHeader(CONTENT_TYPE, XML))); + + // user code + final ODataRequestReadByKey request = new ODataRequestReadByKey(SERVICE_URL, resourceV2, "", ODataProtocol.V2); + request.addHeader(ACCEPT, null); + + final ODataRequestResultGeneric result = request.execute(httpClient); + + // assertions + verify(getRequestedFor(urlEqualTo(SERVICE_URL + resourceV2))); + + final HttpEntity entity = result.getHttpResponse().getEntity(); + assertThat(entity.getContentType()).isEqualTo(XML); + assertThat(entity.getContent()).hasSameContentAs(new ByteArrayInputStream(payloadBytes)); + } + + @Test + void setStreamPropertyV2() + { + stubFor(put(WireMock.anyUrl()).willReturn(noContent())); + stubFor(head(WireMock.anyUrl()).willReturn(ok())); + + // user code + final String payloadV2 = "This is a large document"; + final ByteArrayInputStream payloadBytes = new ByteArrayInputStream(payloadV2.getBytes(UTF_8)); + final HttpEntity httpEntity = new InputStreamEntity(payloadBytes, ContentType.create(XML, UTF_8)); + + final ODataRequestResultGeneric result = + new ODataRequestUpdate(SERVICE_URL, resourceV2, httpEntity, REPLACE_WITH_PUT, null, ODataProtocol.V2) + .execute(httpClient); + + // assertions + + verify( + putRequestedFor(urlEqualTo(SERVICE_URL + resourceV2)) + .withHeader(CONTENT_TYPE, equalTo("application/xml; charset=UTF-8")) + .withRequestBody(equalTo(payloadV2))); + assertThat(result).isNotNull(); + } + + @Test + void getSimplePropertyV4() + { + final String payloadV4 = + "{\"@odata.context\":\"https://services.odata.org/V4/OData/(S())/OData.svc/$metadata#Products(0)/Description\",\"value\":\"Whole grain bread\"}"; + stubFor(get(WireMock.anyUrl()).willReturn(okJson(payloadV4))); + + // user code + final ODataRequestReadByKey request = new ODataRequestReadByKey(SERVICE_URL, resourceV4, "", ODataProtocol.V4); + final ODataRequestResultGeneric result = request.execute(httpClient); + + // assertions + verify(getRequestedFor(urlEqualTo(SERVICE_URL + resourceV2))); + + final String data = result.as(String.class); + assertThat(data).isEqualTo("Whole grain bread"); + } + + @Test + void setSimplePropertyV4() + { + stubFor(put(WireMock.anyUrl()).willReturn(noContent())); + stubFor(head(WireMock.anyUrl()).willReturn(ok())); + + // user code + final String payloadV4 = "{\"value\": \"Whole grain bread\"}"; + final ODataRequestUpdate request = + new ODataRequestUpdate(SERVICE_URL, resourceV4, payloadV4, REPLACE_WITH_PUT, null, ODataProtocol.V4); + final ODataRequestResultGeneric result = request.execute(httpClient); + + // assertions + verify(putRequestedFor(urlEqualTo(SERVICE_URL + resourceV2)).withHeader(CONTENT_TYPE, containing(JSON))); + assertThat(result).isNotNull(); + } + + @Test + void deleteSimplePropertyV4() + { + stubFor(delete(WireMock.anyUrl()).willReturn(noContent())); + stubFor(head(WireMock.anyUrl()).willReturn(ok())); + + // user code + final ODataRequestDelete request = new ODataRequestDelete(SERVICE_URL, resourceV4, null, ODataProtocol.V4); + final ODataRequestResultGeneric result = request.execute(httpClient); + + // assertions + verify(deleteRequestedFor(urlEqualTo(SERVICE_URL + resourceV2))); + assertThat(result).isNotNull(); + } + + @SneakyThrows + @Test + void getStreamPropertyV4() + { + final String payloadV4 = "This is a large document"; + final byte[] payloadBytes = payloadV4.getBytes(UTF_8); + stubFor(get(WireMock.anyUrl()).willReturn(ok().withBody(payloadBytes).withHeader(CONTENT_TYPE, XML))); + + // user code + final ODataRequestReadByKey request = new ODataRequestReadByKey(SERVICE_URL, resourceV4, "", ODataProtocol.V4); + request.addHeader(ACCEPT, null); + + final ODataRequestResultGeneric result = request.execute(httpClient); + + // assertions + verify(getRequestedFor(urlEqualTo(SERVICE_URL + resourceV4))); + + final HttpEntity entity = result.getHttpResponse().getEntity(); + assertThat(entity.getContentType()).isEqualTo(XML); + assertThat(entity.getContent()).hasSameContentAs(new ByteArrayInputStream(payloadBytes)); + } + + @Test + void setStreamPropertyV4() + { + stubFor(put(WireMock.anyUrl()).willReturn(noContent())); + stubFor(head(WireMock.anyUrl()).willReturn(ok())); + + // user code + final String payloadV4 = "This is a large document"; + final ByteArrayInputStream payloadBytes = new ByteArrayInputStream(payloadV4.getBytes(UTF_8)); + final HttpEntity httpEntity = new InputStreamEntity(payloadBytes, ContentType.create(XML, UTF_8)); + + final ODataRequestResultGeneric result = + new ODataRequestUpdate(SERVICE_URL, resourceV4, httpEntity, REPLACE_WITH_PUT, null, ODataProtocol.V4) + .execute(httpClient); + + // assertions + verify( + putRequestedFor(urlEqualTo(SERVICE_URL + resourceV4)) + .withHeader(CONTENT_TYPE, equalTo("application/xml; charset=UTF-8")) + .withRequestBody(equalTo(payloadV4))); + assertThat(result).isNotNull(); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataQueryReadByKeyTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataQueryReadByKeyTest.java new file mode 100644 index 000000000..545312ee7 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataQueryReadByKeyTest.java @@ -0,0 +1,147 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.LocalDateTime; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FieldReference; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; +import com.sap.cloud.sdk.datamodel.odata.client.expression.OrderExpression; +import com.sap.cloud.sdk.datamodel.odata.client.query.Order; +import com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery; + +class ODataQueryReadByKeyTest +{ + private static final WireMockConfiguration WIREMOCK_CONFIGURATION = wireMockConfig().dynamicPort(); + private static final String SERVICE_PATH = "/service/"; + private static final String ENTITY_COLLECTION = "Entity"; + private static final ODataEntityKey ENTITY_KEY = new ODataEntityKey(ODataProtocol.V4); + /* "(" + + "stringKey='stringValue'," + + "booleanKey=true," + + "numberKey=9000," + + "durationKey=duration'PT8H'," + + "dateTimeKey=2019-12-25T08:00:00Z" + + ")";*/ + + private WireMockServer wireMockServer; + private Destination destination; + + static { + ENTITY_KEY.addKeyProperty("stringKey", "stringValue"); + ENTITY_KEY.addKeyProperty("booleanKey", true); + ENTITY_KEY.addKeyProperty("numberKey", 9000); + ENTITY_KEY.addKeyProperty("durationKey", Duration.ofHours(8)); + ENTITY_KEY.addKeyProperty("dateTimeKey", LocalDateTime.of(2019, 12, 25, 8, 0, 0)); + } + + @BeforeEach + void setup() + { + wireMockServer = new WireMockServer(WIREMOCK_CONFIGURATION); + wireMockServer.start(); + destination = DefaultHttpDestination.builder(wireMockServer.baseUrl()).build(); + } + + @AfterEach + void teardown() + { + wireMockServer.stop(); + } + + @Test + void testRequestByEntityKey() + { + final HttpClient client = ApacheHttpClient5Accessor.getHttpClient(destination); + wireMockServer + .stubFor(get(urlPathEqualTo(SERVICE_PATH + ENTITY_COLLECTION + ENTITY_KEY)).willReturn(okJson("{}"))); + + final ODataRequestReadByKey request = + new ODataRequestReadByKey(SERVICE_PATH, ENTITY_COLLECTION, ENTITY_KEY, "", ODataProtocol.V4); + + final ODataRequestResult result = request.execute(client); + assertThat(result).isNotNull(); + + wireMockServer + .verify( + getRequestedFor(urlPathEqualTo(SERVICE_PATH + ENTITY_COLLECTION + ENTITY_KEY)) + .withHeader("Accept", equalTo("application/json"))); + } + + @Test + void testQueryParameters() + { + final HttpClient client = ApacheHttpClient5Accessor.getHttpClient(destination); + wireMockServer + .stubFor(get(urlPathEqualTo(SERVICE_PATH + ENTITY_COLLECTION + ENTITY_KEY)).willReturn(okJson("{}"))); + + final String queryString = "$select=select1&$expand=expand1,expand2($select=nestedSelect;$top=10)"; + + final ODataRequestReadByKey request = + new ODataRequestReadByKey(SERVICE_PATH, ENTITY_COLLECTION, ENTITY_KEY, queryString, ODataProtocol.V4); + request.addQueryParameter("query-param1", "qp1"); + request.addQueryParameter("query-param2", "qp2"); + request.addHeader("header-key1", "hk1"); + request.addHeader("header-key2", "hk2"); + final ODataRequestResult result = request.execute(client); + assertThat(result).isNotNull(); + + wireMockServer + .verify( + getRequestedFor(urlPathEqualTo(SERVICE_PATH + ENTITY_COLLECTION + ENTITY_KEY)) + .withQueryParam("query-param1", equalTo("qp1")) + .withQueryParam("query-param2", equalTo("qp2")) + .withQueryParam("$expand", equalTo("expand1,expand2($select=nestedSelect;$top=10)")) + .withQueryParam("$select", equalTo("select1")) + .withHeader("header-key1", equalTo("hk1")) + .withHeader("header-key2", equalTo("hk2")) + .withHeader("Accept", equalTo("application/json"))); + } + + @Test + void testConstructorWithStructuredQuery() + { + final StructuredQuery structuredQuery = + StructuredQuery + .onEntity(ENTITY_COLLECTION, ODataProtocol.V4) + .filter(FieldReference.of("philosphy").equalTo("Yin & Yang")) + .orderBy(OrderExpression.of("name", Order.ASC).and("ID", Order.ASC)) + .withInlineCount(); + + final ODataRequestReadByKey expected = + new ODataRequestReadByKey( + SERVICE_PATH, + ENTITY_COLLECTION, + ENTITY_KEY, + structuredQuery.getEncodedQueryString(), + ODataProtocol.V4); + + final ODataRequestReadByKey actual = + new ODataRequestReadByKey( + SERVICE_PATH, + ODataResourcePath.of(ENTITY_COLLECTION), + ENTITY_KEY, + structuredQuery); + + assertThat(actual).isEqualTo(expected); + assertThat(actual.getQueryString()).isEqualTo(expected.getQueryString()); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataQueryTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataQueryTest.java new file mode 100644 index 000000000..5268d3375 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataQueryTest.java @@ -0,0 +1,150 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; + +class ODataQueryTest +{ + private static final String SERVICE_PATH = "/service/path"; + private static final String ENTITY_NAME = "EntityName"; + private static final String ENTITY_PATH = "/EntityName(123)"; + private static final ODataEntityKey ENTITY_KEY = new ODataEntityKey(ODataProtocol.V4).addKeyProperty("key", 123); + + @Test + void testReadAll() + { + final ODataRequestRead read = new ODataRequestRead(SERVICE_PATH, ENTITY_NAME, "foo=bar", ODataProtocol.V4); + assertThat(read.getResourcePath()).hasToString("/" + ENTITY_NAME); + assertThat(read.getServicePath()).isEqualTo(SERVICE_PATH); + assertThat(read.getRelativeUri()).hasToString("/service/path/EntityName?foo=bar"); + assertThat(read.getRequestQuery()).isEqualTo("foo=bar"); + assertThat(read.getQueryString()).isEqualTo("foo=bar"); + assertThat(read.toString()).isNotNull(); + assertThat(read).isEqualTo(new ODataRequestRead(SERVICE_PATH, ENTITY_NAME, "foo=bar", ODataProtocol.V4)); + + } + + @Test + void testDelete() + { + final ODataRequestDelete delete = + new ODataRequestDelete(SERVICE_PATH, ENTITY_NAME, ENTITY_KEY, "", ODataProtocol.V4); + + assertThat(delete.getResourcePath()).hasToString(ENTITY_PATH); + assertThat(delete.getServicePath()).isEqualTo(SERVICE_PATH); + assertThat(delete.toString()).isNotNull(); + assertThat(delete) + .isEqualTo(new ODataRequestDelete(SERVICE_PATH, ENTITY_NAME, ENTITY_KEY, "", ODataProtocol.V4)); + assertThat(delete.getHeaders()).contains(entry("Accept", Collections.singletonList("application/json"))); + } + + @Test + void testByKey() + { + final ODataRequestReadByKey byKey = + new ODataRequestReadByKey(SERVICE_PATH, ENTITY_NAME, ENTITY_KEY, "foo=bar", ODataProtocol.V4); + + assertThat(byKey.getResourcePath()).hasToString(ENTITY_PATH); + assertThat(byKey.getServicePath()).isEqualTo(SERVICE_PATH); + assertThat(byKey.getRequestQuery()).isEqualTo("foo=bar"); + assertThat(byKey.getRelativeUri()).hasToString("/service/path/EntityName(123)?foo=bar"); + assertThat(byKey.getQueryString()).isEqualTo("foo=bar"); + assertThat(byKey.toString()).isNotNull(); + assertThat(byKey) + .isEqualTo(new ODataRequestReadByKey(SERVICE_PATH, ENTITY_NAME, ENTITY_KEY, "foo=bar", ODataProtocol.V4)); + } + + @Test + void testUpdate() + { + final ODataRequestUpdate update = + new ODataRequestUpdate( + SERVICE_PATH, + ENTITY_NAME, + ENTITY_KEY, + "{\"foo\": \"bar\"}", + UpdateStrategy.MODIFY_WITH_PATCH, + "", + ODataProtocol.V4); + + assertThat(update.getResourcePath()).hasToString(ENTITY_PATH); + assertThat(update.getServicePath()).isEqualTo(SERVICE_PATH); + assertThat(update.getSerializedEntity()).isEqualTo("{\"foo\": \"bar\"}"); + assertThat(update.getUpdateStrategy()).isEqualTo(UpdateStrategy.MODIFY_WITH_PATCH); + assertThat(update.toString()).isNotNull(); + assertThat(update) + .isEqualTo( + new ODataRequestUpdate( + SERVICE_PATH, + ENTITY_NAME, + ENTITY_KEY, + "{\"foo\": \"bar\"}", + UpdateStrategy.MODIFY_WITH_PATCH, + "", + ODataProtocol.V4)); + assertThat(update) + .isNotEqualTo( + new ODataRequestUpdate( + SERVICE_PATH, + ENTITY_NAME, + ENTITY_KEY, + "{\"foo\": \"bar\"}", + UpdateStrategy.REPLACE_WITH_PUT, + "", + ODataProtocol.V4)); + + update.setUpdateStrategy(UpdateStrategy.REPLACE_WITH_PUT); + assertThat(update.getUpdateStrategy()).isEqualTo(UpdateStrategy.REPLACE_WITH_PUT); + assertThat(update.getHeaders()).contains(entry("Accept", Collections.singletonList("application/json"))); + } + + @Test + void testCreate() + { + final ODataRequestCreate byKey = + new ODataRequestCreate(SERVICE_PATH, ENTITY_NAME, "{\"foo\": \"bar\"}", ODataProtocol.V4); + + assertThat(byKey.getResourcePath()).hasToString("/" + ENTITY_NAME); + assertThat(byKey.getServicePath()).isEqualTo(SERVICE_PATH); + assertThat(byKey.getRequestQuery()).isEmpty(); + assertThat(byKey.getSerializedEntity()).isEqualTo("{\"foo\": \"bar\"}"); + assertThat(byKey.toString()).isNotNull(); + + final ODataRequestCreate compare = + new ODataRequestCreate(SERVICE_PATH, ENTITY_NAME, "{\"foo\": \"bar\"}", ODataProtocol.V4); + assertThat(byKey).isEqualTo(compare); + assertThat(compare.getHeaders()).contains(entry("Accept", Collections.singletonList("application/json"))); + } + + @Test + void testRequestsWithNullKey() + { + final ODataEntityKey nullEntityKey = new ODataEntityKey(ODataProtocol.V4).addKeyProperty("key", null); + + final ODataRequestReadByKey readByKey = + new ODataRequestReadByKey(SERVICE_PATH, ENTITY_NAME, nullEntityKey, "foo=bar", ODataProtocol.V4); + + final ODataRequestDelete deleteRequest = + new ODataRequestDelete(SERVICE_PATH, ENTITY_NAME, nullEntityKey, "", ODataProtocol.V4); + + final ODataRequestUpdate updateRequest = + new ODataRequestUpdate( + SERVICE_PATH, + ENTITY_NAME, + nullEntityKey, + "{\"foo\": \"bar\"}", + UpdateStrategy.MODIFY_WITH_PATCH, + "", + ODataProtocol.V4); + + assertThat(readByKey.getRelativeUri()).hasToString("/service/path/EntityName(null)?foo=bar"); + assertThat(deleteRequest.getRelativeUri()).hasToString("/service/path/EntityName(null)"); + assertThat(updateRequest.getRelativeUri()).hasToString("/service/path/EntityName(null)"); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataReferenceServiceTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataReferenceServiceTest.java new file mode 100644 index 000000000..c6823ffcb --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataReferenceServiceTest.java @@ -0,0 +1,61 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Disabled( "Test runs against a v4 reference service on odata.org. Use it only to manually verify behaviour." ) +class ODataReferenceServiceTest +{ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Person + { + @ElementName( "UserName" ) + @SerializedName( "UserName" ) + @JsonProperty( "UserName" ) + String userName; + + @ElementName( "UserName" ) + @SerializedName( "LastName" ) + @JsonProperty( "LastName" ) + String lastName; + + @ElementName( "UserName" ) + @SerializedName( "FirstName" ) + @JsonProperty( "FirstName" ) + String firstName; + } + + @Test + void testGetFiltered() + { + final Destination httpDestination = DefaultHttpDestination.builder("https://services.odata.org").build(); + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(httpDestination); + + final String queryString = "$top=1&$filter=(UserName%20eq%20'angelhuffman')"; + final ODataRequestRead request = + new ODataRequestRead("TripPinRESTierService", "People", queryString, ODataProtocol.V4); + final ODataRequestResultGeneric result = request.execute(httpClient); + final List persons = result.asList(Person.class); + assertThat(persons).hasSize(1); + assertThat(persons.get(0)).matches(p -> "Angel".equals(p.getFirstName()) && "Huffman".equals(p.getLastName())); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestActionTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestActionTest.java new file mode 100644 index 000000000..b692dca9c --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestActionTest.java @@ -0,0 +1,135 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.noContent; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import com.google.gson.GsonBuilder; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +//import com.sap.cloud.sdk.cloudplatform.connectivity.CsrfTokenRetriever; +//import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultCsrfTokenRetriever; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; + +class ODataRequestActionTest +{ + private static final WireMockConfiguration WIREMOCK_CONFIGURATION = wireMockConfig().dynamicPort(); + + private static final String ODATA_SERVICE_PATH = "/service/"; + private static final String ODATA_ACTION = "TestAction"; + + @RegisterExtension + static final WireMockExtension wireMockServer = + WireMockExtension.newInstance().options(WIREMOCK_CONFIGURATION).build(); + private HttpClient client; + + @BeforeEach + void setup() + { + final Destination destination = DefaultHttpDestination.builder(wireMockServer.baseUrl()).build(); + client = ApacheHttpClient5Accessor.getHttpClient(destination); + } + + @Test + void testActionWithoutParameters() + { + wireMockServer.stubFor(post(urlPathEqualTo(ODATA_SERVICE_PATH + ODATA_ACTION)).willReturn(noContent())); + + final ODataRequestAction request = + new ODataRequestAction(ODATA_SERVICE_PATH, ODATA_ACTION, null, ODataProtocol.V4); + + final ODataRequestResult result = request.execute(client); + assertThat(result).isNotNull(); + + wireMockServer + .verify( + postRequestedFor(urlPathEqualTo(ODATA_SERVICE_PATH + ODATA_ACTION)) + .withHeader("Content-Type", equalTo("application/json"))); + + // wireMockServer + // .verify( + // 1, + // headRequestedFor(urlPathEqualTo(ODATA_SERVICE_PATH)) + // .withHeader(DefaultCsrfTokenRetriever.X_CSRF_TOKEN_HEADER_KEY, equalTo("fetch"))); + } + + @Test + void testActionWithParameters() + { + wireMockServer.stubFor(post(urlPathEqualTo(ODATA_SERVICE_PATH + ODATA_ACTION)).willReturn(noContent())); + + final Map actionParameters = new LinkedHashMap<>(); + actionParameters.put("stringParameter", "test"); + actionParameters.put("booleanParameter", true); + actionParameters.put("integerParameter", 9000); + actionParameters.put("decimalParameter", 3.14d); + actionParameters.put("nullParameter", null); + final ODataRequestAction request = + new ODataRequestAction( + ODATA_SERVICE_PATH, + ODATA_ACTION, + new GsonBuilder().serializeNulls().create().toJson(actionParameters), + ODataProtocol.V4); + + final ODataRequestResult result = request.execute(client); + assertThat(result).isNotNull(); + + wireMockServer + .verify( + postRequestedFor((urlPathEqualTo(ODATA_SERVICE_PATH + ODATA_ACTION))) + .withHeader("Content-Type", equalTo("application/json"))); + + // wireMockServer + // .verify( + // 1, + // headRequestedFor(urlPathEqualTo(ODATA_SERVICE_PATH)) + // .withHeader(DefaultCsrfTokenRetriever.X_CSRF_TOKEN_HEADER_KEY, equalTo("fetch"))); + } + + @Test + void testBoundAction() + { + final ODataResourcePath actionPath = ODataResourcePath.of("Entity").addSegment(ODATA_ACTION); + + final ODataRequestAction requestAction = + new ODataRequestAction(ODATA_SERVICE_PATH, actionPath, "{}", ODataProtocol.V4); + + assertThat(requestAction.getRelativeUri()).hasToString(ODATA_SERVICE_PATH + "Entity/" + ODATA_ACTION); + } + + @Test + void testActionWithoutCsrfTokenIfSkipped() + { + wireMockServer.stubFor(post(urlPathEqualTo(ODATA_SERVICE_PATH + ODATA_ACTION)).willReturn(noContent())); + + final ODataRequestAction request = + new ODataRequestAction(ODATA_SERVICE_PATH, ODataResourcePath.of(ODATA_ACTION), null, ODataProtocol.V4); + + // request.setCsrfTokenRetriever(CsrfTokenRetriever.DISABLED_CSRF_TOKEN_RETRIEVER); + + final ODataRequestResult result = request.execute(client); + assertThat(result).isNotNull(); + + // wireMockServer + // .verify( + // 0, + // headRequestedFor(anyUrl()) + // .withHeader(DefaultCsrfTokenRetriever.X_CSRF_TOKEN_HEADER_KEY, equalToIgnoreCase("fetch"))); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestCountTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestCountTest.java new file mode 100644 index 000000000..6dfe42914 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestCountTest.java @@ -0,0 +1,95 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.assertj.core.api.Assertions.assertThat; + +import javax.annotation.Nonnull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FieldReference; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; +import com.sap.cloud.sdk.datamodel.odata.client.expression.OrderExpression; +import com.sap.cloud.sdk.datamodel.odata.client.query.Order; +import com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery; + +@WireMockTest +class ODataRequestCountTest +{ + private static final String SERVICE_PATH = "/some/path/SOME_API"; + private static final String ENTITY_NAME = "A_EntityName"; + private static final String FULL_URL = SERVICE_PATH + "/" + ENTITY_NAME + "/$count"; + private static final long EXPECTED_LONG_VALUE = 1691L; + private static final String EXPECTED_STRING_VALUE = String.valueOf(EXPECTED_LONG_VALUE); + + private Destination httpDestination; + + @BeforeEach + void before( @Nonnull final WireMockRuntimeInfo wm ) + { + httpDestination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + stubFor( + get(urlPathEqualTo(FULL_URL)) + .willReturn(ok(EXPECTED_STRING_VALUE).withHeader("Content-Type", "text/plain; charset=utf-8"))); + } + + @Test + void testCountWithV2Protocol() + { + final ODataProtocol protocol = ODataProtocol.V2; + + final ODataRequestCount requestCount = new ODataRequestCount(SERVICE_PATH, ENTITY_NAME, "", protocol); + + final ODataRequestResultGeneric result = + requestCount.execute(ApacheHttpClient5Accessor.getHttpClient(httpDestination)); + + final Long count = result.as(Long.class); + + assertThat(count).isEqualTo(EXPECTED_LONG_VALUE); + } + + @Test + void testCountWithV4Protocol() + { + final ODataProtocol protocol = ODataProtocol.V4; + + final ODataRequestCount requestCount = new ODataRequestCount(SERVICE_PATH, ENTITY_NAME, "", protocol); + + final ODataRequestResultGeneric result = + requestCount.execute(ApacheHttpClient5Accessor.getHttpClient(httpDestination)); + + final Long count = result.as(Long.class); + + assertThat(count).isEqualTo(EXPECTED_LONG_VALUE); + } + + @Test + void testConstructorWithStructuredQuery() + { + final StructuredQuery structuredQuery = + StructuredQuery + .onEntity(ENTITY_NAME, ODataProtocol.V4) + .filter(FieldReference.of("philosphy").equalTo("Yin & Yang")) + .orderBy(OrderExpression.of("name", Order.ASC).and("ID", Order.ASC)) + .withInlineCount(); + + final ODataRequestCount expected = + new ODataRequestCount(SERVICE_PATH, ENTITY_NAME, structuredQuery.getEncodedQueryString(), ODataProtocol.V4); + + final ODataRequestCount actual = new ODataRequestCount(SERVICE_PATH, new ODataResourcePath(), structuredQuery); + + assertThat(actual).isEqualTo(expected); + assertThat(actual.getQueryString()).isEqualTo(expected.getQueryString()); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestFunctionTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestFunctionTest.java new file mode 100644 index 000000000..e4a224f2b --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestFunctionTest.java @@ -0,0 +1,206 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.LocalDateTime; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FieldReference; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; +import com.sap.cloud.sdk.datamodel.odata.client.expression.OrderExpression; +import com.sap.cloud.sdk.datamodel.odata.client.query.Order; +import com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery; + +class ODataRequestFunctionTest +{ + private static final WireMockConfiguration WIREMOCK_CONFIGURATION = wireMockConfig().dynamicPort(); + + private static final String ODATA_SERVICE_PATH = "/service/"; + private static final String ODATA_FUNCTION = "TestFunction"; + private static final String ODATA_FUNCTION_PARAMETERS_V2 = """ + ?\ + stringParameter='foo/bar'&\ + booleanParameter=true&\ + integerParameter=9000&\ + doubleParameter=3.14d&\ + nullParameter=null&\ + durationParameter=duration'PT8H'&\ + dateTimeParameter=datetime'2019-12-25T08:00:00'\ + """; + + private static final String ODATA_FUNCTION_PARAMETERS_V4 = """ + (\ + stringParameter='foo%2Fbar',\ + booleanParameter=true,\ + integerParameter=9000,\ + doubleParameter=3.14,\ + nullParameter=null,\ + durationParameter=duration'PT8H',\ + dateTimeParameter=2019-12-25T08:00:00Z\ + )\ + """; + + private WireMockServer wireMockServer; + private HttpClient client; + + @BeforeEach + void setup() + { + wireMockServer = new WireMockServer(WIREMOCK_CONFIGURATION); + wireMockServer.start(); + final Destination destination = DefaultHttpDestination.builder(wireMockServer.baseUrl()).build(); + client = ApacheHttpClient5Accessor.getHttpClient(destination); + } + + @AfterEach + void teardown() + { + wireMockServer.stop(); + } + + @Test + void testFunctionWithoutParameters() + { + wireMockServer.stubFor(get(anyUrl()).willReturn(okJson("{}"))); + + final ODataRequestFunction request = + new ODataRequestFunction( + ODATA_SERVICE_PATH, + ODATA_FUNCTION, + ODataFunctionParameters.empty(ODataProtocol.V4), + ODataProtocol.V4); + + final ODataRequestResult result = request.execute(client); + assertThat(result).isNotNull(); + + wireMockServer + .verify( + getRequestedFor(urlPathEqualTo(ODATA_SERVICE_PATH + ODATA_FUNCTION + "()")) + .withHeader("Accept", equalTo("application/json"))); + } + + @Test + void testFunctionWithParametersV2() + { + wireMockServer.stubFor(get(anyUrl()).willReturn(okJson("{}"))); + + final ODataFunctionParameters functionParameters = new ODataFunctionParameters(ODataProtocol.V2); + functionParameters.addParameter("stringParameter", "foo/bar"); + functionParameters.addParameter("booleanParameter", true); + functionParameters.addParameter("integerParameter", 9000); + functionParameters.addParameter("doubleParameter", 3.14d); + functionParameters.addParameter("nullParameter", null); + functionParameters.addParameter("durationParameter", Duration.ofHours(8)); + functionParameters.addParameter("dateTimeParameter", LocalDateTime.of(2019, 12, 25, 8, 0, 0)); + + final ODataRequestFunction request = + new ODataRequestFunction(ODATA_SERVICE_PATH, ODATA_FUNCTION, functionParameters, ODataProtocol.V2); + + final ODataRequestResult result = request.execute(client); + assertThat(result).isNotNull(); + + wireMockServer + .verify( + getRequestedFor(urlEqualTo(ODATA_SERVICE_PATH + ODATA_FUNCTION + ODATA_FUNCTION_PARAMETERS_V2)) + .withHeader("Accept", equalTo("application/json"))); + } + + @Test + void testFunctionWithParametersV4() + { + wireMockServer.stubFor(get(anyUrl()).willReturn(okJson("{}"))); + + final ODataFunctionParameters functionParameters = new ODataFunctionParameters(ODataProtocol.V4); + functionParameters.addParameter("stringParameter", "foo/bar"); + functionParameters.addParameter("booleanParameter", true); + functionParameters.addParameter("integerParameter", 9000); + functionParameters.addParameter("doubleParameter", 3.14d); + functionParameters.addParameter("nullParameter", null); + functionParameters.addParameter("durationParameter", Duration.ofHours(8)); + functionParameters.addParameter("dateTimeParameter", LocalDateTime.of(2019, 12, 25, 8, 0, 0)); + + final ODataRequestFunction request = + new ODataRequestFunction(ODATA_SERVICE_PATH, ODATA_FUNCTION, functionParameters, ODataProtocol.V4); + + final ODataRequestResult result = request.execute(client); + assertThat(result).isNotNull(); + + wireMockServer + .verify( + getRequestedFor(urlEqualTo(ODATA_SERVICE_PATH + ODATA_FUNCTION + ODATA_FUNCTION_PARAMETERS_V4)) + .withHeader("Accept", equalTo("application/json"))); + } + + @Test + void testAutomaticParameterHandling() + { + final ODataFunctionParameters parametersV2 = + new ODataFunctionParameters(ODataProtocol.V2).addParameter("key", "val"); + final ODataFunctionParameters parametersV4 = + new ODataFunctionParameters(ODataProtocol.V4).addParameter("key", "val"); + final String customQuery = "$foo=bar"; + final ODataResourcePath functionPath = ODataResourcePath.of("function"); + + final ODataRequestFunction functionWithoutQueryV2 = + new ODataRequestFunction("/path", functionPath, parametersV2, null, ODataProtocol.V2); + final ODataRequestFunction functionWithQueryV2 = + new ODataRequestFunction("/path", functionPath, parametersV2, customQuery, ODataProtocol.V2); + final ODataRequestFunction functionWithoutQueryV4 = + new ODataRequestFunction("/path", functionPath, parametersV4, null, ODataProtocol.V4); + final ODataRequestFunction functionWithQueryV4 = + new ODataRequestFunction("/path", functionPath, parametersV4, customQuery, ODataProtocol.V4); + + assertThat(functionWithoutQueryV2.getRelativeUri()).hasToString("/path/function?key='val'"); + assertThat(functionWithQueryV2.getRelativeUri()).hasToString("/path/function?key='val'&$foo=bar"); + assertThat(functionWithoutQueryV4.getRelativeUri()).hasToString("/path/function(key='val')"); + assertThat(functionWithQueryV4.getRelativeUri()).hasToString("/path/function(key='val')?$foo=bar"); + } + + @Test + void testConstructorWithStructuredQuery() + { + final StructuredQuery structuredQuery = + StructuredQuery + .onEntity("Authors", ODataProtocol.V4) + .filter(FieldReference.of("philosphy").equalTo("Yin & Yang")) + .orderBy(OrderExpression.of("name", Order.ASC).and("ID", Order.ASC)) + .withInlineCount(); + + final ODataRequestFunction expected = + new ODataRequestFunction( + ODATA_SERVICE_PATH, + ODataResourcePath.of(ODATA_FUNCTION).addSegment(structuredQuery.getEntityOrPropertyName()), + structuredQuery.getEncodedQueryString(), + ODataProtocol.V4); + + final ODataRequestFunction actual = + new ODataRequestFunction(ODATA_SERVICE_PATH, ODataResourcePath.of(ODATA_FUNCTION), structuredQuery); + + assertThat(actual).isEqualTo(expected); + assertThat(actual.getRequestQuery()).isEqualTo(expected.getRequestQuery()); + assertThat(actual.getRelativeUri()) + .hasPath("/service/TestFunction/Authors") + .hasParameter("$filter", "(philosphy eq 'Yin & Yang')") + .hasParameter("$orderby", "name asc,ID asc") + .hasParameter("$count", "true"); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestReadTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestReadTest.java new file mode 100644 index 000000000..69c6fc24b --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestReadTest.java @@ -0,0 +1,309 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.google.common.net.UrlEscapers; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FieldReference; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; +import com.sap.cloud.sdk.datamodel.odata.client.expression.OrderExpression; +import com.sap.cloud.sdk.datamodel.odata.client.query.Order; +import com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery; + +class ODataRequestReadTest +{ + private static final WireMockConfiguration WIREMOCK_CONFIGURATION = wireMockConfig().dynamicPort(); + private static final String ODATA_SERVICE_PATH = "/service/"; + private static final String ODATA_ENTITY_COLLECTION = "Entity"; + + private WireMockServer wireMockServer; + private Destination destination; + + @BeforeEach + void setup() + { + wireMockServer = new WireMockServer(WIREMOCK_CONFIGURATION); + wireMockServer.start(); + destination = DefaultHttpDestination.builder(wireMockServer.baseUrl()).build(); + } + + @AfterEach + void teardown() + { + wireMockServer.stop(); + } + + @Test + void testQueryParameters() + { + final HttpClient client = ApacheHttpClient5Accessor.getHttpClient(destination); + wireMockServer + .stubFor(get(urlPathEqualTo(ODATA_SERVICE_PATH + ODATA_ENTITY_COLLECTION)).willReturn(okJson("{}"))); + + final String queryString = "$select=select1&$expand=expand1,expand2($select=nestedSelect;$top=10)&$top=1"; + + final ODataRequestRead request = + new ODataRequestRead(ODATA_SERVICE_PATH, ODATA_ENTITY_COLLECTION, queryString, ODataProtocol.V4); + request.addQueryParameter("query-param1", "qp1"); + request.addQueryParameter("query-param2", "qp2"); + request.addHeader("header-key1", "hk1"); + // two headers with the same key + request.addHeader("header-key1", "hk2"); + request.addHeader("header-key2", "hk2"); + final ODataRequestResult result = request.execute(client); + assertThat(result).isNotNull(); + + wireMockServer + .verify( + getRequestedFor(urlPathEqualTo(ODATA_SERVICE_PATH + ODATA_ENTITY_COLLECTION)) + .withQueryParam("query-param1", equalTo("qp1")) + .withQueryParam("query-param2", equalTo("qp2")) + .withQueryParam("$expand", equalTo("expand1,expand2($select=nestedSelect;$top=10)")) + .withQueryParam("$select", equalTo("select1")) + .withQueryParam("$top", equalTo("1")) + .withHeader("header-key1", equalTo("hk1")) + // two headers with the same key + .withHeader("header-key1", equalTo("hk2")) + .withHeader("header-key2", equalTo("hk2")) + .withHeader("Accept", equalTo("application/json"))); + } + + @Test + void testV4QueryExpand() + { + final StructuredQuery query = StructuredQuery.onEntity("Movies", ODataProtocol.V4); + final StructuredQuery subQuery1 = StructuredQuery.asNestedQueryOnProperty("relatedBook", ODataProtocol.V4); + final StructuredQuery subQuery2 = StructuredQuery.asNestedQueryOnProperty("relatedMovies", ODataProtocol.V4); + final StructuredQuery subQuery3 = StructuredQuery.asNestedQueryOnProperty("relatedBook", ODataProtocol.V4); + subQuery2.select(subQuery3); + subQuery1.select(subQuery2); + query.select(subQuery1); + assertThat(query.getQueryString()).isEqualTo("$expand=relatedBook($expand=relatedMovies($expand=relatedBook))"); + } + + @Test + void testV4QuerySelectAndFilter() + { + final StructuredQuery query = StructuredQuery.onEntity("Movies", ODataProtocol.V4); + final StructuredQuery subQuery1 = StructuredQuery.asNestedQueryOnProperty("relatedBook", ODataProtocol.V4); + final StructuredQuery subQuery2 = StructuredQuery.asNestedQueryOnProperty("relatedMovies", ODataProtocol.V4); + subQuery1.select(subQuery2); + query.select(subQuery1); + + subQuery2.select("Director", "Producer"); + subQuery2.filter(FieldReference.of("Duration").greaterThan(3600)); + query.filter(FieldReference.ofPath("relatedBook", "Name").equalTo("Forrest Gump")); + + assertThat(query.getQueryString().split("&")) + .containsExactly( + "$expand=relatedBook($expand=relatedMovies($select=Director,Producer;$filter=(Duration gt 3600)))", + "$filter=(relatedBook/Name eq 'Forrest Gump')"); + + assertThat(query.getEncodedQueryString().split("&")) + .containsExactly( + "$expand=relatedBook($expand=relatedMovies($select=Director,Producer;$filter=(Duration%20gt%203600)))", + "$filter=(relatedBook/Name%20eq%20'Forrest%20Gump')"); + } + + @Test + void testV2QueryExpand() + { + final StructuredQuery query = StructuredQuery.onEntity("Movies", ODataProtocol.V2); + final StructuredQuery subQuery1 = StructuredQuery.asNestedQueryOnProperty("relatedBook", ODataProtocol.V2); + final StructuredQuery subQuery2 = StructuredQuery.asNestedQueryOnProperty("relatedMovies", ODataProtocol.V2); + final StructuredQuery subQuery3 = StructuredQuery.asNestedQueryOnProperty("relatedBook", ODataProtocol.V2); + subQuery2.select(subQuery3); + subQuery1.select(subQuery2); + query.select(subQuery1); + assertThat(query.getQueryString()) + .isEqualTo("$expand=relatedBook,relatedBook/relatedMovies,relatedBook/relatedMovies/relatedBook"); + /* + * note: The query string above is semantically equivalent to this one without fully qualified properties: + * $expand=relatedBook/relatedMovies/relatedBook + */ + } + + @Test + void testV2QuerySelectAndFilter() + { + final StructuredQuery query = StructuredQuery.onEntity("Movies", ODataProtocol.V2); + final StructuredQuery subQuery1 = StructuredQuery.asNestedQueryOnProperty("relatedBook", ODataProtocol.V2); + final StructuredQuery subQuery2 = StructuredQuery.asNestedQueryOnProperty("relatedMovies", ODataProtocol.V2); + subQuery1.select(subQuery2); + query.select(subQuery1); + + subQuery2.select("Director", "Producer"); + subQuery2.filter(FieldReference.of("Duration").greaterThan(3600)); + query.filter(FieldReference.ofPath("relatedBook", "Name").equalTo("Forrest Gump")); + + assertThat(query.getQueryString().split("&")) + .containsExactly( + "$select=relatedBook/relatedMovies/Director,relatedBook/relatedMovies/Producer", + "$expand=relatedBook,relatedBook/relatedMovies", + "$filter=(relatedBook/Name eq 'Forrest Gump')"); + + assertThat(query.getEncodedQueryString().split("&")) + .containsExactly( + "$select=relatedBook/relatedMovies/Director,relatedBook/relatedMovies/Producer", + "$expand=relatedBook,relatedBook/relatedMovies", + "$filter=(relatedBook/Name%20eq%20'Forrest%20Gump')"); + } + + @Test + void testExceptionWhenUnencodedQueryString() + { + final String servicePath = "/odata/v4/Service/"; + final String entityName = "Authors"; + + final String unencodedQuery = "$orderby=name asc,ID"; + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy( + () -> new ODataRequestRead(servicePath, entityName, unencodedQuery, ODataProtocol.V4).getRelativeUri()); + } + + @Test + void testGuavaUrlEscaperEscapedQueryString() + { + final String servicePath = "/odata/v4/Service/"; + final String entityName = "Authors"; + + final String unencodedQuery = "$orderby=name asc,ID"; + + final String encodedQuery = UrlEscapers.urlFragmentEscaper().escape(unencodedQuery); + + final URI relativeUri = + new ODataRequestRead(servicePath, entityName, encodedQuery, ODataProtocol.V4).getRelativeUri(); + + final String expectedUri = "/odata/v4/Service/Authors?$orderby=name%20asc,ID"; + + assertThat(relativeUri.toString()).isEqualTo(expectedUri); + } + + @Test + void testBuildQueryStringWithStructuredQueryV2() + { + final String servicePath = "/odata/v2/Service/"; + final String entityName = "Authors"; + + final StructuredQuery structuredQuery = + StructuredQuery + .onEntity(entityName, ODataProtocol.V2) + .filter(FieldReference.of("philosphy").equalTo("Yin & Yang")) + .orderBy(OrderExpression.of("name", Order.ASC).and("ID", Order.ASC)) + .withInlineCount(); + + final String encodedQuery = structuredQuery.getEncodedQueryString(); + + final URI relativeUri = + new ODataRequestRead(servicePath, entityName, encodedQuery, ODataProtocol.V2).getRelativeUri(); + + final String expectedUri = + "/odata/v2/Service/Authors?$filter=(philosphy%20eq%20'Yin%20%26%20Yang')&$orderby=name%20asc,ID%20asc&$inlinecount=allpages"; + + assertThat(relativeUri.toString()).isEqualTo(expectedUri); + } + + @Test + void testBuildQueryStringWithStructuredQueryV4() + { + final String servicePath = "/odata/v4/Service/"; + final String entityName = "Authors"; + + final StructuredQuery structuredQuery = + StructuredQuery + .onEntity(entityName, ODataProtocol.V4) + .filter(FieldReference.of("philosphy").equalTo("Yin & Yang")) + .orderBy(OrderExpression.of("name", Order.ASC).and("ID", Order.ASC)) + .withInlineCount(); + + final String encodedQuery = structuredQuery.getEncodedQueryString(); + + final URI relativeUri = + new ODataRequestRead(servicePath, entityName, encodedQuery, ODataProtocol.V4).getRelativeUri(); + + final String expectedUri = + "/odata/v4/Service/Authors?$filter=(philosphy%20eq%20'Yin%20%26%20Yang')&$orderby=name%20asc,ID%20asc&$count=true"; + + assertThat(relativeUri.toString()).isEqualTo(expectedUri); + } + + @Test + void testCustomParameterswithStructuredQuery() + { + final String entityName = "Authors"; + final String customKey = "$key"; + final String customValue = "$value"; + + final StructuredQuery structuredQuery = + StructuredQuery + .onEntity(entityName, ODataProtocol.V4) + .filter(FieldReference.of("philosophy").equalTo("Yin & Yang")) + .withCustomParameter(customKey, customValue); + + final String actualQuery = structuredQuery.getEncodedQueryString(); + final String expectedQuery = "$filter=(philosophy%20eq%20'Yin%20%26%20Yang')&$key=%24value"; + + assertThat(actualQuery).isEqualTo(expectedQuery); + } + + @Test + void testCustomParametersOnNestedQuery() + { + final StructuredQuery query = StructuredQuery.asNestedQueryOnProperty("name", ODataProtocol.V4); + + assertThatThrownBy(() -> query.withCustomParameter("key", "value")).isInstanceOf(IllegalStateException.class); + } + + @Test + void testCustomParametersWithEmptyKey() + { + final StructuredQuery query = StructuredQuery.onEntity("name", ODataProtocol.V4); + + assertThatThrownBy(() -> query.withCustomParameter("", "value")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testConstructorWithStructuredQuery() + { + final StructuredQuery structuredQuery = + StructuredQuery + .onEntity("Authors", ODataProtocol.V4) + .filter(FieldReference.of("philosphy").equalTo("Yin & Yang")) + .orderBy(OrderExpression.of("name", Order.ASC).and("ID", Order.ASC)) + .withInlineCount(); + + final ODataRequestRead expected = + new ODataRequestRead( + "/some/service/path", + "Authors", + structuredQuery.getEncodedQueryString(), + ODataProtocol.V4); + + final ODataRequestRead actual = + new ODataRequestRead("/some/service/path", new ODataResourcePath(), structuredQuery); + + assertThat(actual).isEqualTo(expected); + assertThat(actual.getQueryString()).isEqualTo(expected.getQueryString()); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultGenericTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultGenericTest.java new file mode 100644 index 000000000..df2f2246c --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultGenericTest.java @@ -0,0 +1,203 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.entry; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.SocketException; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.io.entity.InputStreamEntity; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.apache.hc.core5.http.message.BasicHeader; +import org.apache.hc.core5.http.message.StatusLine; +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataDeserializationException; + +import lombok.SneakyThrows; + +class ODataRequestResultGenericTest +{ + private static final Header[] headerWithEtag = { new BasicHeader("ETag", "value1") }; + private static final Header[] headerWithTwoEtags = + { new BasicHeader("ETag", "value1"), new BasicHeader("ETag", "value2") }; + private static final Header[] headerWithEmptyEtag = { new BasicHeader("ETag", "") }; + private static final Header[] headerWithNullEtag = { new BasicHeader("ETag", null) }; + private static final Header[] headerWithoutEtag = {}; + + @Test + void testETagHeaderExtraction() + { + final ODataRequestGeneric mockClientRequest = mock(ODataRequestGeneric.class); + when(mockClientRequest.getProtocol()).thenReturn(mock(ODataProtocol.class)); + + final BasicClassicHttpResponse httpResponse = new BasicClassicHttpResponse(200, "Ok"); + final ODataRequestResultGeneric mockResult = new ODataRequestResultGeneric(mockClientRequest, httpResponse); + + assertSoftly(softly -> { + httpResponse.setHeaders(headerWithEtag); + softly.assertThat(mockResult.getVersionIdentifierFromHeader().get()).isEqualTo("value1"); + + httpResponse.setHeaders(headerWithTwoEtags); + softly.assertThat(mockResult.getVersionIdentifierFromHeader().get()).isEqualTo("value1"); + + httpResponse.setHeaders(headerWithEmptyEtag); + softly.assertThat(mockResult.getVersionIdentifierFromHeader()).isEmpty(); + + httpResponse.setHeaders(headerWithNullEtag); + softly.assertThat(mockResult.getVersionIdentifierFromHeader()).isEmpty(); + + httpResponse.setHeaders(headerWithoutEtag); + softly.assertThat(mockResult.getVersionIdentifierFromHeader()).isEmpty(); + }); + } + + @Test + void testEmptyPayload() + { + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + doReturn(ODataProtocol.V2).when(request).getProtocol(); + + final ClassicHttpResponse mockHttpResponse = new BasicClassicHttpResponse(200, ""); + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, mockHttpResponse); + + assertThatThrownBy(() -> result.as(ODataRequestResultGenericTest.class)) + .isInstanceOf(ODataDeserializationException.class) + .hasMessage("OData 2.0 response did not contain any payload."); + } + + @SneakyThrows + @Test + void testBrokenHttpEntity() + { + // mock request + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + doReturn(ODataProtocol.V2).when(request).getProtocol(); + + // mock broken HTTP entity + final HttpEntity httpEntity = mock(HttpEntity.class); + doReturn(1L).when(httpEntity).getContentLength(); + doReturn(false).when(httpEntity).isRepeatable(); + doThrow(SocketException.class).when(httpEntity).getContent(); + doThrow(SocketException.class).when(httpEntity).writeTo(any()); + + // create HTTP response + final BasicClassicHttpResponse httpResponse = new BasicClassicHttpResponse(200, ""); + httpResponse.setEntity(httpEntity); + + // create OData response + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, httpResponse); + + // provoke exception by internally parsing the HTTP response + assertThatThrownBy(() -> result.as(ODataRequestResultGenericTest.class)) + .isInstanceOf(ODataDeserializationException.class) + .hasMessageContaining("OData 2.0 response stream cannot be read for HTTP entity") + .hasCauseInstanceOf(SocketException.class); + } + + @Test + void getHeaderValuesShouldHandleKeyInsensitivity() + { + final ODataRequestResult result = mock(ODataRequestResult.class); + final ClassicHttpResponse mockedResponse = mock(ClassicHttpResponse.class); + when(mockedResponse.getHeaders()) + .thenReturn( + new BasicHeader[] { + new BasicHeader("CoNtEnT-TyPe", "someType"), + new BasicHeader("someKey", "someValue"), + new BasicHeader("someOtherKey", "someOtherValue") }); + + when(result.getHttpResponse()).thenReturn(mockedResponse); + when(result.getAllHeaderValues()).thenCallRealMethod(); + when(result.getHeaderValues(any())).thenCallRealMethod(); + + assertThat(result.getHeaderValues("content-type")).containsExactly("someType"); + assertThat(result.getHeaderValues("Content-Type")).containsExactly("someType"); + + assertThat(result.getHeaderValues("someKey")).containsExactly("someValue"); + assertThat(result.getHeaderValues("SOMEkey")).containsExactly("someValue"); + + assertThat(result.getHeaderValues("someOtherKey")).containsExactly("someOtherValue"); + assertThat(result.getHeaderValues("SOMEotherKEY")).containsExactly("someOtherValue"); + } + + @Test + @SneakyThrows + void ensureNoRedundantHeadersForPaginatedRequests() + { + final ODataRequestGeneric oDataRequest = + new ODataRequestRead("generic/service/path", "entity(123)", null, ODataProtocol.V4); + + final BasicClassicHttpResponse httpResponse = new BasicClassicHttpResponse(200, "OK"); + final String json = "{\"value\":[],\"@odata.nextLink\": \"Foo?$count=true&$select=BarID&$skiptoken='ABCD'\"}"; + httpResponse.setEntity(new StringEntity(json)); + + final HttpClient httpClient = mock(HttpClient.class); + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(httpResponse); + + final ODataRequestResultGeneric testResult = + new ODataRequestResultGeneric(oDataRequest, httpResponse, httpClient); + + ODataRequestResultGeneric nextResult = testResult.tryGetNextPage().get(); + nextResult = nextResult.tryGetNextPage().get(); + nextResult = nextResult.tryGetNextPage().get(); + nextResult = nextResult.tryGetNextPage().get(); + nextResult = nextResult.tryGetNextPage().get(); + nextResult = nextResult.tryGetNextPage().get(); + + final Map> lastRequestHeaders = nextResult.getODataRequest().getHeaders(); + assertThat(lastRequestHeaders).containsExactly(entry("Accept", Collections.singletonList("application/json"))); + } + + @Test + @SneakyThrows + void testDisabledBuffer() + { + // test setup for request + final ODataRequestGeneric oDataRequest = + new ODataRequestRead("generic/service/path", "entity(123)", null, ODataProtocol.V4); + + // test setup for streamed http response + final BasicClassicHttpResponse httpResponse = new BasicClassicHttpResponse(200, "OK"); + final String json = "{\"value\":[]}"; + final InputStream inputStream = new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)); + httpResponse.setEntity(new InputStreamEntity(inputStream, json.length(), ContentType.APPLICATION_JSON)); + + // system under test + try( + final ODataRequestResultResource testResult = + new ODataRequestResultResource(oDataRequest, httpResponse, null) ) { + // sanity checks do not consume the response + assertThat(testResult.getHeaderValues("Content-Length")).isEmpty(); + assertThat(new StatusLine(testResult.getHttpResponse()).getStatusCode()).isEqualTo(200); + + // true-positive, successfully read once + assertThat(testResult.asListOfMaps()).isEmpty(); + + // true-negative, no second read possible + assertThatThrownBy(testResult::asListOfMaps) + .isInstanceOf(ODataDeserializationException.class) + .hasMessageContaining("Unable to read OData 4.0 response."); + } + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultTest.java new file mode 100644 index 000000000..e3617dad4 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultTest.java @@ -0,0 +1,104 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.api.Test; + +class ODataRequestResultTest +{ + @Test + void testGetAllHeaderHeaderValuesRemovesNullValues() + { + final BasicClassicHttpResponse httpResponse = + mockResponseWithHeaders(entry("Header", Arrays.asList("Value", null, " ", null))); + + final ODataRequestResult sut = mock(ODataRequestResult.class); + when(sut.getHttpResponse()).thenReturn(httpResponse); + when(sut.getAllHeaderValues()).thenCallRealMethod(); + + final Map> actual = sut.getAllHeaderValues(); + + assertThat(actual).containsExactly(entry("Header", Arrays.asList("Value", " "))); + } + + @Test + void testGetAllHeaderValuesDoesNotSplitValues() + { + final BasicClassicHttpResponse httpResponse = + mockResponseWithHeaders(entry("Header", Collections.singletonList("Value1-1, Value1-2"))); + + final ODataRequestResult sut = mock(ODataRequestResult.class); + when(sut.getHttpResponse()).thenReturn(httpResponse); + when(sut.getAllHeaderValues()).thenCallRealMethod(); + + final Map> actual = sut.getAllHeaderValues(); + + assertThat(actual).containsExactly(entry("Header", Collections.singletonList("Value1-1, Value1-2"))); + } + + @Test + void testGetAllHeaderValuesDoesNotSplitCookieValues() + { + final BasicClassicHttpResponse httpResponse = + mockResponseWithHeaders(entry("Set-Cookie", Collections.singletonList("Value1-1; Value1-2"))); + + final ODataRequestResult sut = mock(ODataRequestResult.class); + when(sut.getHttpResponse()).thenReturn(httpResponse); + when(sut.getAllHeaderValues()).thenCallRealMethod(); + + final Map> actual = sut.getAllHeaderValues(); + + assertThat(actual).containsExactly(entry("Set-Cookie", Collections.singletonList("Value1-1; Value1-2"))); + } + + @Test + void testGetAllHeaderValuesMergesNamesCaseInsensitively() + { + final BasicClassicHttpResponse httpResponse = + mockResponseWithHeaders( + entry("Header", Collections.singletonList("Value1-1")), + entry("header", Collections.singletonList("Value1-2"))); + + final ODataRequestResult sut = mock(ODataRequestResult.class); + when(sut.getHttpResponse()).thenReturn(httpResponse); + when(sut.getAllHeaderValues()).thenCallRealMethod(); + + final Map> actual = sut.getAllHeaderValues(); + + assertThat(actual).containsExactly(entry("Header", Arrays.asList("Value1-1", "Value1-2"))); + } + + @SafeVarargs + @Nonnull + private static BasicClassicHttpResponse mockResponseWithHeaders( + @Nonnull final Map.Entry>... entries ) + { + final Collection
headers = new ArrayList<>(); + for( final Map.Entry> entry : entries ) { + for( final String value : entry.getValue() ) { + final Header header = mock(Header.class); + when(header.getName()).thenReturn(entry.getKey()); + when(header.getValue()).thenReturn(value); + + headers.add(header); + } + } + final BasicClassicHttpResponse response = mock(BasicClassicHttpResponse.class); + when(response.getHeaders()).thenReturn(headers.toArray(new Header[0])); + + return response; + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataResponseComplexDataParsingTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataResponseComplexDataParsingTest.java new file mode 100644 index 000000000..36e8be008 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataResponseComplexDataParsingTest.java @@ -0,0 +1,449 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.io.entity.BufferedHttpEntity; +import org.apache.hc.core5.http.message.StatusLine; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.cloudplatform.exception.ShouldNotHappenException; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.result.ElementName; +import com.sap.cloud.sdk.result.ResultElement; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +class ODataResponseComplexDataParsingTest +{ + @Data + @NoArgsConstructor + @RequiredArgsConstructor + @AllArgsConstructor + public static class Person + { + @ElementName( "UserName" ) + @SerializedName( "UserName" ) + @JsonProperty( "UserName" ) + @Nonnull + String userName; + + @ElementName( "LastName" ) + @SerializedName( "LastName" ) + @JsonProperty( "LastName" ) + @Nonnull + String lastName; + + @ElementName( "FirstName" ) + @SerializedName( "FirstName" ) + @JsonProperty( "FirstName" ) + @Nonnull + String firstName; + + @ElementName( "AddressInfo" ) + @SerializedName( "AddressInfo" ) + @JsonProperty( "AddressInfo" ) + @Nonnull + List addressInfo; + + @ElementName( "Friends" ) + @SerializedName( "Friends" ) + @JsonProperty( "Friends" ) + @Nullable + List friends; + + @ElementName( "BestFriend" ) + @SerializedName( "BestFriend" ) + @JsonProperty( "BestFriend" ) + @Nullable + Person bestFriend; + + private static final String PAYLOAD_SAMPLE_SET = """ + { + "@odata.context": "serviceRoot/$metadata#People", + "@odata.nextLink": "serviceRoot/People?%24skiptoken=8", + "value": [ + { + "@odata.id": "serviceRoot/People('jackblack')", + "@odata.etag": "W/\\"08D1694BD49A0F11\\"", + "@odata.editLink": "serviceRoot/People('jackblack')", + "UserName": "jackblack", + "FirstName": "Jack", + "LastName": "Black", + "AddressInfo": [ + { + "Address": "187 Suffolk Ln.", + "City": { + "Name": "Boise", + "CountryRegion": "United States", + "Region": "ID" + } + } + ] + }, + { + "@odata.id": "serviceRoot/People('kylegass')", + "@odata.etag": "W/\\"08D1694BD49A0F11\\"", + "@odata.editLink": "serviceRoot/People('kylegass')", + "UserName": "kylegass", + "FirstName": "Kyle", + "LastName": "Gass", + "AddressInfo": [ + { + "Address": "187 Suffolk Ln.", + "City": { + "Name": "Boise", + "CountryRegion": "United States", + "Region": "ID" + } + } + ] + } + ] + } + """; + + private static final String PAYLOAD_SAMPLE_ENTITY = + """ + { + "@odata.context": "https://services.odata.org/TripPinRESTierService/(S(3mslpb2bc0k5ufk24olpghzx))/$metadata#People/$entity", + "UserName": "jackblack", + "FirstName": "Jack", + "LastName": "Black", + "AddressInfo": [ + { + "Address": "187 Suffolk Ln.", + "City": { + "Name": "Boise", + "CountryRegion": "United States", + "Region": "ID" + } + } + ], + "HomeAddress": null + } + """; + + private static final String PAYLOAD_SAMPLE_ENTITY_WITH_EXPANDED_NAVIGATION_PROPERTY = + """ + { + "@odata.context": "https://services.odata.org/TripPinRESTierService/(S(3ubj4rnppjfo1oflxryca1e2))/$metadata#People", + "UserName": "russellwhyte", + "FirstName": "Russell", + "LastName": "Whyte", + "AddressInfo": [ + { + "Address": "187 Suffolk Ln.", + "City": { + "Name": "Boise", + "CountryRegion": "United States", + "Region": "ID" + } + } + ], + "HomeAddress": null, + "Friends": [ + { + "UserName": "scottketchum", + "FirstName": "Scott", + "LastName": "Ketchum", + "AddressInfo": [ + { + "Address": "2817 Milton Dr.", + "City": { + "Name": "Albuquerque", + "CountryRegion": "United States", + "Region": "NM" + } + } + ], + "HomeAddress": null + }, + { + "UserName": "ronaldmundy", + "FirstName": "Ronald", + "LastName": "Mundy", + "AddressInfo": [ + { + "Address": "187 Suffolk Ln.", + "City": { + "Name": "Boise", + "CountryRegion": "United States", + "Region": "ID" + } + } + ], + "HomeAddress": null + } + ] + } + """; + + private static final String PAYLOAD_ENTITY_WITH_BINARY_NAVIGATION_PROPERTY = """ + { + "UserName": "russellwhyte", + "FirstName": "Russell", + "LastName": "Whyte", + "AddressInfo": [ + { + "Address": "187 Suffolk Ln.", + "City": { + "Name": "Boise", + "CountryRegion": "United States", + "Region": "ID" + } + } + ], + "HomeAddress": null, + "BestFriend": { + "UserName": "scottketchum", + "FirstName": "Scott", + "LastName": "Ketchum", + "AddressInfo": [ + { + "Address": "2817 Milton Dr.", + "City": { + "Name": "Albuquerque", + "CountryRegion": "United States", + "Region": "NM" + } + } + ], + "HomeAddress": null + } + } + """; + + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @ToString + public static class AddressInfo + { + + @ElementName( "Address" ) + @SerializedName( "Address" ) + @JsonProperty( "Address" ) + String address; + + @ElementName( "City" ) + @SerializedName( "City" ) + @JsonProperty( "City" ) + City city; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @ToString + public static class City + { + + @ElementName( "Name" ) + @SerializedName( "Name" ) + @JsonProperty( "Name" ) + String name; + + @ElementName( "CountryRegion" ) + @SerializedName( "CountryRegion" ) + @JsonProperty( "CountryRegion" ) + String countryRegion; + + @ElementName( "Region" ) + @SerializedName( "Region" ) + @JsonProperty( "Region" ) + String region; + } + } + + private final Person jackBlack = + new Person( + "jackblack", + "Black", + "Jack", + Arrays.asList(new AddressInfo("187 Suffolk Ln.", new AddressInfo.City("Boise", "United States", "ID")))); + + private final Person kyleGass = + new Person( + "kylegass", + "Gass", + "Kyle", + Arrays.asList(new AddressInfo("187 Suffolk Ln.", new AddressInfo.City("Boise", "United States", "ID")))); + + private final Person russellWhyte = + new Person( + "russellwhyte", + "Whyte", + "Russell", + Arrays.asList(new AddressInfo("187 Suffolk Ln.", new AddressInfo.City("Boise", "United States", "ID")))); + + private final Person scottKetchum = + new Person( + "scottketchum", + "Ketchum", + "Scott", + Arrays + .asList( + new AddressInfo("2817 Milton Dr.", new AddressInfo.City("Albuquerque", "United States", "NM")))); + + private final Person ronaldMundy = + new Person( + "ronaldmundy", + "Mundy", + "Ronald", + Arrays.asList(new AddressInfo("187 Suffolk Ln.", new AddressInfo.City("Boise", "United States", "ID")))); + + @Test + void testGetResultElementPerson() + { + final ODataRequestResultGeneric result = mockRequestResult(Person.PAYLOAD_SAMPLE_ENTITY); + final Person person = result.as(Person.class); + assertThat(person).isEqualTo(jackBlack); + } + + @Test + void testGetResultPersonWithExpandedFriends() + { + final ODataRequestResultGeneric result = + mockRequestResult(Person.PAYLOAD_SAMPLE_ENTITY_WITH_EXPANDED_NAVIGATION_PROPERTY); + final Person person = result.as(Person.class); + russellWhyte.setFriends(Arrays.asList(scottKetchum, ronaldMundy)); + assertThat(person).isEqualTo(russellWhyte); + } + + @Test + void testGetResultPersonWithExpandedBestFriend() + { + final ODataRequestResultGeneric result = + mockRequestResult(Person.PAYLOAD_ENTITY_WITH_BINARY_NAVIGATION_PROPERTY); + final Person person = result.as(Person.class); + russellWhyte.setBestFriend(scottKetchum); + assertThat(person).isEqualTo(russellWhyte); + } + + @Test + void testGetResultElements() + { + final ODataRequestResultGeneric result = mockRequestResult(Person.PAYLOAD_SAMPLE_SET); + final Iterable elements = result.getResultElements(); + assertThat(elements).isNotNull(); + } + + @Test + void testGetResultAsList() + { + final ODataRequestResultGeneric result = mockRequestResult(Person.PAYLOAD_SAMPLE_SET); + final List persons = result.asList(Person.class); + assertThat(persons).containsOnly(jackBlack, kyleGass); + } + + @Test + void testGetResultAsMap() + { + final ODataRequestResultGeneric result = mockRequestResult(Person.PAYLOAD_SAMPLE_SET); + final List> maps = result.asListOfMaps(); + assertThat(maps) + .containsOnly( + ImmutableMap + . builder() + .put("@odata.id", "serviceRoot/People('jackblack')") + .put("@odata.etag", "W/\"08D1694BD49A0F11\"") + .put("@odata.editLink", "serviceRoot/People('jackblack')") + .put("UserName", "jackblack") + .put("FirstName", "Jack") + .put("LastName", "Black") + .put( + "AddressInfo", + Arrays + .asList( + ImmutableMap + .of( + "Address", + "187 Suffolk Ln.", + "City", + ImmutableMap + .of("Name", "Boise", "CountryRegion", "United States", "Region", "ID")))) + .build(), + ImmutableMap + . builder() + .put("@odata.id", "serviceRoot/People('kylegass')") + .put("@odata.etag", "W/\"08D1694BD49A0F11\"") + .put("@odata.editLink", "serviceRoot/People('kylegass')") + .put("UserName", "kylegass") + .put("FirstName", "Kyle") + .put("LastName", "Gass") + .put( + "AddressInfo", + Arrays + .asList( + ImmutableMap + .of( + "Address", + "187 Suffolk Ln.", + "City", + ImmutableMap + .of("Name", "Boise", "CountryRegion", "United States", "Region", "ID")))) + .build()); + } + + @Test + void testGetResultAsStream() + { + final ODataRequestResultGeneric result = mockRequestResult(Person.PAYLOAD_SAMPLE_SET); + final List userNames = Lists.newArrayList(); + result.streamElements(element -> userNames.add(element.getAsObject().as(Person.class).getUserName())); + assertThat(userNames).containsOnly("jackblack", "kylegass"); + } + + private static ODataRequestResultGeneric mockRequestResult( final String payload ) + { + try { + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + when(request.getProtocol()).thenReturn(ODataProtocol.V4); + + final HttpEntity httpEntity = spy(HttpEntity.class); + when(httpEntity.getContentLength()).thenReturn(0L); + when(httpEntity.isRepeatable()).thenReturn(true); + when(httpEntity.getContentType()).thenReturn("application/json"); + when(httpEntity.getContentEncoding()).thenReturn("identity"); + when(httpEntity.getContent()).thenReturn(new ByteArrayInputStream(payload.getBytes())); + final HttpEntity bufferedHttpEntity = new BufferedHttpEntity(httpEntity); + + final StatusLine statusLine = mock(StatusLine.class); + when(statusLine.getStatusCode()).thenReturn(HttpStatus.SC_OK); + + final ClassicHttpResponse httpResponse = mock(ClassicHttpResponse.class); + when(httpResponse.getHeaders()).thenReturn(new Header[0]); + when(httpResponse.getEntity()).thenReturn(bufferedHttpEntity); + // when(httpResponse.getStatusLine()).thenReturn(statusLine); + + return new ODataRequestResultGeneric(request, httpResponse); + } + catch( final Exception e ) { + throw new ShouldNotHappenException("Failed to run tests"); + } + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataResponseExceptionTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataResponseExceptionTest.java new file mode 100644 index 000000000..b8405ac53 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataResponseExceptionTest.java @@ -0,0 +1,83 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol.V2; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; + +import javax.annotation.Nonnull; + +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.ByteArrayEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataResponseException; + +import lombok.Data; +import lombok.SneakyThrows; + +class ODataResponseExceptionTest +{ + private static final ODataRequestGeneric REQUEST = new ODataRequestRead("path", "collection", null, V2); + private static final Throwable CAUSE = new Exception(); + private static final String MESSAGE = "message"; + private static final String INPUT = "Hêllö WØrld"; + + @Data + private static class TestParameters + { + @Nonnull + Charset charset; + @Nonnull + String text; + @Nonnull + String textMissingCharset; + } + + static List getTestParameters() + { + return Arrays + .asList( + new TestParameters(StandardCharsets.ISO_8859_1, INPUT, "H�ll� W�rld"), + new TestParameters(StandardCharsets.UTF_8, INPUT, INPUT)); + } + + @SneakyThrows + @ParameterizedTest + @MethodSource( "getTestParameters" ) + void testEncodingGiven( @Nonnull final TestParameters parameters ) + { + final byte[] encodedString = parameters.text.getBytes(parameters.charset); + + final BasicClassicHttpResponse response = new BasicClassicHttpResponse(200, "OK"); + response.setEntity(new ByteArrayEntity(encodedString, ContentType.create("text/plain", parameters.charset))); + + final ODataResponseException message = new ODataResponseException(REQUEST, response, MESSAGE, CAUSE); + assertThat(message).hasMessage(MESSAGE).hasCause(CAUSE); + assertThat(message.getHttpCode()).isEqualTo(200); + assertThat(message.getHttpHeaders()).isEmpty(); + assertThat(message.getHttpBody()).containsExactly(parameters.text); + } + + @SneakyThrows + @ParameterizedTest + @MethodSource( "getTestParameters" ) + void testEncodingUnknown( @Nonnull final TestParameters parameters ) + { + final byte[] encodedString = parameters.text.getBytes(parameters.charset); + + final BasicClassicHttpResponse response = new BasicClassicHttpResponse(200, "OK"); + response.setEntity(new ByteArrayEntity(encodedString, ContentType.create("text/plain"))); // no charset + + final ODataResponseException message = new ODataResponseException(REQUEST, response, MESSAGE, CAUSE); + assertThat(message).hasMessage(MESSAGE).hasCause(CAUSE); + assertThat(message.getHttpCode()).isEqualTo(200); + assertThat(message.getHttpHeaders()).isEmpty(); + assertThat(message.getHttpBody()).containsExactly(parameters.textMissingCharset); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataResponseParsingIntegrationTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataResponseParsingIntegrationTest.java new file mode 100644 index 000000000..b5d1697ac --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataResponseParsingIntegrationTest.java @@ -0,0 +1,343 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import java.io.IOException; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataDeserializationException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataServiceErrorException; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@Disabled( "Test runs against a reference service on odata.org. Use it only to manually verify behaviour." ) +class ODataResponseParsingIntegrationTest +{ + HttpClient httpClient; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @RequiredArgsConstructor + public static class Person + { + + @ElementName( "UserName" ) + @SerializedName( "UserName" ) + @JsonProperty( "UserName" ) + @Nonnull + String username; + + @ElementName( "LastName" ) + @SerializedName( "LastName" ) + @JsonProperty( "LastName" ) + @Nonnull + String lastName; + + @ElementName( "FirstName" ) + @SerializedName( "FirstName" ) + @JsonProperty( "FirstName" ) + @Nonnull + String firstName; + + @ElementName( "Emails" ) + @SerializedName( "Emails" ) + @JsonProperty( "Emails" ) + @Nonnull + List emails; + + @ElementName( "AddressInfo" ) + @SerializedName( "AddressInfo" ) + @JsonProperty( "AddressInfo" ) + @Nonnull + List addressInfo; + + @ElementName( "Friends" ) + @SerializedName( "Friends" ) + @JsonProperty( "Friends" ) + @Nullable + List friends; + + @ElementName( "BestFriend" ) + @SerializedName( "BestFriend" ) + @JsonProperty( "BestFriend" ) + @Nullable + Person bestFriend; + + @ElementName( "Trips" ) + @SerializedName( "Trips" ) + @JsonProperty( "Trips" ) + @Nullable + List trips; + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class Trip + { + + @ElementName( "TripId" ) + @SerializedName( "TripId" ) + @JsonProperty( "TripId" ) + int tripId; + + @ElementName( "ShareId" ) + @SerializedName( "ShareId" ) + @JsonProperty( "ShareId" ) + UUID shareId; + + @ElementName( "Name" ) + @SerializedName( "Name" ) + @JsonProperty( "Name" ) + String name; + + @ElementName( "Budget" ) + @SerializedName( "Budget" ) + @JsonProperty( "Budget" ) + int budget; + + @ElementName( "Description" ) + @SerializedName( "Description" ) + @JsonProperty( "Description" ) + String description; + + @ElementName( "Tags" ) + @SerializedName( "Tags" ) + @JsonProperty( "Tags" ) + List tags; + + @ElementName( "StartsAt" ) + @SerializedName( "StartsAt" ) + @JsonProperty( "StartsAt" ) + OffsetDateTime startsAt; + + @ElementName( "EndsAt" ) + @SerializedName( "EndsAt" ) + @JsonProperty( "EndsAt" ) + OffsetDateTime endsAt; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @ToString + public static class AddressInfo + { + + @ElementName( "Address" ) + @SerializedName( "Address" ) + @JsonProperty( "Address" ) + String address; + + @ElementName( "City" ) + @SerializedName( "City" ) + @JsonProperty( "City" ) + City city; + + @ToString + public static class City + { + + @ElementName( "Name" ) + @SerializedName( "Name" ) + @JsonProperty( "Name" ) + String name; + + @ElementName( "CountryRegion" ) + @SerializedName( "CountryRegion" ) + @JsonProperty( "CountryRegion" ) + String countryRegion; + + @ElementName( "Region" ) + @SerializedName( "Region" ) + @JsonProperty( "Region" ) + String region; + } + } + + @BeforeEach + void configure() + { + final Destination dest = DefaultHttpDestination.builder("https://services.odata.org").build(); + httpClient = ApacheHttpClient5Accessor.getHttpClient(dest); + } + + //Validating response for primitive collections + @Test + void testEmailList() + { + final ODataRequestRead request = + new ODataRequestRead( + "TripPinRESTierService", + "People", + "$filter=UserName%20eq%20'russellwhyte'", + ODataProtocol.V4); + final ODataRequestResultGeneric result = request.execute(httpClient); + final List persons = result.asList(Person.class); + assertThat(persons).hasSize(1); + assertThat(persons.get(0).getEmails()).isNotEmpty(); + assertThat(persons.get(0).getEmails()).isInstanceOf(List.class); + assertThat(persons.get(0).getEmails().get(0)).isInstanceOf(String.class); + } + + //Validating response for complex types + @Test + void testAddressInfo() + { + final ODataRequestRead request = + new ODataRequestRead( + "TripPinRESTierService", + "People", + "$filter=UserName%20eq%20'russellwhyte'", + ODataProtocol.V4); + final ODataRequestResultGeneric result = request.execute(httpClient); + final List persons = result.asList(Person.class); + assertThat(persons).hasSize(1); + final List addressInfo = persons.get(0).getAddressInfo(); + assertThat(addressInfo).isNotEmpty(); + assertThat(addressInfo.get(0)).isInstanceOf(AddressInfo.class); + assertThat(addressInfo.get(0).getCity()).isInstanceOf(AddressInfo.City.class); + } + + //The below test tests an 1:1 navigation property + @Test + void testExpandedNavigationBestFriend() + { + final ODataRequestRead request = + new ODataRequestRead( + "TripPinRESTierService", + "People", + "$filter=UserName%20eq%20'russellwhyte'&$expand=BestFriend", + ODataProtocol.V4); + final ODataRequestResultGeneric result = request.execute(httpClient); + final List persons = result.asList(Person.class); + assertThat(persons).hasSize(1); + final Person bestFriend = persons.get(0).getBestFriend(); + assertThat(bestFriend).isInstanceOf(Person.class); + } + + //The below tests test an 1:n navigation property + @Test + void testExpandedNavigationFriends() + { + final ODataRequestRead request = + new ODataRequestRead( + "TripPinRESTierService", + "People", + "$filter=UserName%20eq%20'russellwhyte'&$expand=Friends", + ODataProtocol.V4); + final ODataRequestResultGeneric result = request.execute(httpClient); + final List persons = result.asList(Person.class); + assertThat(persons).hasSize(1); + final List friends = persons.get(0).getFriends(); + assertThat(friends).isNotEmpty(); + assertThat(friends.get(0)).isInstanceOf(Person.class); + } + + @Test + void testExpandedNavigationTrips() + { + final ODataRequestRead request = + new ODataRequestRead( + "TripPinRESTierService", + "People", + "$filter=UserName%20eq%20'russellwhyte'&$expand=Trips", + ODataProtocol.V4); + final ODataRequestResultGeneric result = request.execute(httpClient); + final List persons = result.asList(Person.class); + assertThat(persons).hasSize(1); + final List trips = persons.get(0).getTrips(); + assertThat(trips).isNotEmpty(); + assertThat(trips.get(0)).isInstanceOf(Trip.class); + } + + @Test + void testExpandedNavigationTripsWithNestedRequest() + { + final ODataRequestRead request = + new ODataRequestRead( + "TripPinRESTierService", + "People", + "$filter=UserName%20eq%20'russellwhyte'&$expand=Trips($top=1;$select=TripId)", + ODataProtocol.V4); + final ODataRequestResultGeneric result = request.execute(httpClient); + final List persons = result.asList(Person.class); + assertThat(persons).hasSize(1); + final List trips = persons.get(0).getTrips(); + assertThat(trips).isNotEmpty(); + assertThat(trips.get(0)).isInstanceOf(Trip.class); + } + + //Validating response for error message + @Test + void testServiceError() + { + final ODataRequestRead request = + new ODataRequestRead( + "TripPinRESTierService", + "People", + "$filter=contains(Emails,'Russell@example.com')", + ODataProtocol.V4); + assertThatCode(() -> request.execute(httpClient)).isInstanceOf(ODataServiceErrorException.class); + } + + @Test + void testExceptionWhenUnbufferedHttpEntityIsAccessedMultipleTimes() + { + final ODataRequestRead request = + new ODataRequestRead("TripPinRESTierService", "People", "$count=true&$format=json", ODataProtocol.V4); + + try( final ODataRequestResultResource result = request.withoutResponseBuffering().execute(httpClient) ) { + + // first access successful + assertThat(result.asMap()).containsKeys("value", "@odata.count"); + + // second access fails + assertThatExceptionOfType(ODataDeserializationException.class).isThrownBy(result::getInlineCount); + } + } + + @Test + void testWhenUnbufferedHttpEntityIsAccessedAfterAccessingBufferedHttpEntity() + { + final ODataRequestRead request = + new ODataRequestRead("TripPinRESTierService", "People", "$count=true&$format=json", ODataProtocol.V4); + + @SuppressWarnings( "resource" ) // let's assume user is forgetting try-with-resources + final ODataRequestResultGeneric result = request.withoutResponseBuffering().execute(httpClient); + + // first access successful + assertThat(result.asMap()).containsKeys("value", "@odata.count"); + + // second access fails + assertThatExceptionOfType(ODataDeserializationException.class) + .isThrownBy(result::getInlineCount) + .havingRootCause() + .isInstanceOf(IOException.class) + .withMessage("Attempted read from closed stream."); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataResponsePrimitiveDataParsingTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataResponsePrimitiveDataParsingTest.java new file mode 100644 index 000000000..79d0d0a64 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataResponsePrimitiveDataParsingTest.java @@ -0,0 +1,242 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static java.lang.Float.NaN; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.math.BigDecimal; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Consumer; + +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.io.entity.BufferedHttpEntity; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.collect.ImmutableMap; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.cloudplatform.exception.ShouldNotHappenException; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +class ODataResponsePrimitiveDataParsingTest +{ + public enum ColorEnum + { + Yellow + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class ReferenceObject + { + @ElementName( "BooleanValue" ) + @SerializedName( "BooleanValue" ) + @JsonProperty( "BooleanValue" ) + boolean booleanValue; + + @ElementName( "BinaryValue" ) + @SerializedName( "BinaryValue" ) + @JsonProperty( "BinaryValue" ) + byte[] binaryValue; + + @ElementName( "StringValue" ) + @SerializedName( "StringValue" ) + @JsonProperty( "StringValue" ) + String stringValue; + + // Numbers + @ElementName( "IntegerValue" ) + @SerializedName( "IntegerValue" ) + @JsonProperty( "IntegerValue" ) + int integerValue; + + @ElementName( "Int64Value" ) + @SerializedName( "Int64Value" ) + @JsonProperty( "Int64Value" ) + long int64Value; + + @ElementName( "SingleValue" ) + @SerializedName( "SingleValue" ) + @JsonProperty( "SingleValue" ) + float singleValue; + + @ElementName( "DecimalValue" ) + @SerializedName( "DecimalValue" ) + @JsonProperty( "DecimalValue" ) + double decimalValue; + + @ElementName( "DoubleValue" ) + @SerializedName( "DoubleValue" ) + @JsonProperty( "DoubleValue" ) + double doubleValue; + + @ElementName( "GuidValue" ) + @SerializedName( "GuidValue" ) + @JsonProperty( "GuidValue" ) + UUID guidValue; + + // Dates + @ElementName( "DurationValue" ) + @SerializedName( "DurationValue" ) + @JsonProperty( "DurationValue" ) + Duration durationValue; + + @ElementName( "TimeOfDayValue" ) + @SerializedName( "TimeOfDayValue" ) + @JsonProperty( "TimeOfDayValue" ) + LocalTime timeOfDayValue; + + @ElementName( "DateValue" ) + @SerializedName( "DateValue" ) + @JsonProperty( "DateValue" ) + LocalDate dateValue; + + @ElementName( "DateTimeOffsetValue" ) + @SerializedName( "DateTimeOffsetValue" ) + @JsonProperty( "DateTimeOffsetValue" ) + OffsetDateTime dateTimeOffsetValue; + + @ElementName( "Tags" ) + @SerializedName( "Tags" ) + @JsonProperty( "Tags" ) + List tags; + + @ElementName( "ColorEnumValue" ) + @SerializedName( "ColorEnumValue" ) + @JsonProperty( "ColorEnumValue" ) + ColorEnum colorEnumValue; + + // https://docs.oasis-open.org/odata/odata-json-format/v4.01/csprd06/odata-json-format-v4.01-csprd06.html#sec_PrimitiveValue + // applied the following changes: + // - SingleValue: Replaced "INF" with "NaN", Java float expects "Infinity" instead (with an optional plus or minus in front). We would need another type adapter for that. + // - DurationValue: Dropped the last 3 nines before the S from the reference service example here because WTF they have picoseconds (10^-12 seconds) in there... + // - GeographyPoint: Excluded, could be added later if needed. + private static final String PAYLOAD_ODATA_REFERENCE = """ + { + "BooleanValue": false, + "BinaryValue": "T0RhdGE", + "IntegerValue": -128, + "DoubleValue": 3.1415926535897931, + "SingleValue": "NaN", + "DecimalValue": 12.3999999999999999, + "StringValue": "Say \\"Hello\\",\\nthen go", + "DateValue": "2012-12-03", + "DateTimeOffsetValue": "2012-12-03T07:16:23Z", + "DurationValue": "P12DT23H59M59.999999999S", + "TimeOfDayValue": "07:59:59.999", + "GuidValue": "01234567-89ab-cdef-0123-456789abcdef", + "Int64Value": 0 + ,\ + "ColorEnumValue": "Yellow" + }\ + """; + + private static final ReferenceObject expectedObject = + new ReferenceObject( + false, + new byte[] { 79, 68, 97, 116, 97 }, // == Base64.getDecoder().decode("T0RhdGE") + "Say \"Hello\",\nthen go", + -128, + 0L, + NaN, + 12.3999999999999999d, // = 12.4 + 3.1415926535897931, + UUID.fromString("01234567-89ab-cdef-0123-456789abcdef"), + Duration.ofDays(12).plusHours(23).plusMinutes(59).plusSeconds(59).plusMillis(999).plusNanos(999999), + LocalTime.of(7, 59, 59, 999000000), + LocalDate.of(2012, 12, 3), + OffsetDateTime.of(2012, 12, 3, 7, 16, 23, 0, ZoneOffset.of("Z")), + null, + ColorEnum.Yellow); + } + + @Test + void testDataTypeParsingByReferenceObject() + { + final ODataRequestResultGeneric result = mockRequestResult(ReferenceObject.PAYLOAD_ODATA_REFERENCE); + + final ReferenceObject referenceResult = result.as(ReferenceObject.class); + + Objects.requireNonNull(referenceResult); + + assertThat(referenceResult.booleanValue).isEqualTo(ReferenceObject.expectedObject.booleanValue); + assertThat(referenceResult.binaryValue).isEqualTo(ReferenceObject.expectedObject.binaryValue); + assertThat(referenceResult.stringValue).isEqualTo(ReferenceObject.expectedObject.stringValue); + assertThat(referenceResult.integerValue).isEqualTo(ReferenceObject.expectedObject.integerValue); + assertThat(referenceResult.int64Value).isEqualTo(ReferenceObject.expectedObject.int64Value); + assertThat((Object) referenceResult.singleValue).isEqualTo(ReferenceObject.expectedObject.singleValue); + assertThat(referenceResult.decimalValue).isEqualTo(ReferenceObject.expectedObject.decimalValue); + assertThat(referenceResult.doubleValue).isEqualTo(ReferenceObject.expectedObject.doubleValue); + assertThat(referenceResult.guidValue).isEqualTo(ReferenceObject.expectedObject.guidValue); + assertThat(referenceResult.durationValue).isEqualTo(ReferenceObject.expectedObject.durationValue); + assertThat(referenceResult.timeOfDayValue).isEqualTo(ReferenceObject.expectedObject.timeOfDayValue); + assertThat(referenceResult.dateValue).isEqualTo(ReferenceObject.expectedObject.dateValue); + assertThat(referenceResult.dateTimeOffsetValue).isEqualTo(ReferenceObject.expectedObject.dateTimeOffsetValue); + assertThat(referenceResult.tags).isEqualTo(ReferenceObject.expectedObject.tags); + assertThat(referenceResult.colorEnumValue).isEqualTo(ReferenceObject.expectedObject.colorEnumValue); + } + + @Test + void testDataTypeParsingByNumberDeserializationStrategy() + { + final Map> assertions = + ImmutableMap + .of( + NumberDeserializationStrategy.DOUBLE, + number -> assertThat(number).isEqualTo(12.4), + NumberDeserializationStrategy.BIG_DECIMAL, + number -> assertThat(number).isEqualTo(new BigDecimal("12.3999999999999999"))); + + for( final NumberDeserializationStrategy strategy : assertions.keySet() ) { + final ODataRequestResultGeneric result = mockRequestResult(ReferenceObject.PAYLOAD_ODATA_REFERENCE); + final Map referenceResult = result.withNumberDeserializationStrategy(strategy).asMap(); + Objects.requireNonNull(referenceResult); + assertions.get(strategy).accept(referenceResult.get("DecimalValue")); + } + } + + private static ODataRequestResultGeneric mockRequestResult( final String payload ) + { + try { + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + when(request.getProtocol()).thenReturn(ODataProtocol.V4); + + final HttpEntity httpEntity = spy(HttpEntity.class); + when(httpEntity.getContentLength()).thenReturn(0L); + when(httpEntity.isRepeatable()).thenReturn(true); + when(httpEntity.getContentType()).thenReturn("application/json"); + when(httpEntity.getContentEncoding()).thenReturn("identity"); + when(httpEntity.getContent()).thenReturn(new ByteArrayInputStream(payload.getBytes())); + final HttpEntity bufferedHttpEntity = new BufferedHttpEntity(httpEntity); + + final ClassicHttpResponse httpResponse = mock(ClassicHttpResponse.class); + when(httpResponse.getHeaders()).thenReturn(new Header[0]); + when(httpResponse.getEntity()).thenReturn(bufferedHttpEntity); + + return new ODataRequestResultGeneric(request, httpResponse); + } + catch( final Exception e ) { + throw new ShouldNotHappenException("Failed to run tests"); + } + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataUriFactoryTest.java b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataUriFactoryTest.java new file mode 100644 index 000000000..5afa13a1e --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataUriFactoryTest.java @@ -0,0 +1,179 @@ +package com.sap.cloud.sdk.datamodel.odata.client.request; + +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static com.sap.cloud.sdk.datamodel.odata.client.request.UriEncodingStrategy.REGULAR; +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; + +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.google.common.collect.ImmutableMap; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; + +import lombok.SneakyThrows; + +class ODataUriFactoryTest +{ + private static final String servicePath = "/A_Service"; + private static final String entityName = "A_Entity"; + + @Test + void testDoubleSlashesInPath() + { + final String specialPath = "//" + servicePath + "////"; + final String expected = servicePath + "/" + entityName; + + final URI actual = ODataUriFactory.createAndEncodeUri(specialPath, entityName, "", REGULAR); + assertThat(actual.toString()).isEqualTo(expected); + } + + @Test + void testSpecialCharactersInPath() + { + final String specialPath = "Ä$_?Se&rv iß%ë#"; + final String expected = "/%C3%84$_%3FSe&rv%20i%C3%9F%25%C3%AB%23/" + entityName; + + final URI actual = ODataUriFactory.createAndEncodeUri(specialPath, entityName, "", REGULAR); + assertThat(actual.toString()).isEqualTo(expected); + } + + @Test + void testSafeCharactersInPath() + { + final String specialPath = "A_Service/-._~!$'()*,;&=@:+"; + final String entityPath = "B_entityPath/-._~!$'()*,;&=@:+"; + final String expected = "/A_Service/-._~!$'()*,;&=@:+/" + entityPath; + + final URI actual = ODataUriFactory.createAndEncodeUri(specialPath, entityPath, "", REGULAR); + assertThat(actual.toString()).isEqualTo(expected); + } + + @Test + void testEmptyEntityPath() + { + final String expected = servicePath + "/"; + + final URI actual = ODataUriFactory.createAndEncodeUri(servicePath, new ODataResourcePath(), null, REGULAR); + assertThat(actual.toString()).isEqualTo(expected); + } + + @Test + void testSpecialCharactersInEntityPath() + { + final String specialEntityPath = "A$_?En&ti t%y#"; + final String expected = servicePath + "/A$_%3FEn&ti%20t%25y%23"; + + final URI actual = + ODataUriFactory.createAndEncodeUri(servicePath, ODataResourcePath.of(specialEntityPath), null, REGULAR); + assertThat(actual.toString()).isEqualTo(expected); + } + + @Test + void testNoDoubleEncodingInQuery() + { + final String specialEntityPath = "A$_?En&ti t%y#"; + final String query = + "$expand=BestFriend($expand=Trips($filter=contains(Name,'%25%20%24%26%23%3F%22%5C+''')))&$filter=contains(FirstName,'%25%20%24%26%23%3F%22%5C+''')"; + final String expected = servicePath + "/A$_%3FEn&ti%20t%25y%23" + "?" + query; + + final URI actual = + ODataUriFactory + .createAndEncodeUri( + servicePath, + ODataResourcePath.of(specialEntityPath).toEncodedPathString(), + query, + REGULAR); + assertThat(actual.toString()).isEqualTo(expected); + } + + @Test + void testNoDoubleEncodingInParameter() + { + final String specialEntityPath = "A$_?En&ti t%y#"; + final String parameters = "('%25abc')"; + final String expected = servicePath + "/A$_%3FEn&ti%20t%25y%23" + parameters; + + final ODataResourcePath path = + ODataResourcePath.of(specialEntityPath, new ODataEntityKey(ODataProtocol.V4).addKeyProperty("key", "%abc")); + + final URI actual = ODataUriFactory.createAndEncodeUri(servicePath, path, null, REGULAR); + assertThat(actual.toString()).isEqualTo(expected); + } + + @Test + void testFilterWithForeignCharactersInQuery() + { + //Maps unencoded value to corresponding encoded value + final ImmutableMap encodingMap = + ImmutableMap + .of( + "Brontë", + "Bront%C3%AB", + "위키백과:대문", + "%EC%9C%84%ED%82%A4%EB%B0%B1%EA%B3%BC:%EB%8C%80%EB%AC%B8", + "Günter", + "G%C3%BCnter"); + + encodingMap.forEach(( unencoded, encoded ) -> { + final String unencodedFilterCondition = "(field-name eq '" + unencoded + "')"; + + final String expectedEncodedFilterQueryParameter = "$filter=(field-name%20eq%20'" + encoded + "')"; + + final String actualEncodedFilterQueryParameter = + "$filter=" + (ODataUriFactory.encodeQuery(unencodedFilterCondition)); + + assertThat(actualEncodedFilterQueryParameter).isEqualTo(expectedEncodedFilterQueryParameter); + + final URI resultUri = + ODataUriFactory.createAndEncodeUri("service", "entity", actualEncodedFilterQueryParameter, REGULAR); + + assertThat(resultUri.getRawQuery()).isEqualTo(expectedEncodedFilterQueryParameter); + }); + } + + @SneakyThrows + @Test + void testSpecialCharactersAgainstEndpoint() + { + final WireMockServer wireMockServer = new WireMockServer(wireMockConfig().dynamicPort()); + wireMockServer.start(); + + final String query = "$filter=" + ODataUriFactory.encodeQuery("(Formula eq 'Foo +Bar')"); + final String subPath = ODataUriFactory.encodePath("sub-path/Entity(Key=123,Value='?')"); + + final String rootPath = wireMockServer.url("root-path/"); + final URI uri = URI.create(rootPath).resolve(subPath + "?" + query); + try( + final var client = HttpClients.createDefault(); + final var response = client.executeOpen(null, new HttpGet(uri), null) ) { + // response is unused; we only car about that the request was made + response.setCode(200); + } + + wireMockServer.stop(); + wireMockServer.verify(getRequestedFor(urlEqualTo("/root-path/sub-path/Entity(Key=123,Value='%3F')" + // escaped question mark + "?$filter=(Formula%20eq%20'Foo%20%2BBar')"))); + } + + // Regression test for https://github.com/SAP/cloud-sdk/issues/741 + @Test + void testPipeIsNotASafeQueryCharacter() + { + final String query = "(Name eq 'Version|1')"; + final String expectedEncodedQuery = "(Name%20eq%20'Version%7C1')"; + final String actualEncodedQuery = ODataUriFactory.encodeQuery(query); + + assertThat(actualEncodedQuery).isEqualTo(expectedEncodedQuery); + + final URI uri = + ODataUriFactory.createAndEncodeUri(servicePath, entityName, "$filter=" + actualEncodedQuery, REGULAR); + assertThat(uri.toString()).isEqualTo("/A_Service/A_Entity?$filter=" + expectedEncodedQuery); + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/MultipartParserTest/BatchReadResponseBody.txt b/datamodel/odata-client-apache-httpclient5/src/test/resources/MultipartParserTest/BatchReadResponseBody.txt new file mode 100644 index 000000000..ef89b05c9 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/MultipartParserTest/BatchReadResponseBody.txt @@ -0,0 +1,20 @@ +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json; odata.metadata=minimal; odata.streaming=true +OData-Version: 4.0 + +{"@odata.context":"https://services.odata.org/TripPinRESTierService/(S(w3zgpoiit3rigb4hkixmletd))/$metadata#People","value":[{"UserName":"angelhuffman","FirstName":"Angel","LastName":"Huffman"}]} +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json; odata.metadata=minimal; odata.streaming=true +OData-Version: 4.0 + + +{"@odata.context":"https://services.odata.org/TripPinRESTierService/(S(w3zgpoiit3rigb4hkixmletd))/$metadata#People","value":[{"UserName":"klauskinski","FirstName":"Klaus","LastName":"Kinski"},{"UserName":"DanielBruehl","FirstName":"Daniel","LastName":"Brühl"}]} +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef-- diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/MultipartParserTest/BatchWriteResponseBody.txt b/datamodel/odata-client-apache-httpclient5/src/test/resources/MultipartParserTest/BatchWriteResponseBody.txt new file mode 100644 index 000000000..c69262bcd --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/MultipartParserTest/BatchWriteResponseBody.txt @@ -0,0 +1,37 @@ +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: multipart/mixed; boundary=changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8cb4 + +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8cb4 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 2 + +HTTP/1.1 201 Created +Location: https://services.odata.org/TripPinRESTierService/(S(w3zgpoiit3rigb4hkixmletd))/People('menow2') +Content-Type: application/json; odata.metadata=minimal +OData-Version: 4.0 + +{"@odata.context":"https://services.odata.org/TripPinRESTierService/(S(w3zgpoiit3rigb4hkixmletd))/$metadata#People/$entity","UserName":"JohnDoe1","FirstName":"John","LastName":"","MiddleName":null,"Gender":"Male","Age":null,"Emails":[],"FavoriteFeature":null,"Features":[],"AddressInfo":[],"HomeAddress":null} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8cb4 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 3 + +HTTP/1.1 201 Created +Location: https://services.odata.org/TripPinRESTierService/(S(w3zgpoiit3rigb4hkixmletd))/People('menow3') +Content-Type: application/json; odata.metadata=minimal +OData-Version: 4.0 + +{"@odata.context":"https://services.odata.org/TripPinRESTierService/(S(w3zgpoiit3rigb4hkixmletd))/$metadata#People/$entity","UserName":"JohnDoe2","FirstName":"John","LastName":"","MiddleName":null,"Gender":"Male","Age":null,"Emails":[],"FavoriteFeature":null,"Features":[],"AddressInfo":[],"HomeAddress":null} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8cb4-- +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 404 Not Found +Content-Type: application/json; odata.metadata=minimal; odata.streaming=true +OData-Version: 4.0 + +{"error":{"code":"","message":"The request resource is not found."}} +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef-- + diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataClientQueryBatchUnitTest/BatchAllRequestBody.txt b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataClientQueryBatchUnitTest/BatchAllRequestBody.txt new file mode 100644 index 000000000..916829161 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataClientQueryBatchUnitTest/BatchAllRequestBody.txt @@ -0,0 +1,59 @@ +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 1 + +GET Entity?$filter=Fieldname%20eq%20'hello' HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: multipart/mixed;boundary=changeset_00000000-0000-0000-0000-000000000002 + +--changeset_00000000-0000-0000-0000-000000000002 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 2 + +POST Entity HTTP/1.1 +Accept: application/json +Content-Type: application/json +Set-Cookie: foo +Set-Cookie: bar + +{"foo": "bar"} + +--changeset_00000000-0000-0000-0000-000000000002 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 3 + +PATCH Entity(%27the-key%23%26%21%25%27) HTTP/1.1 +Accept: application/json +Content-Type: application/json +If-Match: version-identifier + +{"foo": "bar"} + +--changeset_00000000-0000-0000-0000-000000000002 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 4 + +DELETE Entity(%27the-key%23%26%21%25%27) HTTP/1.1 +Accept: application/json +If-Match: version-identifier + + +--changeset_00000000-0000-0000-0000-000000000002-- + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 5 + +GET Entity(%27the-key%23%26%21%25%27) HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataClientQueryBatchUnitTest/BatchEmptyChangesetRequestBody.txt b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataClientQueryBatchUnitTest/BatchEmptyChangesetRequestBody.txt new file mode 100644 index 000000000..9f89be43a --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataClientQueryBatchUnitTest/BatchEmptyChangesetRequestBody.txt @@ -0,0 +1,6 @@ +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: multipart/mixed;boundary=changeset_00000000-0000-0000-0000-000000000002 + +--changeset_00000000-0000-0000-0000-000000000002-- + +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataClientQueryBatchUnitTest/BatchEmptyRequestBody.txt b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataClientQueryBatchUnitTest/BatchEmptyRequestBody.txt new file mode 100644 index 000000000..87e43fd86 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataClientQueryBatchUnitTest/BatchEmptyRequestBody.txt @@ -0,0 +1 @@ +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataClientQueryBatchUnitTest/BatchReadRequestBody.txt b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataClientQueryBatchUnitTest/BatchReadRequestBody.txt new file mode 100644 index 000000000..892081414 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataClientQueryBatchUnitTest/BatchReadRequestBody.txt @@ -0,0 +1,10 @@ +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 1 + +GET Entity?$filter=Fieldname%20eq%20'hello' HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataFetchAsStreamTest/files/POT01.pdf b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataFetchAsStreamTest/files/POT01.pdf new file mode 100644 index 000000000..65c8596f3 Binary files /dev/null and b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataFetchAsStreamTest/files/POT01.pdf differ diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataFetchAsStreamTest/files/SAP_logo.png b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataFetchAsStreamTest/files/SAP_logo.png new file mode 100644 index 000000000..ed2c2fefa --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataFetchAsStreamTest/files/SAP_logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4bd807355d288e24abcae4b049eea21a04abcbff32207090d0adc5b6bc6155e4 +size 42309 diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataFetchAsStreamTest/files/test.txt b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataFetchAsStreamTest/files/test.txt new file mode 100644 index 000000000..705bb1cf5 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataFetchAsStreamTest/files/test.txt @@ -0,0 +1 @@ +Test file content \ No newline at end of file diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataInlineCountTest/odata-v2-response-with-inline-count.json b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataInlineCountTest/odata-v2-response-with-inline-count.json new file mode 100644 index 000000000..cb5df9c4c --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataInlineCountTest/odata-v2-response-with-inline-count.json @@ -0,0 +1,62 @@ +{ + "d": { + "results": [ + { + "__metadata": { + "uri": "https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ALFKI')", + "type": "NorthwindModel.Customer" + }, + "CustomerID": "ALFKI", + "CompanyName": "Alfreds Futterkiste", + "ContactName": "Maria Anders", + "ContactTitle": "Sales Representative", + "Address": "Obere Str. 57", + "City": "Berlin", + "Region": null, + "PostalCode": "12209", + "Country": "Germany", + "Phone": "030-0074321", + "Fax": "030-0076545", + "Orders": { + "__deferred": { + "uri": "https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ALFKI')/Orders" + } + }, + "CustomerDemographics": { + "__deferred": { + "uri": "https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ALFKI')/CustomerDemographics" + } + } + }, + { + "__metadata": { + "uri": "https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ANATR')", + "type": "NorthwindModel.Customer" + }, + "CustomerID": "ANATR", + "CompanyName": "Ana Trujillo Emparedados y helados", + "ContactName": "Ana Trujillo", + "ContactTitle": "Owner", + "Address": "Avda. de la Constituci\u00f3n 2222", + "City": "M\u00e9xico D.F.", + "Region": null, + "PostalCode": "05021", + "Country": "Mexico", + "Phone": "(5) 555-4729", + "Fax": "(5) 555-3745", + "Orders": { + "__deferred": { + "uri": "https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ANATR')/Orders" + } + }, + "CustomerDemographics": { + "__deferred": { + "uri": "https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ANATR')/CustomerDemographics" + } + } + } + ], + "__count": "2", + "__next": "https://services.odata.org/V2/Northwind/Northwind.svc/Customers?$inlinecount=allpages&$skiptoken='ERNSH'" + } +} \ No newline at end of file diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataInlineCountTest/odata-v2-response-without-inline-count.json b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataInlineCountTest/odata-v2-response-without-inline-count.json new file mode 100644 index 000000000..b4d7e43b7 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataInlineCountTest/odata-v2-response-without-inline-count.json @@ -0,0 +1,61 @@ +{ + "d": { + "results": [ + { + "__metadata": { + "uri": "https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ALFKI')", + "type": "NorthwindModel.Customer" + }, + "CustomerID": "ALFKI", + "CompanyName": "Alfreds Futterkiste", + "ContactName": "Maria Anders", + "ContactTitle": "Sales Representative", + "Address": "Obere Str. 57", + "City": "Berlin", + "Region": null, + "PostalCode": "12209", + "Country": "Germany", + "Phone": "030-0074321", + "Fax": "030-0076545", + "Orders": { + "__deferred": { + "uri": "https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ALFKI')/Orders" + } + }, + "CustomerDemographics": { + "__deferred": { + "uri": "https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ALFKI')/CustomerDemographics" + } + } + }, + { + "__metadata": { + "uri": "https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ANATR')", + "type": "NorthwindModel.Customer" + }, + "CustomerID": "ANATR", + "CompanyName": "Ana Trujillo Emparedados y helados", + "ContactName": "Ana Trujillo", + "ContactTitle": "Owner", + "Address": "Avda. de la Constituci\u00f3n 2222", + "City": "M\u00e9xico D.F.", + "Region": null, + "PostalCode": "05021", + "Country": "Mexico", + "Phone": "(5) 555-4729", + "Fax": "(5) 555-3745", + "Orders": { + "__deferred": { + "uri": "https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ANATR')/Orders" + } + }, + "CustomerDemographics": { + "__deferred": { + "uri": "https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ANATR')/CustomerDemographics" + } + } + } + ], + "__next": "https://services.odata.org/V2/Northwind/Northwind.svc/Customers?$inlinecount=allpages&$skiptoken='ERNSH'" + } +} \ No newline at end of file diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataInlineCountTest/odata-v4-response-with-inline-count.json b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataInlineCountTest/odata-v4-response-with-inline-count.json new file mode 100644 index 000000000..a07dab285 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataInlineCountTest/odata-v4-response-with-inline-count.json @@ -0,0 +1,58 @@ +{ + "@odata.context": "https://services.odata.org/TripPinRESTierService/(S(51gbikfs1rddfjsidhr3hzvi))/$metadata#People", + "@odata.count": 2, + "value": [ + { + "UserName": "russellwhyte", + "FirstName": "Russell", + "LastName": "Whyte", + "MiddleName": null, + "Gender": "Male", + "Age": null, + "Emails": [ + "Russell@example.com", + "Russell@contoso.com" + ], + "FavoriteFeature": "Feature1", + "Features": [ + "Feature1", + "Feature2" + ], + "AddressInfo": [ + { + "Address": "187 Suffolk Ln.", + "City": { + "Name": "Boise", + "CountryRegion": "United States", + "Region": "ID" + } + } + ], + "HomeAddress": null + }, + { + "UserName": "scottketchum", + "FirstName": "Scott", + "LastName": "Ketchum", + "MiddleName": null, + "Gender": "Male", + "Age": null, + "Emails": [ + "Scott@example.com" + ], + "FavoriteFeature": "Feature1", + "Features": [], + "AddressInfo": [ + { + "Address": "2817 Milton Dr.", + "City": { + "Name": "Albuquerque", + "CountryRegion": "United States", + "Region": "NM" + } + } + ], + "HomeAddress": null + } + ] +} \ No newline at end of file diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataInlineCountTest/odata-v4-response-without-inline-count.json b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataInlineCountTest/odata-v4-response-without-inline-count.json new file mode 100644 index 000000000..d1338fd1a --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataInlineCountTest/odata-v4-response-without-inline-count.json @@ -0,0 +1,57 @@ +{ + "@odata.context": "https://services.odata.org/TripPinRESTierService/(S(51gbikfs1rddfjsidhr3hzvi))/$metadata#People", + "value": [ + { + "UserName": "russellwhyte", + "FirstName": "Russell", + "LastName": "Whyte", + "MiddleName": null, + "Gender": "Male", + "Age": null, + "Emails": [ + "Russell@example.com", + "Russell@contoso.com" + ], + "FavoriteFeature": "Feature1", + "Features": [ + "Feature1", + "Feature2" + ], + "AddressInfo": [ + { + "Address": "187 Suffolk Ln.", + "City": { + "Name": "Boise", + "CountryRegion": "United States", + "Region": "ID" + } + } + ], + "HomeAddress": null + }, + { + "UserName": "scottketchum", + "FirstName": "Scott", + "LastName": "Ketchum", + "MiddleName": null, + "Gender": "Male", + "Age": null, + "Emails": [ + "Scott@example.com" + ], + "FavoriteFeature": "Feature1", + "Features": [], + "AddressInfo": [ + { + "Address": "2817 Milton Dr.", + "City": { + "Name": "Albuquerque", + "CountryRegion": "United States", + "Region": "NM" + } + } + ], + "HomeAddress": null + } + ] +} \ No newline at end of file diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataResponseParsingTest/Ticket_Collection_V2.json b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataResponseParsingTest/Ticket_Collection_V2.json new file mode 100644 index 000000000..c8acb19f7 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataResponseParsingTest/Ticket_Collection_V2.json @@ -0,0 +1,50 @@ +{ + "d": { + "results": [ + { + "__metadata": { + "id": "https://127.0.0.1/endpoint/url/Ticket('1')", + "uri": "https://127.0.0.1/endpoint/url/Ticket('1')", + "type": "API_TICKET.TicketType" + }, + "Ticket": "1", + "TicketCategory": "1", + "TicketName": "UpdatedFirstA UpdatedLastA", + "TicketGrouping": "BP01", + "TicketUUID": "34c6020a-aaaa-1111-2222-af75a376b0f9", + "CorrespondenceLanguage": "", + "CreatedByUser": "ABC001553", + "CreationDate": "/Date(1507075200000)/", + "CreationTime": "PT14H06M40S", + "LastChangeDate": "/Date(1524009600000)/", + "LastChangeTime": "ABC", + "LastChangedByUser": "ABC", + "TicketIsBlocked": false, + "TicketType": "", + "ETag": "ABC20190624171745" + }, + { + "__metadata": { + "id": "https://127.0.0.1/endpoint/url/Ticket('1000000')", + "uri": "https://127.0.0.1/endpoint/url/Ticket('1000000')", + "type": "API_TICKET.TicketType" + }, + "Ticket": "1000000", + "TicketCategory": "1", + "TicketName": "test test", + "TicketGrouping": "BP02", + "TicketUUID": "34c6020a-aaaa-1111-2222-af75a376b0f9", + "CorrespondenceLanguage": "EN", + "CreatedByUser": "ABC001553", + "CreationDate": "/Date(1430697600000)/", + "CreationTime": "PT08H08M26S", + "LastChangeDate": "/Date(1561334400000)/", + "LastChangeTime": "PT17H17M45S", + "LastChangedByUser": "ABC", + "TicketIsBlocked": false, + "TicketType": "", + "ETag": "ABC20190624171745" + } + ] + } +} \ No newline at end of file diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataResponseParsingTest/Ticket_Collection_V4.json b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataResponseParsingTest/Ticket_Collection_V4.json new file mode 100644 index 000000000..bfede66d3 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataResponseParsingTest/Ticket_Collection_V4.json @@ -0,0 +1,34 @@ +{ + "@odata.context": "$metadata#Ticket", + "@odata.metadataEtag": "W/\"20200517142415\"", + "value": [ + { + "Ticket" : "1000001", + "TicketCategory" : "2", + "TicketName" : "TESTSUPPLIER0111", + "TicketGrouping" : "BP02", + "TicketUUID" : "34c6020a-aaaa-1111-2222-af75a376b0f9", + "CorrespondenceLanguage" : "", + "CreatedByUser" : "ABC34c6020a", + "CreationDate" : "\/Date(1430784000000)\/", + "CreationTime" : "PT09H50M04S", + "TicketIsBlocked" : false, + "TicketType" : "", + "ETag" : "ABC20180116144654" + }, + { + "Ticket" : "1000001", + "TicketCategory" : "1", + "TicketName" : "TESTSUPPLIER0222", + "TicketGrouping" : "BP03", + "TicketUUID" : "34c6020a-aaaa-1111-2222-af75a376b0f9", + "CorrespondenceLanguage" : "", + "CreatedByUser" : "ABC34c6020a", + "CreationDate" : "\/Date(1430784000000)\/", + "CreationTime" : "PT09H50M04S", + "TicketIsBlocked" : false, + "TicketType" : "", + "ETag" : "ABC20180116144654" + } + ] +} \ No newline at end of file diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataResponseParsingTest/Ticket_Function_V2.json b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataResponseParsingTest/Ticket_Function_V2.json new file mode 100644 index 000000000..045809871 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataResponseParsingTest/Ticket_Function_V2.json @@ -0,0 +1,18 @@ +{ + "d": { + "Partner": { + "Ticket": "1000001", + "TicketCategory": "2", + "TicketName": "TESTSUPPLIER01", + "TicketGrouping": "BP02", + "TicketUUID": "34c6020a-aaaa-1111-2222-af75a376b0f9", + "CorrespondenceLanguage": "", + "CreatedByUser": "ABC34c6020a", + "CreationDate": "\/Date(1430784000000)\/", + "CreationTime": "PT09H50M04S", + "TicketIsBlocked": false, + "TicketType": "", + "ETag": "ABC20180116144654" + } + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataResponseParsingTest/Ticket_Single_V2.json b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataResponseParsingTest/Ticket_Single_V2.json new file mode 100644 index 000000000..2e22604c0 --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataResponseParsingTest/Ticket_Single_V2.json @@ -0,0 +1,21 @@ +{ + "d" : { + "__metadata" : { + "id" : "https://127.0.0.1/endpoint/url/Ticket('1000001')", + "uri" : "https://127.0.0.1/endpoint/url/Ticket('1000001')", + "type" : "API_TICKET.TicketType" + }, + "Ticket" : "1000001", + "TicketCategory" : "2", + "TicketName" : "TESTSUPPLIER01", + "TicketGrouping" : "BP02", + "TicketUUID" : "34c6020a-aaaa-1111-2222-af75a376b0f9", + "CorrespondenceLanguage" : "", + "CreatedByUser" : "ABC34c6020a", + "CreationDate" : "\/Date(1430784000000)\/", + "CreationTime" : "PT09H50M04S", + "TicketIsBlocked" : false, + "TicketType" : "", + "ETag" : "ABC20180116144654" + } +} diff --git a/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataResponseParsingTest/Ticket_Single_V4.json b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataResponseParsingTest/Ticket_Single_V4.json new file mode 100644 index 000000000..50cc006bc --- /dev/null +++ b/datamodel/odata-client-apache-httpclient5/src/test/resources/ODataResponseParsingTest/Ticket_Single_V4.json @@ -0,0 +1,15 @@ +{ + "@odata.context": "/v4/tickets", + "Ticket" : "1000001", + "TicketCategory" : "2", + "TicketName" : "TESTSUPPLIER01", + "TicketGrouping" : "BP02", + "TicketUUID" : "34c6020a-aaaa-1111-2222-af75a376b0f9", + "CorrespondenceLanguage" : "", + "CreatedByUser" : "ABC34c6020a", + "CreationDate" : "\/Date(1430784000000)\/", + "CreationTime" : "PT09H50M04S", + "TicketIsBlocked" : false, + "TicketType" : "", + "ETag" : "ABC20180116144654" +} \ No newline at end of file diff --git a/datamodel/pom.xml b/datamodel/pom.xml index 161f4a74e..8c04b72a3 100644 --- a/datamodel/pom.xml +++ b/datamodel/pom.xml @@ -33,6 +33,7 @@ fluent-result odata-client + odata-client-apache-httpclient5 odata odata-v4 diff --git a/release_notes.md b/release_notes.md index 3112514a9..7274a078f 100644 --- a/release_notes.md +++ b/release_notes.md @@ -12,7 +12,7 @@ ### ✨ New Functionality -- +- [OData Client] Introduced a new module `datamodel/odata-client-apache-httpclient5` to enable the use of the OData Client with Apache's Http Client 5. ### 📈 Improvements