diff --git a/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ApacheHttpClient5Accessor.java b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ApacheHttpClient5Accessor.java index bd2933e742..074c66795c 100644 --- a/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ApacheHttpClient5Accessor.java +++ b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ApacheHttpClient5Accessor.java @@ -21,6 +21,17 @@ @NoArgsConstructor( access = AccessLevel.PRIVATE ) public final class ApacheHttpClient5Accessor { + /** + * Internal request-header marker that instructs the CSRF token interceptor to skip fetching a CSRF token for the + * request it is attached to. The interceptor strips this header before the request is sent, so it never reaches the + * target system. + *

+ * This is an implementation detail used by the OData VDM layer to preserve the legacy {@code withoutCsrfToken()} + * opt-out behavior and is not intended for direct use by applications. + */ + @Nonnull + public static final String SKIP_CSRF_TOKEN_HEADER = "x-sap-sdk-skip-csrf-token"; + /** * Configures the {@code HttpClient5Cache} that is used by the {@code #getHttpClient(String)} and * {@code #getHttpClient(Destination)} methods. 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 index 6a110b3e50..8f6387435f 100644 --- 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 @@ -39,6 +39,12 @@ class CsrfTokenInterceptor implements HttpRequestInterceptor throws HttpException, IOException { + if( request.containsHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER) ) { + request.removeHeaders(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER); + log.debug("CSRF token retrieval explicitly disabled for this request, skipping."); + return; + } + if( !MUTATING_METHODS.contains(request.getMethod().toUpperCase()) ) { return; } 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 index 5d21b64548..13e2d45e21 100644 --- 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 @@ -119,6 +119,33 @@ void tokenIsNotFetchedWhenAlreadyPresent() .isEqualTo("existing-token"); } + @Test + @SneakyThrows + void tokenIsNotFetchedAndMarkerIsStrippedWhenSkipHeaderPresent() + { + final HttpPost request = new HttpPost(REQUEST_PATH); + request.addHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER, "true"); + + sut.process(request, null, null); + + verify(mockHttpClient, never()).execute(any(), ArgumentMatchers.> any()); + assertThat(request.getFirstHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY)).isNull(); + assertThat(request.containsHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER)).isFalse(); + } + + @Test + @SneakyThrows + void skipMarkerIsStrippedEvenOnGetRequest() + { + final HttpGet request = new HttpGet(REQUEST_PATH); + request.addHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER, "true"); + + sut.process(request, null, null); + + verify(mockHttpClient, never()).execute(any(), ArgumentMatchers.> any()); + assertThat(request.containsHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER)).isFalse(); + } + @Test @SneakyThrows void requestProceedsWithoutTokenWhenServerReturnsNoHeader( final WireMockRuntimeInfo wm ) diff --git a/datamodel/odata-core-apache-httpclient5/pom.xml b/datamodel/odata-core-apache-httpclient5/pom.xml new file mode 100644 index 0000000000..04351015c3 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/pom.xml @@ -0,0 +1,126 @@ + + + 4.0.0 + + com.sap.cloud.sdk.datamodel + datamodel-parent + 5.35.0-SNAPSHOT + + odata-core-apache-httpclient5 + jar + Data Model - OData Services - Core (HttpClient 5) + OData Services data model (VDM) - core classes 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 + + + + false + + + + com.sap.cloud.sdk.datamodel + odata-client-apache-httpclient5 + ${project.version} + + + com.sap.cloud.sdk.cloudplatform + cloudplatform-core + + + com.sap.cloud.sdk.cloudplatform + cloudplatform-connectivity + + + com.sap.cloud.sdk.cloudplatform + connectivity-apache-httpclient5 + + + com.sap.cloud.sdk.datamodel + fluent-result + + + org.slf4j + slf4j-api + + + com.google.guava + guava + + + com.google.code.gson + gson + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-databind + + + org.apache.httpcomponents.core5 + httpcore5 + + + org.apache.httpcomponents.client5 + httpclient5 + + + io.vavr + vavr + + + + org.projectlombok + lombok + provided + + + + org.assertj + assertj-core + test + + + org.mockito + mockito-core + test + + + org.wiremock + wiremock + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-params + test + + + diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/adapter/ODataNumberSerializer.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/adapter/ODataNumberSerializer.java new file mode 100644 index 0000000000..62efe10b08 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/adapter/ODataNumberSerializer.java @@ -0,0 +1,40 @@ +package com.sap.cloud.sdk.datamodel.odata.adapter; + +import java.lang.reflect.Type; +import java.math.BigDecimal; + +import javax.annotation.Nonnull; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +/** + * GSON serializer that transforms numbers to their JSON representation according to the OData V2 standard. Meant for + * internal use only. + */ +public class ODataNumberSerializer implements JsonSerializer +{ + @Override + @Nonnull + public JsonElement serialize( + @Nonnull final Number src, + @Nonnull final Type typeOfSrc, + @Nonnull final JsonSerializationContext context ) + { + /* + Short is used both for Edm.Byte and Edm.Int16. + Edm.Byte should be a string but Edm.Int16 should be a number. + But we can't differentiate between that here because we only know it's a Short. + So we serialize to the plain number because this worked in the past. + */ + if( typeOfSrc == Integer.class || typeOfSrc == Short.class ) { + return new JsonPrimitive(src); + } else if( typeOfSrc == BigDecimal.class ) { + return new JsonPrimitive(((BigDecimal) src).toPlainString()); + } else { + return new JsonPrimitive(src.toString()); + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/CollectionValuedFluentHelperFunction.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/CollectionValuedFluentHelperFunction.java new file mode 100644 index 0000000000..572b3d661f --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/CollectionValuedFluentHelperFunction.java @@ -0,0 +1,32 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import javax.annotation.Nonnull; + +/** + * Representation of any OData function import as a fluent class for further configuring the request and + * {@link #executeRequest(Destination) executing} it.This is specifically for functions that return either a collection + * of primitive values or entities + * + * @param + * The fluent helper type. + * @param + * The type of the object this OData request operates on, if any. + * @param + * The type of the result entity, if any. + */ +public abstract class CollectionValuedFluentHelperFunction + extends + FluentHelperFunction +{ + + /** + * Instantiates this fluent helper using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + */ + public CollectionValuedFluentHelperFunction( @Nonnull final String servicePath ) + { + super(servicePath); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/EntityField.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/EntityField.java new file mode 100644 index 0000000000..27a63b2432 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/EntityField.java @@ -0,0 +1,219 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.FieldReference; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FieldUntyped; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionString; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueString; +import com.sap.cloud.sdk.typeconverter.TypeConverter; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * Template class to represent entity fields. Instances of this object are used in query modifier methods of the entity + * fluent helpers. Contains methods to compare a field's value with a provided value. + *

+ * Use the constants declared in each entity inner class. Instantiating directly requires knowing the underlying OData + * field names, so use the constructor with caution. + * + * @param + * VdmObject that the field belongs to + * @param + * Field type + */ +@EqualsAndHashCode +public class EntityField implements EntitySelectable +{ + @Nonnull + @Getter + private final String fieldName; + + @Nullable + @Getter + private final TypeConverter typeConverter; + + @Nonnull + private final FieldUntyped fieldUntyped; + + /** + * Use the constants declared in each entity inner class. Instantiating directly requires knowing the underlying + * OData field names, so use with caution. + * + * @param fieldName + * OData field name. Must match the field returned by the underlying OData service. + */ + public EntityField( @Nonnull final String fieldName ) + { + this(fieldName, null); + } + + /** + * Use the constants declared in each entity inner class. Instantiating directly requires knowing the underlying + * OData field names, so use with caution. + *

+ * When creating instances for custom fields, this constructor can be used to add a type converter that will be + * automatically used by the respective entity when getting or setting custom fields. + * + * @param fieldName + * OData field name. Must match the field returned by the underlying OData service. + * @param typeConverter + * An implementation of a TypeConverter. The first type must match FieldT, the second type must match the + * type Olingo returns. + */ + public EntityField( @Nonnull final String fieldName, @Nullable final TypeConverter typeConverter ) + { + this.fieldName = fieldName; + this.typeConverter = typeConverter; + fieldUntyped = FieldReference.of(fieldName); + } + + /** + * Equals-null expression fluent helper. + * + * @return Fluent helper that represents a field == null expression. + */ + @Nonnull + public ExpressionFluentHelper eqNull() + { + return new ExpressionFluentHelper<>(fieldUntyped.equalToNull()); + } + + /** + * Equals expression fluent helper. + * + * @param value + * Field value to compare with. + * + * @return Fluent helper that represents a field == value expression. + */ + @Nonnull + public ExpressionFluentHelper eq( @Nullable final FieldT value ) + { + return new ExpressionFluentHelper<>(fieldUntyped.equalTo(value)); + } + + /** + * Not equals-null expression fluent helper. + * + * @return Fluent helper that represents a field != null expression. + */ + @Nonnull + public ExpressionFluentHelper neNull() + { + return new ExpressionFluentHelper<>(fieldUntyped.notEqualToNull()); + } + + /** + * Not equals expression fluent helper. + * + * @param value + * Field value to compare with. + * + * @return Fluent helper that represents a field != value expression. + */ + @Nonnull + public ExpressionFluentHelper ne( @Nullable final FieldT value ) + { + return new ExpressionFluentHelper<>(fieldUntyped.notEqualTo(value)); + } + + /** + * Greater than expression fluent helper. + * + * @param value + * Field value to compare with. + * + * @return Fluent helper that represents a field > value expression. + */ + @Nonnull + public ExpressionFluentHelper gt( @Nullable final FieldT value ) + { + return new ExpressionFluentHelper<>(fieldUntyped.greaterThan(value)); + } + + /** + * Greater than or equals expression fluent helper. + * + * @param value + * Field value to compare with. + * + * @return Fluent helper that represents a field ≥ value expression. + */ + @Nonnull + public ExpressionFluentHelper ge( @Nullable final FieldT value ) + { + return new ExpressionFluentHelper<>(fieldUntyped.greaterThanEqual(value)); + } + + /** + * Less than expression fluent helper. + * + * @param value + * Field value to compare with. + * + * @return Fluent helper that represents a field < value expression. + */ + @Nonnull + public ExpressionFluentHelper lt( @Nullable final FieldT value ) + { + return new ExpressionFluentHelper<>(fieldUntyped.lessThan(value)); + } + + /** + * Less than or equals expression fluent helper. + * + * @param value + * Field value to compare with. + * + * @return Fluent helper that represents a field ≤ value expression. + */ + @Nonnull + public ExpressionFluentHelper le( @Nullable final FieldT value ) + { + return new ExpressionFluentHelper<>(fieldUntyped.lessThanEqual(value)); + } + + /** + * Expression fluent helper supporting the filter function "substringof". + * + * @param value + * value String value to apply the function on + * + * @return Fluent helper that represents a {@code substringof(value,field)} expression + */ + @Nonnull + public ExpressionFluentHelper substringOf( @Nonnull final String value ) + { + return new ExpressionFluentHelper<>( + FilterExpressionString.substringOf(ValueString.literal(value), fieldUntyped.asString())); + } + + /** + * Expression fluent helper supporting the filter function "endswith". + * + * @param value + * String value to apply the function on + * @return Fluent helper that represents a {@code endswith(field,value)} expression + */ + @Nonnull + public ExpressionFluentHelper endsWith( @Nonnull final String value ) + { + return new ExpressionFluentHelper<>(fieldUntyped.asString().endsWith(value)); + } + + /** + * Expression fluent helper supporting the filter function "startswith". + * + * @param value + * String value to apply the function on + * @return Fluent helper that represents a {@code startswith(field,value)} expression + */ + @Nonnull + public ExpressionFluentHelper startsWith( @Nonnull final String value ) + { + return new ExpressionFluentHelper<>(fieldUntyped.asString().startsWith(value)); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/EntityFieldAll.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/EntityFieldAll.java new file mode 100644 index 0000000000..a45d275c1d --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/EntityFieldAll.java @@ -0,0 +1,50 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.util.Collections; +import java.util.List; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * OData selector for entities to select each and every field, i.e. star selector. Instances of this object are used in + * query modifier methods of the entity fluent helpers. Methods to compare with provided values are not supported. + * + * @param + * VdmObject that the field belongs to + */ +public class EntityFieldAll> implements EntitySelectable +{ + @Nonnull + @Override + public String getFieldName() + { + return "*"; + } + + @Nonnull + @Override + public List getSelections() + { + return Collections.singletonList("*"); + } + + @Nonnull + @Override + public String toString() + { + return "*"; + } + + @Override + public boolean equals( @Nullable final Object o ) + { + return o instanceof EntitySelectable && ((EntitySelectable) o).getSelections().equals(getSelections()); + } + + @Override + public int hashCode() + { + return getSelections().hashCode(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/EntityLink.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/EntityLink.java new file mode 100644 index 0000000000..f4232eb038 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/EntityLink.java @@ -0,0 +1,180 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.util.ArrayList; +import java.util.List; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.common.collect.Lists; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueBoolean; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +/** + * Helper class for representing links (navigation properties) between entities. + * + * @param + * The type of this link. + * @param + * The type of the entity. + * @param + * The type of the subentity. + */ +@ToString +@EqualsAndHashCode +@RequiredArgsConstructor +public class EntityLink, EntityT extends VdmObject, SubEntityT extends VdmObject> + implements + EntitySelectable +{ + private static final String STAR_SELECTOR = "*"; + + private final List> descendants = new ArrayList<>(); + private final List> selectors = new ArrayList<>(); + + @Nonnull + @Getter + private final String fieldName; + + private EntityLink( + @Nonnull final EntityLink toCopy, + @Nullable final Iterable> addDescendants, + @Nullable final Iterable> addSelectors ) + { + this(toCopy.fieldName); + descendants.addAll(toCopy.descendants); + selectors.addAll(toCopy.selectors); + + if( addDescendants != null ) { + descendants.addAll(Lists.newArrayList(addDescendants)); + } + + if( addSelectors != null ) { + selectors.addAll(Lists.newArrayList(addSelectors)); + } + } + + /** + * Copy constructor. + * + * @param toCopy + * The link to copy. + */ + protected EntityLink( @Nonnull final EntityLink toCopy ) + { + this(toCopy, null, null); + } + + /** + * Used in combination with {@link FluentHelperRead#select(Object[]) FluentHelperRead.select} when expanding a + * navigation property to specify which fields of that navigation property to select, and which navigation + * properties of that navigation property to expand. + * + * @param selectors + * Array of fields to select and/or navigation properties to expand. + * @return Selector for {@link FluentHelperRead#select(Object[]) FluentHelperRead.select}. + */ + @SuppressWarnings( { "unchecked", "varargs" } ) + @SafeVarargs + @Nonnull + public final LinkT select( @Nonnull final EntitySelectable... selectors ) + { + final List> additionalDescendants = new ArrayList<>(); + final List> additionalSelectors = new ArrayList<>(); + + for( final EntitySelectable select : selectors ) { + if( select instanceof EntityLink ) { + additionalDescendants.add((EntityLink) select); + } else { + additionalSelectors.add(select); + } + } + + final EntityLink toTranslate = + new EntityLink<>(this, additionalDescendants, additionalSelectors); + + return translateLinkType(toTranslate); + } + + /* + * may not be not required, since getSelections() provide implicit expand definitions. + * E.g. select(to_BusinessPartnerRole/*) => expand(to_BusinessPartner) + */ + + /** + * Returns a list of expansions for this link. + * + * @return A list of expansions for this link. + */ + @Nonnull + public List getExpansions() + { + if( descendants.isEmpty() ) { + return Lists.newArrayList(getFieldName()); + } + + final List result = new ArrayList<>(); + for( final EntityLink descendant : descendants ) { + for( final String name : descendant.getExpansions() ) { + result.add(getFieldName() + "/" + name); + } + } + return result; + } + + @Nonnull + @Override + public List getSelections() + { + final List result = new ArrayList<>(); + if( selectors.isEmpty() && descendants.isEmpty() ) { + result.add(getFieldName() + "/" + STAR_SELECTOR); + } + for( final EntitySelectable field : selectors ) { + result.add(getFieldName() + "/" + field.getFieldName()); + } + + for( final EntityLink descendant : descendants ) { + for( final String name : descendant.getSelections() ) { + result.add(getFieldName() + "/" + name); + } + } + return result; + } + + /** + * Returns the given {@code link} in a type-safe manner. + * + * @param link + * The link to cast. + * @return The given {@code link} in a type-safe manner. + */ + @SuppressWarnings( "unchecked" ) + @Nonnull + protected LinkT translateLinkType( final EntityLink link ) + { + return (LinkT) link; + } + + /** + * Add a filter expression on a single navigation property. + * + * @param filterExpression + * The filter to apply. + * @return A new expression builder that includes the given filter. + */ + @Nonnull + protected ExpressionFluentHelper filterOnOneToOneLink( + @Nonnull final ExpressionFluentHelper filterExpression ) + { + final ValueBoolean exp = + ( protocol, prefixes ) -> getFieldName() + + "/" + + filterExpression.getDelegateExpressionWithoutOuterParentheses().getExpression(protocol, prefixes); + return new ExpressionFluentHelper<>(exp); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/EntitySelectable.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/EntitySelectable.java new file mode 100644 index 0000000000..833acdcba1 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/EntitySelectable.java @@ -0,0 +1,37 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.util.List; + +import javax.annotation.Nonnull; + +import com.google.common.collect.Lists; + +/** + * Interface to enable management of OData entity selectors. This interface is used by + * {@link com.sap.cloud.sdk.datamodel.odata.helper.EntityField EntityField} and + * {@link com.sap.cloud.sdk.datamodel.odata.helper.EntityLink EntityLink}. + * + * @param + * The generic entity type. + */ +public interface EntitySelectable +{ + /** + * Get the field name of OData entity property. + * + * @return The field name + */ + @Nonnull + String getFieldName(); + + /** + * Get the select expression that represents the OData entity property. + * + * @return The serialized select terms. + */ + @Nonnull + default List getSelections() + { + return Lists.newArrayList(getFieldName()); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ExpressionFluentHelper.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ExpressionFluentHelper.java new file mode 100644 index 0000000000..49655ee1c3 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ExpressionFluentHelper.java @@ -0,0 +1,156 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueBoolean; + +import lombok.extern.slf4j.Slf4j; + +/** + * Template class that represents query expressions. Instances of this object are used in query modifier methods of the + * entity fluent helpers. + * + * Use either the expression methods from EntityField instances, or the logical operators + * {@link #and(ExpressionFluentHelper) and} and {@link #or(ExpressionFluentHelper) or} as methods in this class. + * Negation can be achieved by {@link #not() not}. Every logical operator creates and returns a new instance based on + * the original expression object. Instantiating objects from this class directly can cause undefined results. + * + * @see ExpressionFluentHelper#not(ExpressionFluentHelper) + * + * @param + * VdmObject that the expression is operating on. + */ +@Slf4j +public class ExpressionFluentHelper +{ + private final ValueBoolean delegateExpression; + + /** + * Creates a new helper based on an arbitrary, untyped filter expression. + *

+ * Instances of this class can be used to pass an unchecked {@link ValueBoolean} to fluent helpers. This approach + * discards type safety and is generally discouraged. + * + * @param delegateExpression + * The expression to delegate to. + */ + public ExpressionFluentHelper( @Nonnull final ValueBoolean delegateExpression ) + { + this.delegateExpression = delegateExpression; + } + + /** + *

+ * Boolean OR expression fluent helper. + *

+ *

+ * Please note:
+ * Filter expressions chained together by logical operators are interpreted in the same order as their corresponding + * methods are called. Since the Java language evaluates method calls from left to right, the fluent API design is + * following the same principle. The implicit precedence is following the method invocation and not the underlying, + * logical operators: + * + *

+     *     A.or(B).and(C) <=> (A.or(B)).and(C)
+     * 
+ * + *

+ *

+ * Recommendation:
+ * Incorporate parentheses or introduce variables to reflect combined expressions: + * + *

+     *     var AorB = A.or(B)
+     *     AorB.and(C)
+     * 
+ * + *

+ * + * + * @param disjunctExpression + * Other expression to combine with. + * + * @return Fluent helper that represents a ((this) || other) expression. + */ + @Nonnull + public ExpressionFluentHelper or( @Nonnull final ExpressionFluentHelper disjunctExpression ) + { + return new ExpressionFluentHelper<>(delegateExpression.or(disjunctExpression.delegateExpression)); + } + + /** + *

+ * Boolean AND expression fluent helper. + *

+ *

+ * Please note:
+ * Filter expressions chained together by logical operators are interpreted in the same order as their corresponding + * methods are called. Since the Java language evaluates method calls from left to right, the fluent API design is + * following the same principle. The implicit precedence is following the method invocation and not the underlying, + * logical operators: + * + *

+     *     A.or(B).and(C) <=> (A.or(B)).and(C)
+     * 
+ * + *

+ *

+ * Recommendation:
+ * Incorporate parentheses or introduce variables to reflect combined expressions: + * + *

+     *     var AorB = A.or(B)
+     *     AorB.and(C)
+     * 
+ * + *

+ * + * @param conjunctExpression + * Other expression to combine with. + * + * @return Fluent helper that represents a ((this) && other) expression. + */ + @Nonnull + public ExpressionFluentHelper and( @Nonnull final ExpressionFluentHelper conjunctExpression ) + { + return new ExpressionFluentHelper<>(delegateExpression.and(conjunctExpression.delegateExpression)); + } + + /** + * Boolean NOT expression fluent helper. + * + * @return Fluent helper that represents a not(this) expression. + */ + @Nonnull + public ExpressionFluentHelper not() + { + return new ExpressionFluentHelper<>(delegateExpression.not()); + } + + /** + * Boolean NOT expression fluent helper. + * + * @param + * The type argument for the returned {@link ExpressionFluentHelper} + * @param expression + * expression to be negated. + * + * @return Fluent helper that represents a not(expression) expression. + */ + @Nonnull + public static ExpressionFluentHelper not( @Nonnull final ExpressionFluentHelper expression ) + { + return expression.not(); + } + + @Nonnull + ValueBoolean getDelegateExpressionWithoutOuterParentheses() + { + return ( prefixes, protocol ) -> { + final String expression = delegateExpression.getExpression(prefixes, protocol); + return expression.startsWith("(") && expression.endsWith(")") + ? expression.substring(1, expression.length() - 1) + : expression; + }; + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperBasic.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperBasic.java new file mode 100644 index 0000000000..db1bffed97 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperBasic.java @@ -0,0 +1,232 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +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.query.StructuredQuery; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataUriFactory; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Representation of any OData request as a fluent interface for further configuring the request and + * {@link #executeRequest(Destination) executing} it. + * + * @param + * The fluent helper type. + * @param + * The type of the entity this OData request operates on, if any. + * @param + * The type of the result entity, if any. + */ +@Slf4j +public abstract class FluentHelperBasic implements FluentHelperExecutable +{ + @Nonnull + @Getter( AccessLevel.PROTECTED ) + private final String servicePath; + + /** + * The entity collection to send the OData requests to + */ + @Nullable + protected String entityCollection = null; + + /** + * A map containing the headers to be used for all requests that are part of this FluentHelper implementation. + */ + private final Map headers = new LinkedHashMap<>(); + + /** + * A map containing the custom query parameters to be used only for the actual request of this FluentHelper + * implementation. + */ + @Getter( AccessLevel.PROTECTED ) + private final Map parametersForRequestOnly = new LinkedHashMap<>(); + + /** + * Returns a class object of the type this fluent helper works with. + * + * @return A class object of the handled type. + */ + @Nonnull + protected abstract Class getEntityClass(); + + /** + * Instantiates this fluent helper using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param entityCollection + * The entity collection to direct the requests to. + */ + public FluentHelperBasic( @Nonnull final String servicePath, @Nullable final String entityCollection ) + { + this.servicePath = servicePath; + this.entityCollection = entityCollection; + } + + /** + * Returns the current fluent helper instance. + * + * @return The current fluent helper instance. + */ + @SuppressWarnings( "unchecked" ) + @Nonnull + protected FluentHelperT getThis() + { + return (FluentHelperT) this; + } + + @Nullable + @Override + public abstract ResultT executeRequest( @Nonnull final Destination destination ); + + /** + * Get all headers for explicit and implicit requests. + * + * @return a map containing the headers to be used for all requests that are part of this FluentHelper + * implementation. + */ + protected Map getHeaders() + { + return headers; + } + + /** + * Gives the option to specify custom HTTP headers. The returned object allows to specify the requests the headers + * should be used in. + * + * @param key + * Name of the (first) desired HTTP header parameter. + * @param value + * Value of the (first) desired HTTP header parameter. + * + * @return A fluent helper to specify further headers and their intended usage. + */ + @Nonnull + public FluentHelperT withHeader( @Nonnull final String key, @Nullable final String value ) + { + headers.put(key, value); + return getThis(); + } + + /** + * Gives the option to specify a map of custom HTTP headers. The returned object allows to specify the requests the + * headers should be used in. + * + * @param map + * A map of HTTP header key/value pairs. + * @return A fluent helper to specify further headers and their intended usage. + */ + @Nonnull + public FluentHelperT withHeaders( @Nonnull final Map map ) + { + headers.putAll(map); + return getThis(); + } + + /** + * Gives the option to specify custom query parameters for the request. The passed parameter value will be encoded + * with percentage encoding. + * + *

+ * Note: It is recommended to only use this function for query parameters which are not supported + * by the VDM by default. Using this function to bypass fluent helper method calls can lead to unsupported response + * handling. There is no contract on the order or priority of parameters added to the query. + *

+ * + *

+ * Example: Use the query option $search to reduce the result set, leaving only + * entities which match the specified search expression. This feature is supported in protocol OData v4. + * + *

+     * new DefaultBusinessPartnerService().getAllBusinessPartner().withQueryParameter("$search", "Köln OR Cologne")
+     * 
+ *

+ * + * @param key + * Name of the query parameter. + * @param value + * Unencoded value of the query parameter. + * + * @return The same fluent helper. + */ + @Nonnull + protected FluentHelperT withQueryParameter( @Nonnull final String key, @Nullable final String value ) + { + parametersForRequestOnly.put(key, value); + return getThis(); + } + + /** + * Query modifier to limit which field values of the entity get fetched and populated, and to specify which + * navigation properties to expand. If this method is never called, then all fields will be fetched and populated, + * and no navigation properties expanded. But if this method is called at least once, then only the specified fields + * will be fetched and populated. Calling this multiple times will combine the set(s) of fields and expansions of + * each call. + * + * @param fields + * Fields to select and/or navigation properties to expand. + * + * @param delegateSelect + * Handler to accept simple property names being put inside a delegate "select" query option. + * @param delegateExpand + * Handler to accept navigation properties being put inside a delegate "expand" query option. + * @return The same fluent helper with this query modifier applied. + */ + @Nonnull + FluentHelperT select( + @Nonnull final Iterable> fields, + @Nonnull final Consumer delegateSelect, + @Nonnull final Consumer delegateExpand ) + { + for( final EntitySelectable field : fields ) { + final List selections = field.getSelections(); + + // add items to selections + selections.forEach(delegateSelect); + + // add items to expansions + for( final String fieldName : selections ) { + final int lastSlash = fieldName.lastIndexOf("/"); + if( lastSlash > 0 ) { + final String expandString = fieldName.substring(0, lastSlash); + final StructuredQuery q = StructuredQuery.asNestedQueryOnProperty(expandString, ODataProtocol.V2); + delegateExpand.accept(q); + } + } + } + + return getThis(); + } + + /** + * Translate this OData v2 request into a OData request object extending {@link ODataRequestGeneric}. + * + * @return A protocol agnostic OData request instance. + */ + @Nonnull + public abstract ODataRequestGeneric toRequest(); + + @Nonnull + RequestT addHeadersAndCustomParameters( @Nonnull final RequestT request ) + { + getHeaders().forEach(request::addHeader); + + getParametersForRequestOnly() + .forEach(( key, value ) -> request.addQueryParameter(key, ODataUriFactory.encodeQuery(value))); + + return request; + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperByKey.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperByKey.java new file mode 100644 index 0000000000..41792307ed --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperByKey.java @@ -0,0 +1,128 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.util.Arrays; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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.query.StructuredQuery; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestReadByKey; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +/** + * Representation of an OData request to retrieve an entity by its key as a fluent interface for further configuring the + * request and {@link #executeRequest(Destination) executing} it. + * + * @param + * The fluent helper type. + * @param + * The type of the result entity. + * @param + * The type of the class that represents fields of the entity. + */ +public abstract class FluentHelperByKey, SelectableT> + extends + FluentHelperBasic +{ + private final StructuredQuery delegateQuery; + + /** + * Instantiates this fluent helper using the given service path and entity collection to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * + * @param entityCollection + * The entity collection to direct the requests to. + */ + public FluentHelperByKey( @Nonnull final String servicePath, @Nonnull final String entityCollection ) + { + super(servicePath, entityCollection); + delegateQuery = StructuredQuery.onEntity(entityCollection, ODataProtocol.V2); + } + + /** + * Getter for a map containing the OData name of key properties, each mapped to the value to search by. + * + * @return A name-value mapping for the OData key properties. + */ + @Nonnull + protected abstract Map getKey(); + + @Override + @Nonnull + public ODataRequestReadByKey toRequest() + { + final ODataEntityKey entityKey = ODataEntityKey.of(getKey(), ODataProtocol.V2); + final String queryString = delegateQuery.getEncodedQueryString(); + + final ODataRequestReadByKey request = + new ODataRequestReadByKey(getServicePath(), entityCollection, entityKey, queryString, ODataProtocol.V2); + + return super.addHeadersAndCustomParameters(request); + } + + @Override + @Nonnull + public FluentHelperT withQueryParameter( @Nonnull final String key, @Nullable final String value ) + { + return super.withQueryParameter(key, value); + } + + /** + * Query modifier to limit which field values of the entity get fetched & populated. If this method is never + * called, then all fields will be fetched & populated. But if this method is called at least once, then only + * the specified fields will be fetched & populated. Calling this multiple times will combine the set(s) of + * fields of each call. + * + * @param fields + * Array of fields to select. + * + * @return The same fluent helper with the provided fields selected. + */ + @SuppressWarnings( "unchecked" ) + @Nonnull + public FluentHelperT select( @Nonnull final SelectableT... fields ) + { + final Iterable> selectableFields = (Iterable>) Arrays.asList(fields); + return super.select(selectableFields, delegateQuery::select, delegateQuery::select); + } + + @Override + @Nonnull + public EntityT executeRequest( @Nonnull final Destination destination ) + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + final ODataRequestResultGeneric response = toRequest().execute(httpClient); + + final EntityT result = response.as(getEntityClass()); + + // use version identifier from header if present + response.getVersionIdentifierFromHeader().peek(result::setVersionIdentifier); + + result.attachToService(getServicePath(), destination); + return result; + } + + /** + * Activates CSRF token retrieval for this OData request. + * + * @return The same fluent helper that will now fetch a CSRF token. + * @deprecated CSRF token handling is now performed automatically by the underlying HTTP client. This method is a + * no-op retained only for source compatibility, as read requests never require a CSRF token. It is + * scheduled for removal. + */ + @Deprecated + @Nonnull + public FluentHelperT withCsrfToken() + { + return getThis(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperCount.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperCount.java new file mode 100644 index 0000000000..8db826f924 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperCount.java @@ -0,0 +1,86 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException; +import com.sap.cloud.sdk.cloudplatform.connectivity.exception.HttpClientInstantiationException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestCount; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +/** + * Representation of an OData query for count, as a fluent interface for further configuring the request and + * {@link #executeRequest(Destination) executing} it. + */ +@RequiredArgsConstructor( access = AccessLevel.PACKAGE ) +public class FluentHelperCount +{ + private final ODataRequestCount request; + + /** + * Executes the underlying query for count, using the stored values, plus any query modifiers that were previously + * called. + * + * @param destination + * The target system this request should be issued against. + * @return The number of tuples that match the criteria specified in the query. + * + * @throws DestinationAccessException + * If there is an issue accessing the + * {@link com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination}. + * @throws HttpClientInstantiationException + * If there is an issue creating the {@link HttpClient}. + * @throws com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException + * If the OData request execution failed. Please find the documentation for {@link ODataException} + * possible sub-types and error scenarios they can occur in. + */ + public long executeRequest( @Nonnull final Destination destination ) + { + final ODataRequestCount requestCount = toRequest(); + + final ODataRequestResultGeneric result = + requestCount.execute(ApacheHttpClient5Accessor.getHttpClient(destination)); + + return result.as(Long.class); + } + + /** + * Creates an instance of {@link ODataRequestCount}. + *

+ * The following settings are used: + *

    + *
  • the endpoint URL
  • + *
  • the entity collection name
  • + *
  • the OData query
  • + *
+ * + * @return A new count request with the given configuraiton. + */ + @Nonnull + public ODataRequestCount toRequest() + { + return request; + } + + /** + * Activates CSRF token retrieval for this OData request. + * + * @return The same fluent helper that will now fetch a CSRF token. + * @deprecated CSRF token handling is now performed automatically by the underlying HTTP client. This method is a + * no-op retained only for source compatibility, as read requests never require a CSRF token. It is + * scheduled for removal. + */ + @Deprecated + @Nonnull + public FluentHelperCount withCsrfToken() + { + return this; + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperCreate.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperCreate.java new file mode 100644 index 0000000000..240235d30d --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperCreate.java @@ -0,0 +1,148 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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.ODataSerializationException; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestCreate; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import io.vavr.control.Option; +import io.vavr.control.Try; +import lombok.extern.slf4j.Slf4j; + +/** + * Representation of an OData create request as a fluent interface for further configuring the request and + * {@link #executeRequest(Destination) executing} it. + * + * @param + * The fluent helper type. + * @param + * The type of the entity to create. + */ +@Slf4j +public abstract class FluentHelperCreate> + extends + FluentHelperModification +{ + private EntityLink, ?, EntityT> linkFromParentEntity; + private VdmEntity parentEntity; + + /** + * Instantiates this fluent helper using the given service path and entity collection to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * + * @param entityCollection + * The entity collection to direct the requests to. + */ + public FluentHelperCreate( @Nonnull final String servicePath, @Nonnull final String entityCollection ) + { + super(servicePath, entityCollection); + } + + /** + * Getter for the VDM representation of the entity to be created. + * + * @return The entity that should be created by calling the {@link #executeRequest(Destination)} method. + */ + @Nonnull + protected abstract EntityT getEntity(); + + @SuppressWarnings( "unchecked" ) + @Override + @Nonnull + protected Class getEntityClass() + { + return (Class) getEntity().getClass(); + } + + @Override + @Nonnull + public ModificationResponse executeRequest( @Nonnull final Destination destination ) + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + + final ODataRequestResultGeneric result = toRequest().execute(httpClient); + + return ModificationResponse.of(result, getEntity(), destination); + } + + @Override + @Nonnull + public ODataRequestCreate toRequest() + { + final EntityT entity = getEntity(); + final ODataResourcePath resourcePath; + + if( linkFromParentEntity != null && parentEntity != null ) { + resourcePath = + ODataResourcePath + .of(parentEntity.getEntityCollection(), ODataEntityKey.of(parentEntity.getKey(), ODataProtocol.V2)) + .addSegment(linkFromParentEntity.getFieldName()); + } else { + resourcePath = ODataResourcePath.of(getEntityCollection()); + } + + final String serializedEntity = + Try + .of(() -> ODataEntitySerializer.serializeEntityForCreate(entity)) + .getOrElseThrow( + e -> new ODataSerializationException( + new ODataRequestCreate(getServicePath(), resourcePath, "", ODataProtocol.V2), + entity, + "Failed to serialize HTTP request entity of type " + getEntityClass().getSimpleName(), + e)); + + final ODataRequestCreate request = + new ODataRequestCreate(getServicePath(), resourcePath, serializedEntity, ODataProtocol.V2); + + return super.addHeadersAndCustomParameters(request); + } + + /** + * This function allows to create a new entity via an existing parent entity. Parent means that the existing entity + * has to be related to the entity to be created via a navigation property. Thus, the function requires the caller + * to provide an {@code EntityLink} that represents such a navigation property. + * {@code ParentEntityT} can represent any entity that is related to the entity to be created. {@code EntityT} + * represents the type of the entity to be created. Furthermore, the function requires an instance of type + * {@code ParentEntityT}. This instance must hold the key fields used to identify it. + * + * NOTE: While any EntityLink provided by the OData VDM satisfying these type constraints allows you to call this + * function, the service may NOT allow this kind of create operation for the respective navigation property. Thus, + * calling this function without knowledge of the underlying OData service can result in failing requests. + * + * @param entity + * An instance of the related entity that MUST hold values for the respective key fields. + * @param entityLink + * An {@link EntityLink} representing a navigation property. + * + * @return The same fluent helper configured to use the provided navigation property for creation. + * @param + * The generic parent entity type in this navigation property relation. + */ + @Nonnull + public > FluentHelperT asChildOf( + @Nullable final ParentEntityT entity, + @Nullable final EntityLink, ParentEntityT, EntityT> entityLink ) + { + linkFromParentEntity = entityLink; + parentEntity = entity; + + return getThis(); + } + + @Nonnull + private String getEntityCollection() + { + return Option.of(entityCollection).getOrElse(() -> getEntity().getEntityCollection()); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperDelete.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperDelete.java new file mode 100644 index 0000000000..150c1b3cbf --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperDelete.java @@ -0,0 +1,128 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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.ETagSubmissionStrategy; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestDelete; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import io.vavr.control.Option; + +/** + * Representation of an OData delete request as a fluent interface for further configuring the request and + * {@link #executeRequest(Destination) executing} it. + * + * @param + * The fluent helper type. + * @param + * The type of the entity to delete. + */ +public abstract class FluentHelperDelete> + extends + FluentHelperModification +{ + private ETagSubmissionStrategy eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_ETAG_FROM_ENTITY; + + /** + * Instantiates this fluent helper using the given service path and entity collection to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param entityCollection + * The entity collection to direct the requests to. + */ + public FluentHelperDelete( @Nonnull final String servicePath, @Nonnull final String entityCollection ) + { + super(servicePath, entityCollection); + } + + /** + * The entity object to be deleted by calling the {@link #executeRequest(Destination)} method. + * + * @return The entity to be deleted. + */ + @Nonnull + protected abstract EntityT getEntity(); + + @SuppressWarnings( "unchecked" ) + @Override + @Nonnull + protected Class getEntityClass() + { + return (Class) getEntity().getClass(); + } + + /** + * The delete request will ignore any version identifier present on the entity and not send an `If-Match` header. + *

+ * Warning: This might lead to a response from the remote system that the `If-Match` header is missing. + *

+ * It depends on the implementation of the remote system whether the `If-Match` header is expected. + * + * @return The same request builder that will not send the `If-Match` header in the update request + */ + @Nonnull + public FluentHelperT disableVersionIdentifier() + { + eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_NO_ETAG; + return getThis(); + } + + /** + * The delete request will ignore any version identifier present on the entity and delete the entity, regardless of + * any changes on the remote entity. + *

+ * Warning: Be careful with this option, as this might overwrite any changes made to the remote + * representation of this object. + * + * @return The same request builder that will ignore the version identifier of the entity to update + */ + @Nonnull + public FluentHelperT matchAnyVersionIdentifier() + { + eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_ANY_MATCH_ETAG; + return getThis(); + } + + @Override + @Nonnull + public ModificationResponse executeRequest( @Nonnull final Destination destination ) + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + + final ODataRequestResultGeneric result = toRequest().execute(httpClient); + + return ModificationResponse.of(result, getEntity(), destination); + } + + @Override + @Nonnull + public ODataRequestDelete toRequest() + { + final EntityT entity = getEntity(); + final String versionIdentifier = + eTagSubmissionStrategy.getHeaderFromVersionIdentifier(entity.getVersionIdentifier()); + + final ODataRequestDelete request = + new ODataRequestDelete( + getServicePath(), + getEntityCollection(), + ODataEntityKey.of(entity.getKey(), ODataProtocol.V2), + versionIdentifier, + ODataProtocol.V2); + + return super.addHeadersAndCustomParameters(request); + } + + @Nonnull + private String getEntityCollection() + { + return Option.of(entityCollection).getOrElse(() -> getEntity().getEntityCollection()); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperExecutable.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperExecutable.java new file mode 100644 index 0000000000..5d7e2a7864 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperExecutable.java @@ -0,0 +1,38 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException; +import com.sap.cloud.sdk.cloudplatform.connectivity.exception.HttpClientInstantiationException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException; + +/** + * Representation of any OData V2 request that can be executed. + * + * @param + * The type of the result entity, if any. + */ +public interface FluentHelperExecutable +{ + /** + * Executes this request. + * + * @param destination + * The target system this request should be issued against. + * @return A response according to the query criteria. + * + * @throws DestinationAccessException + * If there is an issue accessing the + * {@link com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination}. + * @throws HttpClientInstantiationException + * If there is an issue creating the {@link HttpClient}. + * @throws ODataException + * If the OData request execution failed. + */ + @Nullable + ResultT executeRequest( @Nonnull final Destination destination ); +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperFactory.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperFactory.java new file mode 100644 index 0000000000..e88436afed --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperFactory.java @@ -0,0 +1,280 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Function; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.classic.methods.HttpUriRequest; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +/** + * Utility class to conveniently create fluent helper instances. + */ +@RequiredArgsConstructor( access = AccessLevel.PRIVATE ) +class FluentHelperFactory +{ + @Nonnull + private final String servicePath; + + static FluentHelperFactory withServicePath( @Nonnull final String servicePath ) + { + return new FluentHelperFactory(servicePath); + } + + < + FluentHelperT extends FluentHelperByKey, EntityT extends VdmEntity, SelectableT> + FluentHelperByKey + readByKey( + @Nonnull final Class entityClass, + @Nonnull final String entityCollection, + @Nonnull final Map key ) + { + return new FluentHelperByKey<>(servicePath, entityCollection) + { + @Nonnull + @Override + protected Map getKey() + { + return key; + } + + @Nonnull + @Override + protected Class getEntityClass() + { + return entityClass; + } + }; + } + + < + FluentHelperT extends FluentHelperRead, EntityT extends VdmEntity, SelectableT> + FluentHelperRead + read( @Nonnull final Class entityClass, @Nonnull final String entityCollection ) + { + return new FluentHelperRead<>(servicePath, entityCollection) + { + @Nonnull + @Override + protected Class getEntityClass() + { + return entityClass; + } + }; + } + + < + FluentHelperT extends FluentHelperCreate, EntityT extends VdmEntity> + FluentHelperCreate + create( @Nonnull final String entityCollection, @Nonnull final EntityT entity ) + { + return new FluentHelperCreate<>(servicePath, entityCollection) + { + @Nonnull + @Override + protected EntityT getEntity() + { + return entity; + } + + @SuppressWarnings( "unchecked" ) + @Nonnull + @Override + protected Class getEntityClass() + { + return (Class) entity.getClass(); + } + }; + } + + < + FluentHelperT extends FluentHelperCreate, EntityT extends VdmEntity> + FluentHelperCreate + create( @Nonnull final EntityT entity ) + { + return create(entity.getEntityCollection(), entity); + } + + < + FluentHelperT extends FluentHelperDelete, EntityT extends VdmEntity> + FluentHelperDelete + delete( @Nonnull final String entityCollection, @Nonnull final EntityT entity ) + { + return new FluentHelperDelete<>(servicePath, entityCollection) + { + @Nonnull + @Override + protected EntityT getEntity() + { + return entity; + } + + @SuppressWarnings( "unchecked" ) + @Nonnull + @Override + protected Class getEntityClass() + { + return (Class) entity.getClass(); + } + }; + } + + < + FluentHelperT extends FluentHelperDelete, EntityT extends VdmEntity> + FluentHelperDelete + delete( @Nonnull final EntityT entity ) + { + return delete(entity.getEntityCollection(), entity); + } + + < + FluentHelperT extends FluentHelperUpdate, EntityT extends VdmEntity> + FluentHelperUpdate + update( @Nonnull final String entityCollection, @Nonnull final EntityT entity ) + { + return new FluentHelperUpdate<>(servicePath, entityCollection) + { + @Nonnull + @Override + protected EntityT getEntity() + { + return entity; + } + + @SuppressWarnings( "unchecked" ) + @Nonnull + @Override + protected Class getEntityClass() + { + return (Class) entity.getClass(); + } + }; + } + + < + FluentHelperT extends FluentHelperUpdate, EntityT extends VdmEntity> + FluentHelperUpdate + update( @Nonnull final EntityT entity ) + { + return update(entity.getEntityCollection(), entity); + } + + < + FluentHelperT extends FluentHelperFunction, ObjectT, ResultT> + FluentHelperFunction + function( + @Nonnull final Map parameters, + @Nonnull final String functionName, + @Nonnull final Class objectClass, + @Nonnull final Function requestHandler, + @Nonnull final BiFunction, Destination, ResultT> executeHandler ) + { + return new FluentHelperFunction<>(servicePath) + { + @Nonnull + @Override + protected Map getParameters() + { + return parameters; + } + + @Nonnull + @Override + protected String getFunctionName() + { + return functionName; + } + + @Nonnull + @Override + protected HttpUriRequest createRequest( @Nonnull final URI uri ) + { + return requestHandler.apply(uri); + } + + @Nonnull + @Override + protected Class getEntityClass() + { + return objectClass; + } + + @Nullable + @Override + public ResultT executeRequest( @Nonnull final Destination destination ) + { + return executeHandler.apply(this, destination); + } + + /** + * {@inheritDoc} The special treatment for SAP S/4 HANA OData v2 responses is considered. + */ + @Override + @Nullable + protected JsonElement refineJsonResponse( @Nullable JsonElement jsonElement ) + { + if( jsonElement instanceof JsonObject && ((JsonObject) jsonElement).has(getFunctionName()) ) { + jsonElement = ((JsonObject) jsonElement).get(getFunctionName()); + } + return super.refineJsonResponse(jsonElement); + } + }; + } + + < + FluentHelperT extends FluentHelperFunction, ObjectT> + FluentHelperFunction + functionSinglePost( + @Nonnull final Map parameters, + @Nonnull final String functionName, + @Nonnull final Class objectClass ) + { + return function(parameters, functionName, objectClass, HttpPost::new, FluentHelperFunction::executeSingle); + } + + < + FluentHelperT extends FluentHelperFunction, ObjectT> + FluentHelperFunction + functionSingleGet( + @Nonnull final Map parameters, + @Nonnull final String functionName, + @Nonnull final Class objectClass ) + { + return function(parameters, functionName, objectClass, HttpGet::new, FluentHelperFunction::executeSingle); + } + + < + FluentHelperT extends FluentHelperFunction>, ObjectT> + FluentHelperFunction> + functionMultiplePost( + @Nonnull final Map parameters, + @Nonnull final String functionName, + @Nonnull final Class objectClass ) + { + return function(parameters, functionName, objectClass, HttpPost::new, FluentHelperFunction::executeMultiple); + } + + < + FluentHelperT extends FluentHelperFunction>, ObjectT> + FluentHelperFunction> + functionMultipleGet( + @Nonnull final Map parameters, + @Nonnull final String functionName, + @Nonnull final Class objectClass ) + { + return function(parameters, functionName, objectClass, HttpGet::new, FluentHelperFunction::executeMultiple); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperFunction.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperFunction.java new file mode 100644 index 0000000000..a5df8cd654 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperFunction.java @@ -0,0 +1,243 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.net.URI; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.classic.methods.HttpUriRequest; + +import com.google.gson.JsonElement; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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.ODataException; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataFunctionParameters; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestAction; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestFunction; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import io.vavr.Lazy; +import lombok.extern.slf4j.Slf4j; + +/** + * Representation of any OData function import as a fluent interface for further configuring the request and + * {@link #executeRequest(Destination) executing} it. + * + * @param + * The fluent helper type. + * @param + * The type of the object this OData request operates on, if any. + * @param + * The type of the result entity, if any. + */ +@Slf4j +public abstract class FluentHelperFunction + extends + FluentHelperBasic +{ + /** + * Upper-case HTTP method derived from {@link #createRequest(URI)} method. + */ + @SuppressWarnings( "this-escape" ) + private final Lazy httpMethod = Lazy.of(() -> createRequest(URI.create("")).getMethod().toUpperCase()); + + /** + * Instantiates this fluent helper using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + */ + public FluentHelperFunction( @Nonnull final String servicePath ) + { + super(servicePath, null); + } + + /** + * Getter for the map of parameters to be used in the function call. + *

+ * The map maps the ODataName of a parameter to the corresponding unserialized value. + *

+ * Only literal values are allowed as value. No complex or entity objects. + * + * @return A map containing the parameter for the function call. + */ + @Nonnull + protected abstract Map getParameters(); + + /** + * The exact name of the function to be called on the OData Endpoint. + * + * @return The function name on the endpoint. + */ + @Nonnull + protected abstract String getFunctionName(); + + /** + * Creates a request for this function based on the given {@code URI}. + *

+ * Examples for such requests are {@code HttpGet} and {@code HttpPost}. + * + * @param uri + * The {@code URI} the request should target. + * @return The instantiated request. + */ + @Nonnull + protected abstract HttpUriRequest createRequest( @Nonnull final URI uri ); + + /** + * {@inheritDoc} + *

+ * The function import arguments are encoded as HTTP query expressions. + */ + @Nonnull + @Override + public ODataRequestGeneric toRequest() + { + final ODataRequestGeneric functionImportRequest = instantiateRequest(); + return super.addHeadersAndCustomParameters(functionImportRequest); + } + + @Nonnull + private ODataRequestGeneric instantiateRequest() + { + final String servicePath = getServicePath(); + final ODataResourcePath resourcePath = ODataResourcePath.of(getFunctionName()); + + // get and encode function import parameters as query expressions + final String encodedQuery = ODataFunctionParameters.of(getParameters(), ODataProtocol.V2).toEncodedString(); + + switch( httpMethod.get() ) { + case HttpGet.METHOD_NAME: + return new ODataRequestFunction(servicePath, resourcePath, encodedQuery, ODataProtocol.V2); + case HttpPost.METHOD_NAME: + return new ODataRequestAction(servicePath, resourcePath, null, encodedQuery, ODataProtocol.V2); + default: + throw new IllegalStateException("Unexpected HTTP request method for function import: " + httpMethod); + } + } + + /** + * Default implementation for the case that this function returns a single type. + *

+ * This method can be used in subclasses to implement the {@link #executeRequest(Destination)} method. + * + * @param destination + * The destination to run the function against. + * + * @return The single object returned by the function call. Returns {@code null} if the function did not return a + * result. + * + * @throws ODataException + * If the execution of the function failed. + */ + @SuppressWarnings( "checkstyle:IllegalCatch" ) + @Nullable + protected ObjectT executeSingle( @Nonnull final Destination destination ) + throws ODataException + { + final ODataRequestResultGeneric result = executeInternal(destination); + final Class resultType = getEntityClass(); + + if( resultType.equals(Void.class) ) { + return null; + } + + final ObjectT resultObject = result.as(resultType, this::refineJsonResponse); + + if( resultObject instanceof VdmEntity ) { + final VdmEntity entity = (VdmEntity) resultObject; + + // use version identifier from header if present + result.getVersionIdentifierFromHeader().peek(entity::setVersionIdentifier); + + entity.attachToService(getServicePath(), destination); + } + return resultObject; + } + + /** + * Default implementation for the case that this function returns a collection of entries. + *

+ * This method can be used in subclasses to implement the {@link #executeRequest(Destination)} method. + * + * @param destination + * The destination to run the function against. + * + * @return A list of the objects returned by the function call. Returns an empty list if the function did not return + * a result. + * + * @throws ODataException + * If the execution of the function failed. + */ + @Nonnull + @SuppressWarnings( "unchecked" ) + protected List executeMultiple( @Nonnull final Destination destination ) + throws ODataException + { + final ODataRequestResultGeneric result = executeInternal(destination); + + final List resultObjectList = (List) result.asList(getEntityClass()); + + if( !resultObjectList.isEmpty() && resultObjectList.get(0) instanceof VdmEntity ) { + resultObjectList.forEach(entity -> ((VdmEntity) entity).attachToService(getServicePath(), destination)); + } + return resultObjectList; + } + + @Nonnull + private ODataRequestResultGeneric executeInternal( final Destination destination ) + throws ODataException + { + final ODataRequestGeneric functionImportRequest = toRequest(); + return (ODataRequestResultGeneric) functionImportRequest + .execute(ApacheHttpClient5Accessor.getHttpClient(destination)); + } + + /** + * Transform the JSON element from the response to extract a result entity. By default this method returns the + * original object. + * + * @param jsonElement + * The optional response JSON element + * @return The refined JSON element + */ + @Nullable + protected JsonElement refineJsonResponse( @Nullable final JsonElement jsonElement ) + { + return jsonElement; + } + + /** + * Activates CSRF token retrieval for this OData request. + * + * @return The same fluent helper that will now fetch a CSRF token. + * @deprecated CSRF token handling is now performed automatically by the underlying HTTP client. This method is a + * no-op retained only for source compatibility. It is scheduled for removal. + */ + @Deprecated + @Nonnull + public FluentHelperT withCsrfToken() + { + return getThis(); + } + + /** + * Deactivates the CSRF token retrieval for this OData request. This is useful if the server does not support or + * require CSRF tokens as part of the request. + * + * @return The same builder + */ + @Nonnull + public FluentHelperT withoutCsrfToken() + { + withHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER, "true"); + return getThis(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperModification.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperModification.java new file mode 100644 index 0000000000..f580821b4e --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperModification.java @@ -0,0 +1,46 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; + +/** + * Representation of an OData modification request (Create, Update, Delete) as a fluent interface for further + * configuring the request and {@link #executeRequest(Destination) executing} it. + * + * @param + * The fluent helper type. + * @param + * The type of the entity this OData request operates on, if any. + */ +public abstract class FluentHelperModification> + extends + FluentHelperBasic> +{ + /** + * Instantiates this fluent helper using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param entityCollection + * The entity collection to direct the requests to. + */ + public FluentHelperModification( @Nonnull final String servicePath, @Nullable final String entityCollection ) + { + super(servicePath, entityCollection); + } + + /** + * Deactivates the CSRF token retrieval for this OData request. This is useful if the server does not support or + * require CSRF tokens as part of the request. + * + * @return The same builder + */ + @Nonnull + public FluentHelperT withoutCsrfToken() + { + withHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER, "true"); + return getThis(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperRead.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperRead.java new file mode 100644 index 0000000000..3a7b394889 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperRead.java @@ -0,0 +1,298 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.google.common.collect.Streams; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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.query.StructuredQuery; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestCount; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead; + +import lombok.extern.slf4j.Slf4j; + +/** + * Representation of an OData query as a fluent interface for further configuring the request and + * {@link #executeRequest(Destination) executing} it. + * + * @param + * The fluent helper type. + * @param + * The type of the result entity. + * @param + * The type of the {@link EntityField class that represents fields of the entity}. + */ +@Slf4j +public abstract class FluentHelperRead, SelectableT> + extends + FluentHelperBasic> +{ + + private final StructuredQuery delegateQuery; + + /** + * Instantiates this fluent helper using the given service path and entity collection to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * + * @param entityCollection + * The entity collection to direct the requests to. + */ + public FluentHelperRead( @Nonnull final String servicePath, @Nonnull final String entityCollection ) + { + super(servicePath, entityCollection); + + delegateQuery = StructuredQuery.onEntity(entityCollection, ODataProtocol.V2); + } + + @Override + @Nonnull + public ODataRequestRead toRequest() + { + final String queryString = delegateQuery.getEncodedQueryString(); + final ODataRequestRead request = + new ODataRequestRead(getServicePath(), entityCollection, queryString, ODataProtocol.V2); + return super.addHeadersAndCustomParameters(request); + } + + @Override + @Nonnull + public FluentHelperT withQueryParameter( @Nonnull final String key, @Nullable final String value ) + { + return super.withQueryParameter(key, value); + } + + /** + * Query modifier to limit which field values of the entity get fetched and populated, and to specify which + * navigation properties to expand. If this method is never called, then all fields will be fetched and populated, + * and no navigation properties expanded. But if this method is called at least once, then only the specified fields + * will be fetched and populated. Calling this multiple times will combine the set(s) of fields and expansions of + * each call. + * + * @param fields + * Fields to select and/or navigation properties to expand. + * @return The same fluent helper with this query modifier applied. + */ + @SuppressWarnings( "unchecked" ) + @Nonnull + public FluentHelperT select( @Nonnull final SelectableT... fields ) + { + final Iterable> selectableFields = (Iterable>) Arrays.asList(fields); + return super.select(selectableFields, delegateQuery::select, delegateQuery::select); + } + + /** + * Query modifier to sort the set of returned entities by one or more fields. If this method is called more than + * once, then the result entity set will be sorted by each field in the order that the methods were called. + * + * @param field + * Field to sort by. + * @param order + * Sorting direction to use (ascending or descending). + * + * @return The same fluent helper with this query modifier applied. + */ + @Nonnull + public FluentHelperT orderBy( @Nonnull final EntityField field, @Nonnull final Order order ) + { + final com.sap.cloud.sdk.datamodel.odata.client.query.Order clientTypeOrder = + com.sap.cloud.sdk.datamodel.odata.client.query.Order.valueOf(order.toString()); + delegateQuery.orderBy(field.getFieldName(), clientTypeOrder); + + return getThis(); + } + + /** + * Query modifier to limit the number of entities returned. If this method is never called, then the result entity + * list will not be limited in size. If this method is called multiple times, then only the value of the last call + * will be used. + * + * @param top + * Number of entities to limit the result set to. + * @return The same fluent helper with this query modifier applied. + */ + @Nonnull + public FluentHelperT top( @Nonnull final Number top ) + { + delegateQuery.top(top); + return getThis(); + } + + /** + * Query modifier to not return the first N entities of the result set. If this method is never called, then the + * full list will be returned from the first entity. If this method is called multiple times, then only the value of + * the last call will be used. + * + * @param skip + * Number of entities to skip by. + * @return The same fluent helper with this query modifier applied. + */ + @Nonnull + public FluentHelperT skip( @Nonnull final Number skip ) + { + delegateQuery.skip(skip); + return getThis(); + } + + @Override + @Nonnull + public List executeRequest( @Nonnull final Destination destination ) + throws com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException + { + final Iterable> iterablePages = iteratingPages().executeRequest(destination); + final Iterable iterableItems = Iterables.concat(Objects.requireNonNull(iterablePages)); + return Lists.newArrayList(iterableItems); + } + + /** + * Manually explore the individual pages from the lazy-loading entity result-set. The returning object allows for + * performant consumption of all data through server-driven pagination. + * + * @return An instance of {@link FluentHelperExecutable} that allows for lazy iteration through the result-set + * pages. + */ + @Nonnull + public FluentHelperExecutable>> iteratingPages() + { + return this::executeInternal; + } + + /** + * Manually explore the individual entities from the lazy-loading entity result-set. The returning iterable allows + * for performant consumption of all data through server-driven pagination. + * + * @return An instance of {@link FluentHelperExecutable} that allows for lazy iteration through the result-set + * pages. + */ + @Nonnull + public FluentHelperExecutable> iteratingEntities() + { + // concat applies lazy evaluation so individual pages will still be loaded lazily + return destination -> Iterables.concat(executeInternal(destination)); + } + + /** + * Manually explore the individual entities from the lazy-loading entity result-set. The returning Stream allows for + * performant consumption of all data through server-driven pagination. + * + * @return An instance of {@link FluentHelperExecutable} that allows for lazy iteration through the result-set + * pages. + */ + @Nonnull + public FluentHelperExecutable> streamingEntities() + { + // concat applies lazy evaluation so individual pages will still be loaded lazily + return destination -> Streams.stream(Iterables.concat(executeInternal(destination))); + } + + @Nonnull + private Iterable> executeInternal( @Nonnull final Destination destination ) + throws com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + final Iterable> result = toRequest().execute(httpClient).iteratePages(getEntityClass()); + + // Refine lazy iterable to attach destination properties to individual entities in the page lists. + //noinspection StaticPseudoFunctionalStyleMethod,ConstantConditions + return Iterables + .transform( + result, + list -> list + .stream() + .peek(entity -> entity.attachToService(getServicePath(), destination)) // enable lazy loading for navigation properties on entity + .collect(Collectors.toList())); + } + + /** + * Query modifier to restrict the set of returned entities based on the values of one or more fields. If this method + * is never called, then all accessible entities will be fetched. Calling this multiple times will combine the + * filters into one that is the intersection of all filters (filter1 AND filter2 AND ... filterN). + * + * @param expression + * Fluent helper that represents a field value expression. To create this, start with an + * {@link com.sap.cloud.sdk.datamodel.odata.helper.EntityField EntityField} constant in your respective + * entity type for the desired entity field. Then call one of the comparison operators and provide a + * comparison value. Optionally the resulting fluent helper can be chained with other expression fluent + * helpers. + * + * @return The same fluent helper with this query modifier applied. + */ + @Nonnull + public FluentHelperT filter( @Nonnull final ExpressionFluentHelper expression ) + { + delegateQuery.filter(expression.getDelegateExpressionWithoutOuterParentheses()); + return getThis(); + } + + /** + * Query modifier to return only the number of tuples that match the criteria specified in the query. The actual + * tuples are not returned. May be used with all other query modifiers. Any call to top is overloaded with top(0). + * Any call to skip is ignored. + * + * @return The number of tuples that match the criteria specified in the query. + */ + @Nonnull + public FluentHelperCount count() + { + final StructuredQuery prunedQuery = StructuredQuery.onEntity(entityCollection, ODataProtocol.V2); + + delegateQuery.getFilters().forEach(prunedQuery::filter); + delegateQuery.getCustomParameters().forEach(prunedQuery::withCustomParameter); + + final ODataRequestCount requestCount = + new ODataRequestCount( + getServicePath(), + entityCollection, + prunedQuery.getEncodedQueryString(), + ODataProtocol.V2); + + final ODataRequestCount requestCountUpdated = addHeadersAndCustomParameters(requestCount); + + return new FluentHelperCount(requestCountUpdated); + } + + /** + * Set the preferred page size of the OData response. A result-set may be split into multiple pages, each including + * a subset of the entities matching the query. + *

+ * Note: The OData service might ignore the preferred page size setting and may not use pagination + * at all. + * + * @param size + * The preferred page size + * @return This request object with the added parameter. + */ + @Nonnull + public FluentHelperT withPreferredPageSize( final int size ) + { + return withHeader("Prefer", "odata.maxpagesize=" + size); + } + + /** + * Activates CSRF token retrieval for this OData request. + * + * @return The same fluent helper that will now fetch a CSRF token. + * @deprecated CSRF token handling is now performed automatically by the underlying HTTP client. This method is a + * no-op retained only for source compatibility, as read requests never require a CSRF token. It is + * scheduled for removal. + */ + @Deprecated + @Nonnull + public FluentHelperT withCsrfToken() + { + return getThis(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperUpdate.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperUpdate.java new file mode 100644 index 0000000000..299a47dd3e --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperUpdate.java @@ -0,0 +1,293 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.stream.Collectors; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.google.common.annotations.Beta; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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.ODataSerializationException; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FieldReference; +import com.sap.cloud.sdk.datamodel.odata.client.request.ETagSubmissionStrategy; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestUpdate; +import com.sap.cloud.sdk.datamodel.odata.client.request.UpdateStrategy; + +import io.vavr.control.Option; +import lombok.extern.slf4j.Slf4j; + +/** + * Representation of an OData update request as a fluent interface for further configuring the request and + * {@link #executeRequest(Destination) executing} it. + * + * @param + * The fluent helper type. + * @param + * The type of the entity to update. + */ +@Slf4j +public abstract class FluentHelperUpdate> + extends + FluentHelperModification +{ + private final Collection> includedFields = new HashSet<>(); + private final Collection> excludedFields = new HashSet<>(); + private UpdateStrategy updateStrategy = UpdateStrategy.MODIFY_WITH_PATCH; + private ETagSubmissionStrategy eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_ETAG_FROM_ENTITY; + + /** + * Instantiates this fluent helper using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param entityCollection + * The entity collection to direct the requests to. + */ + public FluentHelperUpdate( @Nonnull final String servicePath, @Nonnull final String entityCollection ) + { + super(servicePath, entityCollection); + } + + /** + * The entity object to be updated by calling the {@link #executeRequest(Destination)} method. + * + * @return The entity to be updated. + */ + protected abstract EntityT getEntity(); + + @SuppressWarnings( "unchecked" ) + @Nonnull + @Override + protected Class getEntityClass() + { + return (Class) getEntity().getClass(); + } + + /** + * The update request will ignore any version identifier present on the entity and not send an `If-Match` header. + *

+ * Warning: This might lead to a response from the remote system that the `If-Match` header is missing. + *

+ * It depends on the implementation of the remote system whether the `If-Match` header is expected. + * + * @return The same request builder that will not send the `If-Match` header in the update request + */ + @Nonnull + public FluentHelperT disableVersionIdentifier() + { + eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_NO_ETAG; + return getThis(); + } + + /** + * The update request will ignore any version identifier present on the entity and update the entity, regardless of + * any changes on the remote entity. + *

+ * Warning: Be careful with this option, as this might overwrite any changes made to the remote + * representation of this object. + * + * @return The same request builder that will ignore the version identifier of the entity to update + */ + @Nonnull + public FluentHelperT matchAnyVersionIdentifier() + { + eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_ANY_MATCH_ETAG; + return getThis(); + } + + @Override + @Nonnull + public ModificationResponse executeRequest( @Nonnull final Destination destination ) + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + + final ODataRequestResultGeneric result = toRequest().execute(httpClient); + + return ModificationResponse.of(result, getEntity(), destination); + } + + @Override + @Nonnull + public ODataRequestUpdate toRequest() + { + final EntityT entity = getEntity(); + final String serializedEntity = getSerializedEntity(); + final String versionIdentifier = + eTagSubmissionStrategy.getHeaderFromVersionIdentifier(entity.getVersionIdentifier()); + + final ODataRequestUpdate request = + new ODataRequestUpdate( + getServicePath(), + getEntityCollection(), + ODataEntityKey.of(entity.getKey(), ODataProtocol.V2), + serializedEntity, + updateStrategy, + versionIdentifier, + ODataProtocol.V2); + + return super.addHeadersAndCustomParameters(request); + } + + @Nonnull + private String getSerializedEntity() + { + final EntityT entity = getEntity(); + try { + final List fieldsToExcludeUpdate = + excludedFields + .stream() + .map(EntitySelectable::getFieldName) + .map(FieldReference::of) + .collect(Collectors.toList()); + + final List fieldsToIncludeInUpdate = + includedFields + .stream() + .map(EntitySelectable::getFieldName) + .map(FieldReference::of) + .collect(Collectors.toList()); + + switch( updateStrategy ) { + case REPLACE_WITH_PUT: + return ODataEntitySerializer.serializeEntityForUpdatePut(entity, fieldsToExcludeUpdate); + case MODIFY_WITH_PATCH: + return ODataEntitySerializer.serializeEntityForUpdatePatchShallow(entity, fieldsToIncludeInUpdate); + case MODIFY_WITH_PATCH_RECURSIVE_DELTA: + return ODataEntitySerializer + .serializeEntityForUpdatePatchRecursiveDelta(entity, fieldsToIncludeInUpdate); + case MODIFY_WITH_PATCH_RECURSIVE_FULL: + return ODataEntitySerializer + .serializeEntityForUpdatePatchRecursiveFull(entity, fieldsToIncludeInUpdate); + default: + throw new IllegalStateException("Unexpected update strategy:" + updateStrategy); + } + } + catch( final Exception e ) { + final String msg = + String + .format( + "Failed to serialize OData Update HTTP request entity for type %s with strategy %s", + getEntityClass().getSimpleName(), + updateStrategy); + + final ODataRequestUpdate request = + new ODataRequestUpdate( + getServicePath(), + entity.getEntityCollection(), + ODataEntityKey.of(entity.getKey(), ODataProtocol.V2), + "", + updateStrategy, + eTagSubmissionStrategy.getHeaderFromVersionIdentifier(entity.getVersionIdentifier()), + ODataProtocol.V2); + throw new ODataSerializationException(request, entity, msg, e); + } + } + + /** + * Allows to explicitly specify entity fields that shall be sent in an update request regardless if the values of + * these fields have been changed. This is helpful in case the API requires to send certain fields in any case in an + * update request. + * + * @param fields + * The fields to be included in the update execution. + * @return The same fluent helper which will include the specified fields in an update request. + */ + @Nonnull + @SafeVarargs + @SuppressWarnings( "varargs" ) + public final FluentHelperT includingFields( @Nonnull final EntitySelectable... fields ) + { + includedFields.addAll(Arrays.asList(fields)); + return getThis(); + } + + /** + * Allows to explicitly specify entity fields that should not be sent in an update request. This is helpful in case, + * some services require no read only fields to be sent for update requests. These fields are only excluded in a PUT + * request, they are not considered in a PATCH request. + * + * @param fields + * The fields to be excluded in the update execution. + * @return The same fluent helper which will exclude the specified fields in an update request. + */ + @Nonnull + @SafeVarargs + @SuppressWarnings( "varargs" ) + public final FluentHelperT excludingFields( @Nonnull final EntitySelectable... fields ) + { + excludedFields.addAll(Arrays.asList(fields)); + return getThis(); + } + + @Nonnull + private String getEntityCollection() + { + return Option.of(entityCollection).getOrElse(() -> getEntity().getEntityCollection()); + } + + /** + * Allows to control that the 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. + * + * @return The same fluent helper which will replace the entity in the remote system + */ + @Nonnull + public final FluentHelperT replacingEntity() + { + updateStrategy = UpdateStrategy.REPLACE_WITH_PUT; + return getThis(); + } + + /** + * Allows to control that the request to update the entity is sent with the HTTP method PATCH and its payload + * contains the changed fields only. + * + * @return The same fluent helper which will modify the entity in the remote system. + */ + @Nonnull + public final FluentHelperT modifyingEntity() + { + updateStrategy = UpdateStrategy.MODIFY_WITH_PATCH; + return getThis(); + } + + /** + * Allows to control that the request to update the entity is sent with the HTTP method PATCH and its payload + * contains the changed fields only, with different strategies for handling nested fields. + * + * @param strategy + * The strategy to use for the PATCH update. + * @return The same fluent helper which will modify the entity in the remote system. + * @throws IllegalArgumentException + * If an unknown ModifyPatchStrategy is provided. + * @since 5.16.0 + */ + @Beta + @Nonnull + public final FluentHelperT modifyingEntity( @Nonnull final ModifyPatchStrategy strategy ) + { + switch( strategy ) { + case SHALLOW: + return modifyingEntity(); + case RECURSIVE_DELTA: + updateStrategy = UpdateStrategy.MODIFY_WITH_PATCH_RECURSIVE_DELTA; + break; + case RECURSIVE_FULL: + updateStrategy = UpdateStrategy.MODIFY_WITH_PATCH_RECURSIVE_FULL; + break; + default: + throw new IllegalArgumentException("Unknown ModifyPatchStrategy: " + strategy); + } + return getThis(); + } + +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ModificationResponse.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ModificationResponse.java new file mode 100644 index 0000000000..227c5baa8d --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ModificationResponse.java @@ -0,0 +1,154 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.common.annotations.Beta; +import com.google.gson.Gson; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import io.vavr.control.Option; +import io.vavr.control.Try; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +/** + * Generic OData service response wrapper for data modification requests. + * + * @param + * The generic entity type. + */ +@EqualsAndHashCode +@ToString +@RequiredArgsConstructor( staticName = "of", onConstructor = @__( { @Beta } ), access = AccessLevel.PUBLIC ) +@Slf4j +public final class ModificationResponse> +{ + private static final Gson GSON = new Gson(); + + @Nullable + private EntityT modifiedEntity; + + @Nonnull + private Option responseEntity = Option.none(); + + @Nonnull + private final ODataRequestResultGeneric result; + + @Nonnull + private final EntityT originalRequestEntity; + + @Nonnull + private final Destination destination; + + /** + * Get an updated version of the entity. If the service responded with an entity it is returned here. If the service + * didn't respond with an entity but send an @{code ETag} header, the entity is updated and returned. Otherwise a + * copy of the original, unmodified entity is returned. + * + * @return The modified entity. + */ + @Nonnull + public synchronized EntityT getModifiedEntity() + { + if( modifiedEntity == null ) { + evaluateResponse(); + } + return modifiedEntity; + } + + /** + * Get the optional response entity parsed from the HTTP content. + * + * @return The parsed entity or none. + */ + @Nonnull + public synchronized Option getResponseEntity() + { + // We synchronize the full method here because Fortify does not like double checked locking: + // https://vulncat.fortify.com/en/detail?id=desc.structural.java.code_correctness_double_checked_locking + if( modifiedEntity == null ) { + evaluateResponse(); + } + return responseEntity; + } + + @SuppressWarnings( "unchecked" ) + private void evaluateResponse() + { + responseEntity = parseEntityFromResponse(); + + // create a copy before modifying the version identifier to not change existing objects + final EntityT entityToModify = responseEntity.getOrElse(originalRequestEntity); + + // clone entity + modifiedEntity = GSON.fromJson(GSON.toJson(entityToModify), (Class) originalRequestEntity.getClass()); + + modifiedEntity.attachToService(result.getODataRequest().getServicePath(), destination); + + modifiedEntity.setVersionIdentifier(getUpdatedVersionIdentifier().getOrNull()); + } + + /** + * Access the original entity used to make the request. + * + * @return The original entity object used to perform an OData request. + */ + @Nonnull + public EntityT getRequestEntity() + { + return originalRequestEntity; + } + + /** + * Get the response status code. + * + * @return The integer representation of the HTTP status code. + */ + public int getResponseStatusCode() + { + return result.getHttpResponse().getCode(); + } + + /** + * Get the response headers. + * + * @return The headers of the HTTP status code. + */ + @Nonnull + public Map> getResponseHeaders() + { + return result.getAllHeaderValues(); + } + + /** + * Get the version identifier present in the response headers, the identifier of the original entity or none, if + * neither exist. + * + * @return An up to date version identifier or none. + */ + @Nonnull + public Option getUpdatedVersionIdentifier() + { + return result + .getVersionIdentifierFromHeader() + .orElse(() -> getResponseEntity().flatMap(entity -> entity.getVersionIdentifier())); + } + + @Nonnull + @SuppressWarnings( "unchecked" ) + private Option parseEntityFromResponse() + { + return Try + .of(() -> result.as((Class) originalRequestEntity.getClass())) + .onFailure(e -> log.debug("Failed to parse entity from HTTP response.", e)) + .toOption() + .peek(entity -> result.getVersionIdentifierFromHeader().peek(entity::setVersionIdentifier)); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ModifyPatchStrategy.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ModifyPatchStrategy.java new file mode 100644 index 0000000000..e934c50324 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ModifyPatchStrategy.java @@ -0,0 +1,23 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import com.google.common.annotations.Beta; + +/** + * Strategy to determine how a patch operation should be applied to an entity. + * + * @since 5.16.0 + */ +@Beta +public enum ModifyPatchStrategy +{ + /** Only the top level fields can be patched */ + SHALLOW, + + /** All top level and nested fields can be patched, resulting in JSON containing only the changed fields */ + RECURSIVE_DELTA, + + /** + * All top level and nested fields can be patched, resulting in JSON containing the full value of complex object. + */ + RECURSIVE_FULL +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataEntitySerializer.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataEntitySerializer.java new file mode 100644 index 0000000000..3898351d0d --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataEntitySerializer.java @@ -0,0 +1,321 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.BiPredicate; +import java.util.function.Predicate; +import java.util.stream.StreamSupport; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.sap.cloud.sdk.datamodel.odata.adapter.ODataNumberSerializer; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FieldReference; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +final class ODataEntitySerializer +{ + private static final Gson GSON; + private static final Gson GSON_SERIALIZING_NULLS; + + static { + final ODataNumberSerializer numberSerializer = new ODataNumberSerializer(); + final GsonBuilder builder = + new GsonBuilder() + .registerTypeAdapter(Long.class, numberSerializer) + .registerTypeAdapter(Double.class, numberSerializer) + .registerTypeAdapter(Float.class, numberSerializer) + .registerTypeAdapter(BigDecimal.class, numberSerializer); + // CXSDK currently serializes Edm.SByte as plain number so the client also does it for consistency. + // .registerTypeAdapter(Byte.class, numberSerializer) + + GSON = builder.create(); + GSON_SERIALIZING_NULLS = builder.serializeNulls().create(); + } + + /** + * Serializes an entity for update request (PUT). Allowing null values. Removing potential "versionIdentifier" + * fields. + * + * @param entity + * The OData V2 entity reference. + * @param excludedFields + * Collection of fields to be excluded in the update (PUT) request. + * @return The serialized JSON string for entity update request. + */ + @Nonnull + static String serializeEntityForUpdatePut( + @Nonnull final VdmEntity entity, + @Nullable final Collection excludedFields ) + { + final JsonObject jsonObject = GSON_SERIALIZING_NULLS.toJsonTree(entity).getAsJsonObject(); + + removeVersionIdentifier(jsonObject); + + // find field names to be removed from PUT request + if( excludedFields != null ) { + excludedFields.stream().map(FieldReference::getFieldName).forEach(jsonObject::remove); + } + + return GSON_SERIALIZING_NULLS.toJson(jsonObject); + } + + /** + * Serializes an entity for create request. Ignoring empty collections and null values. + * + * @param entity + * The OData V2 entity reference. + * @return The serialized JSON string for entity create request. + */ + @Nonnull + static String serializeEntityForCreate( @Nonnull final VdmEntity entity ) + { + // When using builder pattern, all 1:n navigation properties will be initialized with `new ArrayList()` instead of expected `null` + final JsonObject jsonObject = GSON.toJsonTree(entity).getAsJsonObject(); + + removeEmptyArrays(jsonObject); + + return GSON.toJson(jsonObject); + } + + /** + * Serializes an entity for update request (PATCH). Allowing null values. + * + * @param entity + * The OData V2 entity reference. + * @param includedFields + * Collection of fields to be included in the update (PATCH) request. + * @return The serialized JSON string for entity update request. + */ + @Nonnull + static String serializeEntityForUpdatePatchShallow( + @Nonnull final VdmEntity entity, + @Nonnull final Collection includedFields ) + { + final JsonObject fullEntity = GSON_SERIALIZING_NULLS.toJsonTree(entity).getAsJsonObject(); + + // find field names to be patched + final Set fieldNamesToPatch = new HashSet<>(entity.getChangedFields().keySet()); + includedFields.stream().map(FieldReference::getFieldName).forEach(fieldNamesToPatch::add); + log.debug("The following fields are marked for updates: {}.", fieldNamesToPatch); + + final JsonObject partialEntity = new JsonObject(); + + fieldNamesToPatch.forEach(key -> partialEntity.add(key, fullEntity.get(key))); + + return GSON_SERIALIZING_NULLS.toJson(partialEntity); + } + + /** + * Serializes an entity for update request (PATCH) including changes in nested properties. Allowing null values. + * Resulting JSON contains the full value of complex fields for changing any nested field. + * + * @param entity + * The OData V2 entity reference. + * @param includedFields + * Collection of fields to be included in the update (PATCH) request. + * @return The serialized JSON string for entity update request. + */ + @Nonnull + static String serializeEntityForUpdatePatchRecursiveFull( + @Nonnull final VdmEntity entity, + @Nonnull final Collection includedFields ) + { + final JsonObject fullEntityJson = GSON_SERIALIZING_NULLS.toJsonTree(entity).getAsJsonObject(); + final JsonObject patchObject = new JsonObject(); + + final Set changedFieldNames = new HashSet<>(entity.getChangedFields().keySet()); + includedFields.stream().map(FieldReference::getFieldName).forEach(changedFieldNames::add); + changedFieldNames.forEach(key -> patchObject.add(key, fullEntityJson.get(key))); + + entity + .toMapOfFields() + .entrySet() + .stream() + .filter(entry -> !patchObject.has(entry.getKey())) + .filter(entry -> containsNestedChangedFields(entry.getValue())) + .forEach(entry -> patchObject.add(entry.getKey(), fullEntityJson.get(entry.getKey()))); + + log.debug("The following object is serialized for update : {}.", patchObject); + + return GSON_SERIALIZING_NULLS.toJson(patchObject); + } + + /** + * Checks if the given complex object contains any changed fields in its nested fields. + * + * @param obj + * the complex object to check + * @return true if the complex object contains any changed fields, false otherwise + */ + private static boolean containsNestedChangedFields( final Object obj ) + { + if( obj instanceof VdmComplex vdmComplex ) { + if( !vdmComplex.getChangedFields().isEmpty() ) { + return true; + } + for( final Object complexField : vdmComplex.toMapOfFields().values() ) { + if( containsNestedChangedFields(complexField) ) { + return true; + } + } + } + return false; + } + + /** + * Serializes an entity for update request (PATCH) including changes in nested properties. Allowing null values. + * Resulting JSON contains only the changed fields (including nested changes). + * + * @param entity + * The OData V2 entity reference. + * @param includedFields + * Collection of fields to be included in the update (PATCH) request. + * @return The serialized JSON string for entity update request. + */ + @Nonnull + static String serializeEntityForUpdatePatchRecursiveDelta( + @Nonnull final VdmEntity entity, + @Nonnull final Collection includedFields ) + { + final JsonObject fullEntityJson = GSON_SERIALIZING_NULLS.toJsonTree(entity).getAsJsonObject(); + final JsonObject patchObject = new JsonObject(); + + // Recursively build patch object from changed fields + final JsonObject tempPatchObject = createPatchObjectRecursiveDelta(entity, fullEntityJson); + + // Add included fields (from the root only) + includedFields + .stream() + .map(FieldReference::getFieldName) + .forEach(key -> patchObject.add(key, fullEntityJson.get(key))); + + // Merge all fields from the tempPatchObject if not already present + tempPatchObject + .entrySet() + .stream() + .filter(entry -> !patchObject.has(entry.getKey())) + .forEach(entry -> patchObject.add(entry.getKey(), entry.getValue())); + + log.debug("The following delta object is serialized for update : {}.", patchObject); + + return GSON_SERIALIZING_NULLS.toJson(patchObject); + } + + /** + * Recursively builds a patch object for a VdmObject by including only changed fields. Complex fields are traversed + * recursively. + * + * @param vdmObject + * the VdmObject (entity or complex) to build the patch from + * @param jsonObject + * the full JSON representation of this object + * @return a JsonObject that contains only changed fields (including nested changes) + */ + @Nonnull + private static + JsonObject + createPatchObjectRecursiveDelta( @Nonnull final VdmObject vdmObject, @Nonnull final JsonObject jsonObject ) + { + final JsonObject patch = new JsonObject(); + + // Process all complex fields and recursively build patch for the complex field + vdmObject.toMapOfFields().forEach(( fieldName, val ) -> { + if( val instanceof VdmComplex complexField ) { + final var childJsonObject = jsonObject.getAsJsonObject(fieldName); + final var childJsonObjectDelta = createPatchObjectRecursiveDelta(complexField, childJsonObject); + if( !childJsonObjectDelta.isEmpty() ) { + patch.add(fieldName, childJsonObjectDelta); + } + } + }); + + // Add explicitly changed fields + vdmObject.getChangedFields().keySet().forEach(key -> patch.add(key, jsonObject.get(key))); + + return patch; + } + + private static void removeVersionIdentifier( @Nonnull final JsonObject jsonObject ) + { + log.debug("Removing redundant \"versionIdentifier\" recursively from JSON object: {}", jsonObject); + + final Predicate isNullOrString = + obj -> obj.isJsonNull() || obj.isJsonPrimitive() && obj.getAsJsonPrimitive().isString(); + + traverseJsonObject( + jsonObject, + ( element, name ) -> "versionIdentifier".equals(name) && isNullOrString.test(element), + JsonObject::remove); + + log.debug("JSON object after removing redundant \"versionIdentifier\": {}", jsonObject); + } + + private static void removeEmptyArrays( @Nonnull final JsonObject jsonObject ) + { + log.debug("Removing empty arrays recursively from JSON object: {}", jsonObject); + + traverseJsonObject( + jsonObject, + ( element, name ) -> element.isJsonArray() && element.getAsJsonArray().size() == 0, + JsonObject::remove); + + log.debug("JSON object after removing empty arrays: {}", jsonObject); + } + + /** + * Traverse the JSON object tree to apply an action on filtered items. + * + * @param jsonObject + * The current JSON object to check properties for. + * @param filter + * The filter as lambda (predicate) for property value and property key. + * @param action + * The action as lambda (consumer) for parent object and filtered property key. + */ + private static void traverseJsonObject( + @Nonnull final JsonObject jsonObject, + @Nonnull final BiPredicate filter, + @Nonnull final BiConsumer action ) + { + final List filteredChildKeys = new ArrayList<>(); + + jsonObject.entrySet().forEach(entry -> { + if( entry.getValue().isJsonObject() ) { + // Apply this logic recursively for all nested objects + traverseJsonObject(entry.getValue().getAsJsonObject(), filter, action); + } else if( filter.test(entry.getValue(), entry.getKey()) ) { + // Collect key names for children that tested against the provided filter predicate + filteredChildKeys.add(entry.getKey()); + } else if( entry.getValue().isJsonArray() ) { + // Apply this logic recursively for lists of nested objects + final JsonArray jsonArray = entry.getValue().getAsJsonArray(); + StreamSupport + .stream(jsonArray.spliterator(), false) + .filter(JsonElement::isJsonObject) + .map(JsonElement::getAsJsonObject) + .forEach(o -> traverseJsonObject(o, filter, action)); + } + }); + + log + .trace( + "Applying the provided action on parent element {} for the following child items: {}", + jsonObject, + filteredChildKeys); + + filteredChildKeys.forEach(affectedChildKey -> action.accept(jsonObject, affectedChildKey)); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/OneToOneLink.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/OneToOneLink.java new file mode 100644 index 0000000000..bd5f9b329a --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/OneToOneLink.java @@ -0,0 +1,24 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import javax.annotation.Nonnull; + +/** + * Interface designed to be implemented by {@link EntityLink} class in order to provide a filter function. + * + * @param + * The entity where the link starts. + * @param + * The entity where the link ends (i.e. the related entity). + */ +public interface OneToOneLink +{ + /** + * Add filter to a one-to-one navigation property relationship. + * + * @param filterExpression + * The filter expression to use for resolving the navigation property + * @return The fluent helper to continue constructing a filter expression. + */ + @Nonnull + ExpressionFluentHelper filter( @Nonnull final ExpressionFluentHelper filterExpression ); +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/Order.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/Order.java new file mode 100644 index 0000000000..7ecf43a135 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/Order.java @@ -0,0 +1,17 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +/** + * 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-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/SingleValuedFluentHelperFunction.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/SingleValuedFluentHelperFunction.java new file mode 100644 index 0000000000..efa3f3c914 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/SingleValuedFluentHelperFunction.java @@ -0,0 +1,32 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import javax.annotation.Nonnull; + +/** + * Representation of any OData function import as a fluent class for further configuring the request and + * {@link #executeRequest(Destination) executing} it. This is specifically for functions that return either a single + * primitive value or entity value + * + * @param + * The fluent helper type. + * @param + * The type of the object this OData request operates on, if any. + * @param + * The type of the result entity, if any. + */ +public abstract class SingleValuedFluentHelperFunction + extends + FluentHelperFunction +{ + + /** + * Instantiates this fluent helper using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + */ + public SingleValuedFluentHelperFunction( @Nonnull final String servicePath ) + { + super(servicePath); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmComplex.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmComplex.java new file mode 100644 index 0000000000..74297e4257 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmComplex.java @@ -0,0 +1,15 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import lombok.EqualsAndHashCode; + +/** + * Complex type in the virtual data model. + * + * @param + * Object type of the complex type. + */ +@EqualsAndHashCode( callSuper = true, doNotUseGetters = true ) +public abstract class VdmComplex extends VdmObject +{ + +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntity.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntity.java new file mode 100644 index 0000000000..837c6ba1b1 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntity.java @@ -0,0 +1,226 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.util.List; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.collect.Iterables; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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.ODataRequestException; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import io.vavr.control.Option; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; + +/** + * Represents a {@link VdmObject} which is an entity. Entities may have a version identifier. + * + * @param + * The specific entity data type. + */ +@EqualsAndHashCode( callSuper = true, doNotUseGetters = true ) +public abstract class VdmEntity extends VdmObject +{ + @SerializedName( "versionIdentifier" ) + @JsonProperty( "versionIdentifier" ) + @Nullable + private String versionIdentifier = null; + + /** + * The service path only used for the fetch commands of this entity. + *

+ * Note: Use with caution, as this can easily break the fetch call on this entity. See the interface of the + * corresponding service for the default service path. + */ + @Getter( AccessLevel.PROTECTED ) + @Setter( AccessLevel.PROTECTED ) + @JsonIgnore + @EqualsAndHashCode.Exclude + private transient String servicePathForFetch; + + /** + * Convenience field for reusing the same destination with multiple queries (e.g. fetching associated entities). + */ + @Getter( AccessLevel.PROTECTED ) + @Setter( AccessLevel.PROTECTED ) + @JsonIgnore + @EqualsAndHashCode.Exclude + private transient Destination destinationForFetch; + + /** + * Getter for the version identifier of this entity. + *

+ * This identifier can be used to compare this entity with a remote one. As not the whole entity has to be sent this + * reduces the request overhead. + *

+ * Actual use cases can be checking whether this entity is still current with regards to the remote entity, and + * ensuring that a update/delete operation is done on the expected version of the remote entity. + * + * @return The optional version identifier. + */ + @Nonnull + public Option getVersionIdentifier() + { + return Option.of(versionIdentifier); + } + + /** + * Setter for the version identifier of this entity. + *

+ * This identifier can be used to compare this entity with a remote one. As not the whole entity has to be sent this + * reduces the request overhead. + *

+ * Actual use cases can be checking whether this entity is still current with regards to the remote entity, and + * ensuring that a update/delete operation is done on the expected version of the remote entity. + * + * @param versionIdentifier + * The version identifier of this entity. + */ + public void setVersionIdentifier( @Nullable final String versionIdentifier ) + { + this.versionIdentifier = versionIdentifier; + } + + /** + * Used by fluent helpers and navigation property methods to construct OData queries. + * + * @return EDMX name of the entity collection identifier. + */ + @Nonnull + protected abstract String getEntityCollection(); + + /** + * Used by fluent helpers and navigation property methods to construct OData queries. + * + * @return Default context path to the OData service. In other words, everything in between the + * {@code protocol://hostname:port} and the OData resource name (entity set, {@code $metadata}, etc.) + */ + @Nullable + protected String getDefaultServicePath() + { + return null; + } + + /** + * Sets the service path and destination for the fetch commands of this entity. + *

+ * Also applies to any associated entities (navigation properties) that were previously fetched. + *

+ * Note: Use with caution, as this can easily break the fetch calls on this entity. See the interface of the + * corresponding service for the default service path. + * + * @param servicePath + * Optional parameter. New service path to apply to this entity and any associated entities that were + * previously fetched. If a null value is provided and the service path has never been set, then the + * service path will be set to the default defined in the corresponding service interface. + * @param destination + * New destination to apply to this entity and any associated entities that were previously fetched. + */ + protected void attachToService( @Nullable final String servicePath, @Nonnull final Destination destination ) + { + if( servicePath != null ) { + servicePathForFetch = servicePath; + } else if( servicePathForFetch == null ) { + servicePathForFetch = getDefaultServicePath(); + } + + destinationForFetch = destination; + + toMapOfNavigationProperties().values().forEach(navProperty -> { + if( navProperty instanceof Iterable ) { + final Iterable navPropertyList = (Iterable) navProperty; + final boolean itemTypeIsEntity = Iterables.getFirst(navPropertyList, null) instanceof VdmEntity; + + if( itemTypeIsEntity ) { + for( final Object childEntity : navPropertyList ) { + final VdmEntity vdmEntity = (VdmEntity) childEntity; + vdmEntity.attachToService(servicePathForFetch, destinationForFetch); + } + } + } else if( navProperty instanceof VdmEntity ) { + final VdmEntity vdmEntity = (VdmEntity) navProperty; + vdmEntity.attachToService(servicePathForFetch, destinationForFetch); + } + }); + } + + /** + * Helper method to lazily resolve a field value from current entity. + * + * @param fieldName + * The field name to lookup. + * @param fieldType + * The field type to cast the value to. + * @param + * The generic type parameter. + * @return A list of requested values. + */ + @Nonnull + protected > List fetchFieldAsList( + @Nonnull final String fieldName, + @Nonnull final Class fieldType ) + { + final Destination destination = getDestinationForFetch(); + final ODataRequestResultGeneric response = fetchField(fieldName, destination); + final List entityList = response.asList(fieldType); + for( final T entity : entityList ) { + entity.attachToService(getServicePathForFetch(), destination); + } + return entityList; + } + + /** + * Helper method to lazily resolve a field value from current entity. + * + * @param fieldName + * The field name to lookup. + * @param fieldType + * The field type to cast the value to. + * @param + * The generic type parameter. + * @return The requested values. + */ + @Nonnull + protected > T fetchFieldAsSingle( + @Nonnull final String fieldName, + @Nonnull final Class fieldType ) + { + final Destination destination = getDestinationForFetch(); + final ODataRequestResultGeneric response = fetchField(fieldName, destination); + final T entity = response.as(fieldType); + entity.attachToService(getServicePathForFetch(), destination); + return entity; + } + + @Nonnull + private ODataRequestResultGeneric fetchField( final String fieldName, final Destination destination ) + { + final ODataEntityKey entityKey = ODataEntityKey.of(getKey(), ODataProtocol.V2); + final ODataResourcePath path = ODataResourcePath.of(getEntityCollection(), entityKey).addSegment(fieldName); + final ODataRequestRead request = new ODataRequestRead(getServicePathForFetch(), path, null, ODataProtocol.V2); + if( destination == null ) { + throw new ODataRequestException( + request, + "Failed to fetch related objects from field name " + + fieldName + + ": The entity was created locally without an assigned HttpDestination. This method is applicable only on entities which were retrieved or created using the OData VDM.", + null); + } + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + return request.execute(httpClient); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntityUtil.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntityUtil.java new file mode 100644 index 0000000000..08d6c65692 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntityUtil.java @@ -0,0 +1,54 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.lang.reflect.InvocationTargetException; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.cloudplatform.exception.ShouldNotHappenException; + +import lombok.RequiredArgsConstructor; + +/** + * Utility class to manage OData entity deserialization. + * + * @param + * Entity type to create new instances from. + */ +@RequiredArgsConstructor +public final class VdmEntityUtil> +{ + private final Class entityClass; + + @Nonnull + EntityT newInstance() + { + try { + return entityClass.getDeclaredConstructor().newInstance(); + } + catch( final + InstantiationException + | IllegalAccessException + | NoSuchMethodException + | InvocationTargetException e ) { + throw new ShouldNotHappenException( + "Failed to instantiate object of type " + entityClass.getSimpleName(), + e); + } + } + + /** + * Helper method to resolve the expected entity type for the provided fluent helper instance. For internal use. + * + * @param fluentHelper + * The fluent helper instance to resolve the entity type for. + * @param + * The generic entity type. + * @return The entity type. + */ + @SuppressWarnings( "unchecked" ) + @Nonnull + public static Class getEntityClass( @Nonnull final FluentHelperBasic fluentHelper ) + { + return (Class) fluentHelper.getEntityClass(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmMediaEntity.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmMediaEntity.java new file mode 100644 index 0000000000..80d4f6cd19 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmMediaEntity.java @@ -0,0 +1,98 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.io.IOException; +import java.io.InputStream; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.HttpEntity; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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.ODataException; +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.expression.ODataResourcePath; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestReadByKey; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import lombok.EqualsAndHashCode; + +/** + * Represents a media entity which exposes additional data under a {@code $value} endpoint. + * + * @param + * The specific entity type. + */ +@EqualsAndHashCode( callSuper = true, doNotUseGetters = true ) +public abstract class VdmMediaEntity extends VdmEntity +{ + /** + * Get the binary data stream (file) from this media entity. Perform this operation after retrieving the + * entity object from the OData service. + *

+ * Alternatively, you can use this method to only retrieve the media resource without requesting the entity + * data. Build this entity via its {@code .builder()} and use {@link #attachToService(String, Destination)} to + * declare a service path and destination to request the media resource from. You can obtain the service path from + * the {@code #DEFAULT_SERVICE_PATH}, e.g. + * {@code BusinessPartnerServiceBusinessPartnerService.DEFAULT_SERVICE_PATH} + *

+ * Please ensure this stream is closed after usage. The below example achieves this using + * try-with-resources: + * + *

+     * try( InputStream content = entity.fetchMediaStream() ) {
+     *     // do something with the content here
+     * }
+     * 
+ * + * @return File content as an {@link InputStream}. + * @throws ODataException + * if the request could not be sent or the OData service responded with an error. + */ + @Nonnull + public InputStream fetchMediaStream() + throws ODataException + { + final ODataResourcePath resource = + ODataResourcePath + .of(getEntityCollection(), ODataEntityKey.of(getKey(), ODataProtocol.V2)) + .addSegment("$value"); + + final ODataRequestReadByKey request = + new ODataRequestReadByKey(getServicePathForFetch(), resource, null, ODataProtocol.V2); + + final Destination destination = getDestinationForFetch(); + if( destination == null ) { + throw new ODataRequestException( + request, + "Failed to fetch media stream.", + new IllegalStateException( + "Unable to execute OData query. The entity was created locally without an assigned HttpDestination. This method is applicable only on entities which were retrieved or created using the OData VDM.")); + } + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + final ODataRequestResultGeneric result = request.execute(httpClient); + @SuppressWarnings( "PMD.CloseResource" ) // entity is intentionally not closed here because its content stream is returned to the caller + final HttpEntity entity = result.getHttpResponse().getEntity(); + if( entity == null ) { + throw new ODataResponseException( + request, + result.getHttpResponse(), + "Failed to read the input stream of the OData response: Response didn't contain any payload.", + null); + } + try { + return entity.getContent(); + } + catch( final IOException | UnsupportedOperationException e ) { + throw new ODataResponseException( + request, + result.getHttpResponse(), + "Failed to read the input stream of the OData response.", + e); + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmObject.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmObject.java new file mode 100644 index 0000000000..48549f0e40 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmObject.java @@ -0,0 +1,373 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import com.sap.cloud.sdk.s4hana.datamodel.odata.exception.NoSuchEntityFieldException; +import com.sap.cloud.sdk.typeconverter.TypeConverter; + +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * Superclass of all entities which contains common elements such as a generic representation of custom fields. + * + * @param + * The type of the implementing object. + */ +@ToString( doNotUseGetters = true ) +@EqualsAndHashCode( doNotUseGetters = true ) +@JsonAutoDetect( + fieldVisibility = JsonAutoDetect.Visibility.ANY, + getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE, + setterVisibility = JsonAutoDetect.Visibility.NONE, + creatorVisibility = JsonAutoDetect.Visibility.NONE ) +public abstract class VdmObject +{ + @JsonIgnore + private final transient Map customFields = new LinkedHashMap<>(); + + /** + * A mapping of the OData field name to the original value. + *

+ * This should be updated via {@link #rememberChangedField(String, Object)} on every set call of a property. + */ + @JsonIgnore + @Nonnull + protected final transient Map changedOriginalFields = new HashMap<>(); + + /** + * Returns the names of all custom fields of this object. + * + * @return The names of the custom fields of this object. + */ + @Nonnull + public Set getCustomFieldNames() + { + return customFields.keySet(); + } + + /** + * Returns all custom field names and values of this object. + * + * @return All names & values of custom fields as a map. + */ + @JsonAnyGetter + @Nonnull + public Map getCustomFields() + { + return customFields; + } + + /** + * Sets the value of a single custom field. + * + * @param customFieldName + * Name of the custom field. + * @param value + * Value of the custom field. + */ + @JsonAnySetter + public void setCustomField( @Nonnull final String customFieldName, @Nullable final Object value ) + { + rememberChangedField(customFieldName, customFields.get(customFieldName)); + customFields.put(customFieldName, value); + } + + /** + * Sets the value of a single custom field. If the EntityField passed as parameter holds a TypeConverter, the value + * will be converted before it's stored. + * + * @param customField + * Name of the custom field, represented as an EntityField object. + * @param value + * Value of the custom field. + * @param + * The type of the custom field to set. + */ + public void setCustomField( + @Nonnull final EntityField customField, + @Nullable final FieldT value ) + { + if( customField.getTypeConverter() != null ) { + setCustomField(customField.getFieldName(), customField.getTypeConverter().toDomain(value).get()); + } else { + setCustomField(customField.getFieldName(), value); + } + } + + /** + * Checks whether this object contains a custom field with the given name. + * + * @param customFieldName + * Name of the custom field to check for + * + * @return {@code true} if this entity has a custom field with the given name, {@code false} otherwise. + */ + public boolean hasCustomField( @Nonnull final String customFieldName ) + { + return customFields.containsKey(customFieldName); + } + + /** + * Checks whether this object contains a value for the given custom field. + * + * @param customField + * Custom field to check for, represented as an {@code EntityField} object. + * + * @return {@code true} if this object has a custom field with the name of the given field, {@code false} otherwise. + */ + public boolean hasCustomField( @Nonnull final EntityField customField ) + { + return hasCustomField(customField.getFieldName()); + } + + /** + * This method allows for retrieval of custom fields that are added to the underlying OData services. + * + * @param customFieldName + * Name of the field returned by the underlying OData service. + * @param + * The type of the returned field. + * + * @return The value of the custom field. Actual type will depend on the type configured in the underlying OData + * service. + * + * @throws NoSuchEntityFieldException + * if no field with the given name could be found. + */ + @SuppressWarnings( "unchecked" ) + @Nullable + public FieldT getCustomField( @Nonnull final String customFieldName ) + throws NoSuchEntityFieldException + { + if( !hasCustomField(customFieldName) ) { + throw new NoSuchEntityFieldException("Object has no field with name '" + customFieldName + "'."); + } + return (FieldT) customFields.get(customFieldName); + } + + @SuppressWarnings( "unchecked" ) + @Nullable + private FieldT getCustomField( + @Nonnull final String customFieldName, + @Nonnull final TypeConverter typeConverter ) + throws NoSuchEntityFieldException + { + if( !hasCustomField(customFieldName) ) { + throw new NoSuchEntityFieldException("Object has no field with name '" + customFieldName + "'."); + } + return typeConverter.fromDomain((T) customFields.get(customFieldName)).get(); + } + + /** + * This method allows for retrieval of custom fields that are added to the underlying OData services. If the + * EntityField passed as parameter holds a TypeConverter, the value will be converted before it's returned. + * + * @param customField + * Field returned by the underlying OData service. + * @param + * The type of the returned field. + * + * @return The value of the custom field. Actual type will depend on the type configured in the underlying OData + * service. + * + * @throws NoSuchEntityFieldException + * if no field with the given name could be found. + */ + @Nullable + public FieldT getCustomField( @Nonnull final EntityField customField ) + throws NoSuchEntityFieldException + { + if( customField.getTypeConverter() != null ) { + return getCustomField(customField.getFieldName(), customField.getTypeConverter()); + } else { + return getCustomField(customField.getFieldName()); + } + } + + /** + * Returns the class of this object. + * + * @return The class of this object. + */ + @Nonnull + public abstract Class getType(); + + /** + * Returns the compound key of this object. + * + * @return The compound key of this object. + */ + @Nonnull + protected Map getKey() + { + return new HashMap<>(); + } + + /** + * Sets the values of all custom fields contained in the given {@code values}. + *

+ * Afterwards, marks all fields as unchanged. + *

+ * + * @param values + * The map of custom fields to set. + */ + protected void fromMap( final Map values ) + { + for( final Map.Entry entry : values.entrySet() ) { + setCustomField(entry.getKey(), entry.getValue()); + } + + resetChangedFields(); + } + + /** + * Returns a map of all custom fields contained in this object. + * + * @return A map of all custom fields contained in this object. + */ + @Nonnull + protected Map toMapOfCustomFields() + { + return Maps.newHashMap(getCustomFields()); + } + + /** + * Returns a set of all custom field names contained in this object. + * + * @return A set of all custom field names contained in this object. + */ + @Nonnull + protected Set getSetOfCustomFields() + { + return Sets.newHashSet(getCustomFields().keySet()); + } + + /** + * Returns a map of all fields contained in this object. + * + * @return A map of all fields contained in this object. + */ + @Nonnull + protected Map toMapOfFields() + { + return new HashMap<>(); + } + + /** + * Returns a set of all field names contained in this object. + * + * @return A set of all field names contained in this object. + */ + @Nonnull + protected Set getSetOfFields() + { + return Sets.newHashSet(toMapOfFields().keySet()); + } + + /** + * Returns a map of all navigation properties contained in this object. + * + * @return A map of all navigation properties contained in this object. + */ + @Nonnull + protected Map toMapOfNavigationProperties() + { + return new HashMap<>(); + } + + /** + * Returns a set of all navigation property names contained in this object. + * + * @return A set of all navigation property names contained in this object. + */ + @Nonnull + protected Set getSetOfNavigationProperties() + { + return Sets.newHashSet(toMapOfNavigationProperties().keySet()); + } + + /** + * Returns a map of all fields, navigation properties, and custom fields contained in this object. + * + * @return A map of all fields, navigation properties, and custom fields contained in this object. + */ + @Nonnull + protected Map toMap() + { + final Map values = new HashMap<>(); + + values.putAll(toMapOfFields()); + values.putAll(toMapOfNavigationProperties()); + values.putAll(toMapOfCustomFields()); + + return values; + } + + /** + * Returns map of all fields which have been changed on this entity along with their updated values. + * + * @return Map containing all changed fields with their current value. + */ + @Nonnull + public Map getChangedFields() + { + final Map changedFields = new HashMap<>(); + + final Map currentFields = toMapOfFields(); + currentFields.putAll(getCustomFields()); + + for( final Map.Entry changedOriginalField : changedOriginalFields.entrySet() ) { + final Object originalValue = changedOriginalField.getValue(); + final Object currentValue = currentFields.get(changedOriginalField.getKey()); + + if( originalValue != null && !originalValue.equals(currentValue) + || originalValue == null && currentValue != null ) { + + changedFields.put(changedOriginalField.getKey(), currentValue); + } + } + + return changedFields; + } + + /** + * Remembers the original value of a changed field. + * + * @param fieldName + * The name of the field that is changed. + * @param valueBeforeChange + * The original value before the change. + */ + protected void rememberChangedField( @Nonnull final String fieldName, @Nullable final Object valueBeforeChange ) + { + if( !changedOriginalFields.containsKey(fieldName) ) { + changedOriginalFields.put(fieldName, valueBeforeChange); + } + } + + /** + * Resets the map of all fields which have been changed on this entity. + *

+ * After calling this method, no field is considered changed, until you change the value of fields on this entity + * afterwards. + */ + public void resetChangedFields() + { + changedOriginalFields.clear(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchChangeSetFluentHelperBasic.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchChangeSetFluentHelperBasic.java new file mode 100644 index 0000000000..d19638cc57 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchChangeSetFluentHelperBasic.java @@ -0,0 +1,216 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestAction; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestBatch; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestCreate; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestDelete; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestUpdate; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperCreate; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperDelete; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperFunction; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperUpdate; +import com.sap.cloud.sdk.datamodel.odata.helper.VdmEntity; + +import lombok.RequiredArgsConstructor; + +/** + * Representation of any changeset in a OData batch request as a fluent interface. + * + * @param + * The fluent helper type of the associated OData batch request. + * @param + * The current type of the implementing fluent helper instance. + */ +@RequiredArgsConstructor +public abstract class BatchChangeSetFluentHelperBasic, ThisT> + implements + FluentHelperBatchEndChangeSet +{ + private final FluentHelperBatchT typedBatchFluentHelper; + private final BatchFluentHelperBasic basicBatchFluentHelper; + + private final List operations = new ArrayList<>(); + + /** + * Method to safely return the current fluent helper instance upon public method calls. + * + * @return The current fluent helper instance. + */ + @Nonnull + protected abstract ThisT getThis(); + + /** + * Add a delete operation to the current changeset request. + * + * @param serviceMethod + * The method of a delete fluent helper from the given service class. + * @param entity + * The entity instance to be deleted. + * @param + * The entity type to guarantee type safety. + * @return The current fluent helper instance. + */ + @Nonnull + protected > ThisT addRequestDelete( + final Function> serviceMethod, + final EntityT entity ) + { + final FluentHelperDelete delete = serviceMethod.apply(entity); + return addRequest(delete); + } + + /** + * Adds a delete operation to the current changeset request. + * + * @param deleteRequest + * The {@link FluentHelperDelete} that represents the delete operation. + * @return The current fluent helper instance. + */ + @Nonnull + protected ThisT addRequest( @Nonnull final FluentHelperDelete deleteRequest ) + { + final ODataRequestDelete request = deleteRequest.toRequest(); + basicBatchFluentHelper.requestMapping.put(deleteRequest, request); + + final BatchRequestChangeSetOperation operation = + new BatchRequestChangeSetOperation(( changeSet ) -> changeSet.addDelete(request), deleteRequest); + + operations.add(operation); + return getThis(); + } + + /** + * Add a create operation to the current changeset request. + * + * @param serviceMethod + * The method of a create fluent helper from the given service class. + * @param entity + * The entity instance to be created. + * @param + * The entity type to guarantee type safety. + * @return The current fluent helper instance. + */ + @Nonnull + protected > ThisT addRequestCreate( + final Function> serviceMethod, + final EntityT entity ) + { + final FluentHelperCreate create = serviceMethod.apply(entity); + return addRequest(create); + } + + /** + * Adds a create operation to the current changeset request. + * + * @param createRequest + * The {@link FluentHelperCreate} that represents the create operation. + * @return The current fluent helper instance. + */ + @Nonnull + protected ThisT addRequest( @Nonnull final FluentHelperCreate createRequest ) + { + final ODataRequestCreate request = createRequest.toRequest(); + basicBatchFluentHelper.requestMapping.put(createRequest, request); + + final BatchRequestChangeSetOperation operation = + new BatchRequestChangeSetOperation(( changeSet ) -> changeSet.addCreate(request), createRequest); + + operations.add(operation); + return getThis(); + } + + /** + * Add an update operation to the current changeset request. + * + * @param serviceMethod + * The method of an update fluent helper from the given service class. + * @param entity + * The entity instance to be updated. + * @param + * The entity type to guarantee type safety. + * @return The current fluent helper instance. + */ + @Nonnull + protected > ThisT addRequestUpdate( + final Function> serviceMethod, + final EntityT entity ) + { + final FluentHelperUpdate update = serviceMethod.apply(entity); + return addRequest(update); + } + + /** + * Adds an update operation to the current changeset request. + * + * @param updateRequest + * The {@link FluentHelperUpdate} that represents the update operation. + * @return The current fluent helper instance. + */ + @Nonnull + protected ThisT addRequest( @Nonnull final FluentHelperUpdate updateRequest ) + { + final ODataRequestUpdate request = updateRequest.toRequest(); + basicBatchFluentHelper.requestMapping.put(updateRequest, request); + + final BatchRequestChangeSetOperation operation = + new BatchRequestChangeSetOperation(( changeSet ) -> changeSet.addUpdate(request), updateRequest); + + operations.add(operation); + return getThis(); + } + + @Nonnull + @Override + public FluentHelperBatchT endChangeSet() + { + final BatchRequestChangeSet changeSet = new BatchRequestChangeSet(operations); + basicBatchFluentHelper.addChangeSet(changeSet); + return typedBatchFluentHelper; + } + + /** + * Adds a function import call to the currently opened changeset. Only use {@code executeRequest} to issue the batch + * request. + * + * @param functionImport + * The {@link FluentHelperFunction} that represents the function import call + * @return The same fluent helper + * @throws IllegalStateException + * If the batch request contains a function import call within a change set which uses the HTTP GET + * method. + */ + @Nonnull + public ThisT addFunctionImport( @Nonnull final FluentHelperFunction functionImport ) + { + final ODataRequestGeneric request = functionImport.toRequest(); + basicBatchFluentHelper.requestMapping.put(functionImport, request); + + final Consumer changesetConsumer; + + if( request instanceof ODataRequestAction ) { + changesetConsumer = changeset -> changeset.addAction((ODataRequestAction) request); + } else { + throw new IllegalStateException( + "Request for function imports in batch change sets must be " + + ODataRequestAction.class.getSimpleName() + + ", but was " + + request.getClass().getSimpleName() + + ". Only function imports using HTTP POST are allowed."); + } + + final BatchRequestChangeSetOperation operation = + new BatchRequestChangeSetOperation(changesetConsumer, functionImport); + + operations.add(operation); + + return getThis(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchFluentHelperBasic.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchFluentHelperBasic.java new file mode 100644 index 0000000000..a14fd06829 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchFluentHelperBasic.java @@ -0,0 +1,219 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Supplier; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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.ODataRequestAction; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestBatch; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestFunction; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; +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.ODataRequestResultMultipartGeneric; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperBasic; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperByKey; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperCreate; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperDelete; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperFunction; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperModification; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperRead; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperUpdate; + +import lombok.AccessLevel; +import lombok.Getter; + +/** + * Representation of any OData batch request as a fluent interface for managing changesets and + * {@link #executeRequest(Destination) executing} them in a single query. + * + * @param + * The fluent helper type. + * @param + * The type of the changesets being managed in this OData batch request. + */ +public abstract class BatchFluentHelperBasic, FluentHelperBatchChangeSetT extends FluentHelperBatchEndChangeSet> + implements + FluentHelperServiceBatch +{ + @Getter( AccessLevel.PACKAGE ) + private final List requestParts = new ArrayList<>(); + final Map, Integer> requestMappingLegacy = new IdentityHashMap<>(); + @Getter( AccessLevel.PACKAGE ) + final Map, ODataRequestGeneric> requestMapping = new IdentityHashMap<>(); + + Supplier uuidProvider = UUID::randomUUID; + + private boolean skipCsrfTokenRetrieval = false; + + /** + * Get the OData service endpoint path for the current OData batch request. Usually it can be found as static member + * DEFAULT_SERVICE_PATH in the service class. + * + * @return The String representation of the OData service endpoint path. + */ + @Nonnull + protected abstract String getServicePathForBatchRequest(); + + /** + * Method to safely return the current fluent helper instance upon public method calls. + * + * @return The current fluent helper instance. + */ + @Nonnull + protected abstract FluentHelperBatchT getThis(); + + @Nonnull + @Override + public BatchResponse executeRequest( @Nonnull final Destination destination ) + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + + @SuppressWarnings( "PMD.CloseResource" ) // The ODataRequestResultMultipartGeneric is closed by DefaultBatchResponseResult + final ODataRequestResultMultipartGeneric result = toRequest().execute(httpClient); + + return DefaultBatchResponseResult.of(requestParts, requestMapping, result); + } + + /** + * Translate this OData v2 batch request into a generic {@link ODataRequestBatch}. + * + * @return A protocol agnostic OData batch request instance. + */ + @Nonnull + public ODataRequestBatch toRequest() + { + final String servicePath = getServicePathForBatchRequest(); + final ODataRequestBatch requestBatch = new ODataRequestBatch(servicePath, ODataProtocol.V2, uuidProvider); + + for( final BatchRequestOperation part : requestParts ) { + part.addToRequestBuilder(requestBatch); + } + if( skipCsrfTokenRetrieval ) { + requestBatch.addHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER, "true"); + } + return requestBatch; + } + + /** + * Deactivates the CSRF token retrieval for this OData request. This is useful if the server does not support or + * require CSRF tokens as part of the request. + * + * @return The same builder + */ + @Nonnull + public FluentHelperBatchT withoutCsrfToken() + { + skipCsrfTokenRetrieval = true; + return getThis(); + } + + /** + * Method handler to register a finished changeset definition. + * + * @param changeSet + * Instance of the changeset, containing OData operations modifying entities. + */ + void addChangeSet( @Nonnull final BatchRequestChangeSet changeSet ) + { + requestParts.add(changeSet); + } + + @Nonnull + @Override + public FluentHelperBatchT addReadOperations( @Nonnull final FluentHelperRead... readOperations ) + { + for( final FluentHelperRead operation : readOperations ) { + final ODataRequestRead request = operation.toRequest(); + requestMappingLegacy.put(operation, requestParts.size()); + requestMapping.put(operation, request); + requestParts.add(new BatchRequestRead.GetAll(operation, request)); + } + return getThis(); + } + + @Nonnull + @Override + public FluentHelperBatchT addReadOperations( @Nonnull final FluentHelperByKey... readByKeyOperations ) + { + for( final FluentHelperByKey operation : readByKeyOperations ) { + final ODataRequestReadByKey request = operation.toRequest(); + requestMappingLegacy.put(operation, requestParts.size()); + requestMapping.put(operation, request); + requestParts.add(new BatchRequestRead.GetByKey(operation, request)); + } + return getThis(); + } + + @Nonnull + @Override + public FluentHelperBatchT addReadOperations( @Nonnull final FluentHelperFunction... functionOperations ) + { + for( final FluentHelperFunction operation : functionOperations ) { + final ODataRequestGeneric request = operation.toRequest(); + if( request instanceof ODataRequestAction ) { + throw new IllegalStateException( + "Request for function imports while adding read operations must be " + + ODataRequestFunction.class.getSimpleName() + + ", but was " + + request.getClass().getSimpleName() + + ". Only function imports using HTTP GET are allowed."); + } + requestMappingLegacy.put(operation, requestParts.size()); + requestMapping.put(operation, request); + requestParts.add(new BatchRequestRead.GetFunctionRequest(operation, (ODataRequestFunction) request)); + } + return getThis(); + } + + @Nonnull + @Override + public FluentHelperBatchT addChangeSet( @Nonnull final FluentHelperModification... modifications ) + { + final FluentHelperBatchChangeSetT changeSet = beginChangeSet(); + + if( !(changeSet instanceof BatchChangeSetFluentHelperBasic) ) { + throw new UnsupportedOperationException( + String + .format( + "%1$s::beginChangeSet does not return an instance of type %2$s. To fix this issue, you may either implement %1$s::addChangeSet yourself, or use the default implementation of %3$s::beginChangeSet.", + getClass().getName(), + BatchChangeSetFluentHelperBasic.class.getName(), + BatchFluentHelperBasic.class.getName())); + } + + final BatchChangeSetFluentHelperBasic mutableChangeSet = + (BatchChangeSetFluentHelperBasic) changeSet; + + for( final FluentHelperModification modification : modifications ) { + final ODataRequestGeneric request = modification.toRequest(); + requestMapping.put(modification, request); + requestMappingLegacy.put(modification, requestMappingLegacy.size()); + + if( modification instanceof FluentHelperCreate ) { + mutableChangeSet.addRequest((FluentHelperCreate) modification); + } else if( modification instanceof FluentHelperUpdate ) { + mutableChangeSet.addRequest((FluentHelperUpdate) modification); + } else if( modification instanceof FluentHelperDelete ) { + mutableChangeSet.addRequest((FluentHelperDelete) modification); + } else { + throw new IllegalArgumentException( + "Failed to add unknown type of modifying operation to OData batch request: " + + modification.getClass().getSimpleName()); + } + } + + changeSet.endChangeSet(); + return getThis(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchRequestChangeSet.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchRequestChangeSet.java new file mode 100644 index 0000000000..d92bbdbdfd --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchRequestChangeSet.java @@ -0,0 +1,32 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import java.util.List; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestBatch; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Instance of a OData batch request changeset, defined by one or many OData operations. + */ +@RequiredArgsConstructor +class BatchRequestChangeSet implements BatchRequestOperation +{ + @Getter( AccessLevel.PACKAGE ) + @Nonnull + private final List operations; + + @Override + public void addToRequestBuilder( @Nonnull final ODataRequestBatch requestBatch ) + { + final ODataRequestBatch.Changeset requestChangeset = requestBatch.beginChangeset(); + for( final BatchRequestChangeSetOperation operation : getOperations() ) { + operation.getChangeSetConsumer().accept(requestChangeset); + } + requestChangeset.endChangeset(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchRequestChangeSetOperation.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchRequestChangeSetOperation.java new file mode 100644 index 0000000000..f5bc12d6b3 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchRequestChangeSetOperation.java @@ -0,0 +1,18 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import java.util.function.Consumer; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestBatch; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperBasic; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter( AccessLevel.PACKAGE ) +class BatchRequestChangeSetOperation +{ + private final Consumer changeSetConsumer; + private final FluentHelperBasic fluentHelper; +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchRequestOperation.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchRequestOperation.java new file mode 100644 index 0000000000..860d7bf8c6 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchRequestOperation.java @@ -0,0 +1,13 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestBatch; + +/** + * Common interface for items on root level of an OData batch request. + */ +interface BatchRequestOperation +{ + void addToRequestBuilder( @Nonnull final ODataRequestBatch requestBatch ); +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchRequestRead.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchRequestRead.java new file mode 100644 index 0000000000..61c115a5f1 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchRequestRead.java @@ -0,0 +1,59 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestBatch; +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.helper.FluentHelperByKey; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperFunction; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperRead; + +import lombok.EqualsAndHashCode; +import lombok.Value; + +abstract class BatchRequestRead implements BatchRequestOperation +{ + @Value + @EqualsAndHashCode( callSuper = true ) + static class GetAll extends BatchRequestRead + { + FluentHelperRead fluentHelper; + ODataRequestRead request; + + @Override + public void addToRequestBuilder( @Nonnull final ODataRequestBatch builder ) + { + builder.addRead(request); + } + } + + @Value + @EqualsAndHashCode( callSuper = true ) + static class GetByKey extends BatchRequestRead + { + FluentHelperByKey fluentHelper; + ODataRequestReadByKey request; + + @Override + public void addToRequestBuilder( @Nonnull final ODataRequestBatch builder ) + { + builder.addReadByKey(request); + } + } + + @Value + @EqualsAndHashCode( callSuper = true ) + static class GetFunctionRequest extends BatchRequestRead + { + FluentHelperFunction fluentHelper; + ODataRequestFunction request; + + @Override + public void addToRequestBuilder( @Nonnull final ODataRequestBatch builder ) + { + builder.addFunction(request); + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchResponse.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchResponse.java new file mode 100644 index 0000000000..43b0b4f864 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchResponse.java @@ -0,0 +1,102 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import java.util.List; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.helper.CollectionValuedFluentHelperFunction; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperByKey; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperRead; +import com.sap.cloud.sdk.datamodel.odata.helper.SingleValuedFluentHelperFunction; +import com.sap.cloud.sdk.datamodel.odata.helper.VdmEntity; + +import io.vavr.control.Try; + +/** + * Interface to access the OData batch response. + */ +public interface BatchResponse extends AutoCloseable +{ + /** + * Get the result for a single changeset. + * + * @param index + * The zero-based index of the selected changeset. + * @return A wrapper of the changeset result. It can be checked for errors upon evaluation. + */ + @Nonnull + Try get( int index ); + + /** + * Convenience method to get the result for a read request on the OData batch response. + * + * @param helper + * The original fluent helper instance that was used to create the request. + * @param + * The generic entity type. + * @return A list of entities according to the original request. + */ + @Nonnull + default > List getReadResult( + @Nonnull final FluentHelperRead helper ) + { + throw new UnsupportedOperationException(); + } + + /** + * Convenience method to get the result for a read-by-key request on the OData batch response. + * + * @param helper + * The original fluent helper instance that was used to create the request. + * @param + * The generic entity type. + * @return A single entity according to the original request. + */ + @Nonnull + default < + EntityT extends VdmEntity> EntityT getReadResult( @Nonnull final FluentHelperByKey helper ) + { + throw new UnsupportedOperationException(); + } + + /** + * Convenience method to get the result for a function import request that returns a single primitive value or + * entity on the OData batch response. + * + * @param helper + * The original fluent helper instance that was used to create the request. + * @param + * The result type of the function import request + * @return A single primitive value or entity according to the original request + */ + @Nonnull + default ResultT getReadResult( @Nonnull final SingleValuedFluentHelperFunction helper ) + { + throw new UnsupportedOperationException(); + } + + /** + * Convenience method to get the result for a function import request that returns a collection of primitive values + * or entities on the OData batch response. + * + * @param helper + * The original fluent helper instance that was used to create the request. + * @param + * The result type of the function import request + * @return A collection of primitive values or entities according to the original request + */ + @Nonnull + default List getReadResult( + @Nonnull final CollectionValuedFluentHelperFunction helper ) + { + throw new UnsupportedOperationException(); + } + + /** + * Closes the underlying HTTP response entity. + * + * @since 4.15.0 + */ + @Override + void close(); +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchResponseChangeSet.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchResponseChangeSet.java new file mode 100644 index 0000000000..36c20acbbe --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchResponseChangeSet.java @@ -0,0 +1,22 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import java.util.List; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.helper.VdmEntity; + +/** + * Interface to evaluate the response of a single changeset from an OData batch response. + */ +public interface BatchResponseChangeSet +{ + /** + * Get all newly created entities from this changeset. + * + * @return A list of generic {@link VdmEntity} instances. The consumer can type-check, evaluate and cast its + * entries. + */ + @Nonnull + List> getCreatedEntities(); +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchService.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchService.java new file mode 100644 index 0000000000..8c61d0f23c --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/BatchService.java @@ -0,0 +1,20 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import javax.annotation.Nonnull; + +/** + * Interface to expose the batch feature for service class. + * + * @param + * The type of the Batch instance. + */ +public interface BatchService +{ + /** + * Instantiate a new FluentHelper instance for a single OData batch request. + * + * @return A new instance of an OData batch request associated with the service object. + */ + @Nonnull + FluentHelperBatchT batch(); +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/DefaultBatchResponseChangeSet.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/DefaultBatchResponseChangeSet.java new file mode 100644 index 0000000000..648d19620d --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/DefaultBatchResponseChangeSet.java @@ -0,0 +1,30 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperCreate; +import com.sap.cloud.sdk.datamodel.odata.helper.VdmEntity; + +import lombok.Value; + +@Value +class DefaultBatchResponseChangeSet implements BatchResponseChangeSet +{ + List changesetOperations; + Function> changesetEntityExtractor; + + @Nonnull + @Override + public List> getCreatedEntities() + { + return changesetOperations + .stream() + .filter(req -> req.getFluentHelper() instanceof FluentHelperCreate) + .map(changesetEntityExtractor) + .collect(Collectors.toList()); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/DefaultBatchResponseResult.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/DefaultBatchResponseResult.java new file mode 100644 index 0000000000..1dafddcc88 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/DefaultBatchResponseResult.java @@ -0,0 +1,134 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultMultipartGeneric; +import com.sap.cloud.sdk.datamodel.odata.helper.CollectionValuedFluentHelperFunction; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperBasic; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperByKey; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperRead; +import com.sap.cloud.sdk.datamodel.odata.helper.SingleValuedFluentHelperFunction; +import com.sap.cloud.sdk.datamodel.odata.helper.VdmEntity; +import com.sap.cloud.sdk.datamodel.odata.helper.VdmEntityUtil; + +import io.vavr.control.Try; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Generic OData service response wrapper for Batch response. + * + */ +@Slf4j +@RequiredArgsConstructor( staticName = "of", access = AccessLevel.PACKAGE ) +public class DefaultBatchResponseResult implements BatchResponse +{ + private final List requestParts; + private final Map, ODataRequestGeneric> requestMapping; + private final ODataRequestResultMultipartGeneric result; + + /** + * Static factory method to convert from generic response to typed response. + * + * @param response + * The generic response that should be converted. + * @param initialRequest + * The initial BatchRequest + * @return The typed (high-level) BatchResponse object. + */ + @Nonnull + public static DefaultBatchResponseResult of( + @Nonnull final ODataRequestResultMultipartGeneric response, + @Nonnull final BatchFluentHelperBasic initialRequest ) + { + return DefaultBatchResponseResult + .of(initialRequest.getRequestParts(), initialRequest.getRequestMapping(), response); + } + + @Nonnull + @Override + public Try get( final int index ) + { + final BatchRequestChangeSet requests = getRequestPartByTypeAndIndex(BatchRequestChangeSet.class, index); + if( requests == null ) { + final String errorMessage = "Unable to find changeset " + index + " in batch request."; + return Try.failure(new IllegalArgumentException(errorMessage)); + } + + // perform health check for first operation response in changeset + final FluentHelperBasic vdmRequest = requests.getOperations().get(0).getFluentHelper(); + final ODataRequestGeneric request = requestMapping.get(vdmRequest); + final Try testParse = Try.run(() -> result.getResult(request)); + if( testParse.isFailure() ) { + return Try.failure(testParse.getCause()); + } + + return Try.success(new DefaultBatchResponseChangeSet(requests.getOperations(), this::getResultingEntity)); + } + + @Nonnull + private VdmEntity getResultingEntity( @Nonnull final BatchRequestChangeSetOperation req ) + { + final ODataRequestGeneric request = requestMapping.get(req.getFluentHelper()); + final Class> entityClass = VdmEntityUtil.getEntityClass(req.getFluentHelper()); + return result.getResult(request).as(entityClass); + } + + @Nonnull + @Override + public > List getReadResult( + @Nonnull final FluentHelperRead helper ) + { + final Class entityClass = VdmEntityUtil.getEntityClass(helper); + return result.getResult(requestMapping.get(helper)).asList(entityClass); + } + + @Nonnull + @Override + public < + EntityT extends VdmEntity> EntityT getReadResult( @Nonnull final FluentHelperByKey helper ) + { + final Class entityClass = VdmEntityUtil.getEntityClass(helper); + return result.getResult(requestMapping.get(helper)).as(entityClass); + } + + @Nonnull + @Override + public ResultT getReadResult( @Nonnull final SingleValuedFluentHelperFunction helper ) + { + final Class resultClass = VdmEntityUtil.getEntityClass(helper); + return result.getResult(requestMapping.get(helper)).as(resultClass); + } + + @Nonnull + @Override + public List getReadResult( + @Nonnull final CollectionValuedFluentHelperFunction helper ) + { + final Class resultClass = VdmEntityUtil.getEntityClass(helper); + return result.getResult(requestMapping.get(helper)).asList(resultClass); + } + + @Nullable + private T getRequestPartByTypeAndIndex( final Class type, final int index ) + { + return requestParts.stream().filter(type::isInstance).map(type::cast).skip(index).findFirst().orElse(null); + } + + /** + * Closes the underlying HTTP response entity. + * + * @since 4.15.0 + */ + @Override + public void close() + { + result.close(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/FluentHelperBatchChangeSet.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/FluentHelperBatchChangeSet.java new file mode 100644 index 0000000000..4bf49c48e9 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/FluentHelperBatchChangeSet.java @@ -0,0 +1,28 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperFunction; + +/** + * Contains methods applicable to enrich the currently open change set. + * + * @param + * The same fluent helper + */ +public interface FluentHelperBatchChangeSet +{ + /** + * Adds a function import call to the currently opened changeset. Only use {@code executeRequest} to issue the batch + * request. + * + * @param functionImport + * The {@link FluentHelperFunction} that represents the function import call + * @return The same fluent helper + * @throws IllegalStateException + * If the batch request contains a function import call within a change set which uses the HTTP GET + * method. + */ + @Nonnull + FluentHelperT addFunctionImport( @Nonnull final FluentHelperFunction functionImport ); +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/FluentHelperBatchEndChangeSet.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/FluentHelperBatchEndChangeSet.java new file mode 100644 index 0000000000..1d25c30f66 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/FluentHelperBatchEndChangeSet.java @@ -0,0 +1,21 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import javax.annotation.Nonnull; + +/** + * Interface to finish the definition of a single changeset. + * + * @param + * Type of the implementing OData batch request class + */ +public interface FluentHelperBatchEndChangeSet +{ + /** + * Finish the definition of a single changeset and return to the parent OData batch request instance. All changesets + * will be evaluated independently from each other. + * + * @return The current OData batch request instance. + */ + @Nonnull + FluentHelperT endChangeSet(); +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/FluentHelperServiceBatch.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/FluentHelperServiceBatch.java new file mode 100644 index 0000000000..ba6f123deb --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/FluentHelperServiceBatch.java @@ -0,0 +1,71 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperByKey; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperFunction; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperModification; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperRead; + +/** + * Interface to the batch object of an OData service. + * + * @param + * Type of the implementing OData batch request class + * @param + * Type of the associated changeset class + */ +public interface FluentHelperServiceBatch> + extends + FluentHelperServiceBatchExecute +{ + /** + * Method to define a new OData batch changeset. Modifying operations on entities in a batch request can only be + * done as part of a changeset. All changesets will be evaluated independently from each other. + * + * @return A new instance of a batch changeset associated with the OData service. + */ + @Nonnull + FluentHelperChangeSetT beginChangeSet(); + + /** + * Add read request to the OData batch request builder. + * + * @param readOperations + * A var-arg array of read operations. + * @return The current OData batch request instance. + */ + @Nonnull + FluentHelperT addReadOperations( @Nonnull final FluentHelperRead... readOperations ); + + /** + * Adds a single OData batch changeset that includes all of the given data modification requests. All changesets + * will be evaluated independently from each other. + * + * @param modifications + * The data modification requests to be performed as part of a single changeset. + * @return The current OData batch request instance. + */ + @Nonnull + FluentHelperT addChangeSet( @Nonnull final FluentHelperModification... modifications ); + + /** + * Add read-by-key request to the OData batch request builder. + * + * @param readByKeyOperations + * A var-arg array of read operations. + * @return The current OData batch request instance. + */ + @Nonnull + FluentHelperT addReadOperations( @Nonnull final FluentHelperByKey... readByKeyOperations ); + + /** + * Add function requests to the OData batch request builder.Only functions that use GET can be added. + * + * @param functionOperations + * A var-arg array of function operations. + * @return The current OData batch request instance + */ + @Nonnull + FluentHelperT addReadOperations( @Nonnull final FluentHelperFunction... functionOperations ); +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/FluentHelperServiceBatchExecute.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/FluentHelperServiceBatchExecute.java new file mode 100644 index 0000000000..a832209529 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/FluentHelperServiceBatchExecute.java @@ -0,0 +1,23 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; + +/** + * Interface to provide execute methods. + * + */ +public interface FluentHelperServiceBatchExecute +{ + /** + * Executes the underlying batch query including the stored changeset operations. + * + * @param destination + * Destination object for resolving the {@code HttpClient} when executing the underlying OData query. + * + * @return A single result element, holding each changeset response. + */ + @Nonnull + BatchResponse executeRequest( @Nonnull final Destination destination ); +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/AbstractCalendarAdapter.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/AbstractCalendarAdapter.java new file mode 100644 index 0000000000..a33dbb04b5 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/AbstractCalendarAdapter.java @@ -0,0 +1,87 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.io.IOException; +import java.util.Calendar; + +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.typeconverter.ConvertedObject; + +/** + * Abstract base class to be used to easily parse fields which can be read as a {@link Calendar} object as a Gson + * {@link TypeAdapter}. + *

+ * This may be used to specify an adapter for the new Java date API (e.g. {@link java.time.LocalDateTime}) based on a + * common conversion logic. This way the logic is split the following way: + * + * + * + * + * + * + * + * + * + * + * + * + * + *
General Conversion logicAbstractTypeConverter subclasses (*CalendarConverter)
Gson AdapterAbstractCalendarAdapter subclasses
Jackson (De)SerializerAbstractJacksonCalendar(De)Serializer and subclasses
+ * + * @param + * The type this adapter should parse. + */ +public abstract class AbstractCalendarAdapter extends TypeAdapter +{ + /** + * Converts a string value read from a json property as an instance of the type to be created by this adapter. + * + * @param jsonString + * The string value of a json property. + * @return A {@code ConvertedObject} instance containing the resulting object, or is empty if conversion was not + * possible. + */ + @Nonnull + protected abstract ConvertedObject convertStringToType( @Nonnull final String jsonString ); + + /** + * Converts an instance of the type handled by this adapter into a string that can be written as a json value. + * + * @param entity + * The entity to convert into a String + * @return A {@code ConvertedObject} instance containing the resulting string, or is empty if conversion was not + * possible. + */ + @Nonnull + protected abstract ConvertedObject convertTypeToString( @Nullable final T entity ); + + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final T value ) + throws IOException + { + final ConvertedObject maybeString = convertTypeToString(value); + if( maybeString.isConvertible() ) { + out.value(maybeString.get()); + } + } + + @Override + @Nullable + public T read( @Nonnull final JsonReader jsonReader ) + throws IOException + { + if( jsonReader.peek() != JsonToken.STRING ) { + jsonReader.skipValue(); + return null; + } + + final String jsonValue = jsonReader.nextString(); + + return convertStringToType(jsonValue).orNull(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/AbstractJacksonCalendarDeserializer.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/AbstractJacksonCalendarDeserializer.java new file mode 100644 index 0000000000..eaefefcddf --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/AbstractJacksonCalendarDeserializer.java @@ -0,0 +1,89 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.io.IOException; +import java.util.Calendar; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.sap.cloud.sdk.typeconverter.AbstractTypeConverter; +import com.sap.cloud.sdk.typeconverter.ConvertedObject; + +/** + * Abstract base class to be used to easily read fields which can be read as a {@link Calendar} object as a Jackson + * {@link StdDeserializer}. + *

+ * This may be used to specify a deserializer for the new Java date API (e.g. {@link java.time.LocalDateTime}) based on + * a common conversion logic. This way the logic is split the following way: + *

+ * + * + * + * + * + * + * + * + * + * + * + * + * + *
General Conversion logicAbstractTypeConverter subclasses (*CalendarConverter)
Gson AdapterAbstractCalendarAdapter subclasses
Jackson (De)SerializerAbstractJacksonCalendar(De)Serializer and subclasses
+ *

+ * + * @param + * The type this deserializer should read. + */ +public abstract class AbstractJacksonCalendarDeserializer extends StdDeserializer +{ + private static final long serialVersionUID = -3931503330406820250L; + + /** + * Constructor needed by the super class. + * + * @param vc + * The class to be read by this deserializer. + */ + protected AbstractJacksonCalendarDeserializer( final Class vc ) + { + super(vc); + } + + /** + * Getter for an instance of the common conversion logic from and to {@code Calendar}. + * + * @return The conversion logic for this deserializer. + */ + @Nonnull + protected abstract AbstractTypeConverter getCalendarConverterInstance(); + + /** + * Getter for the conversion of a String into a Calendar object. The selection of implementation depends on what + * kind of date object the string value should represent. + * + * @return {@code AbstractTypeConverter} instance that converts a String into a Calendar. + */ + @Nonnull + protected abstract AbstractTypeConverter getStringCalendarConverterInstance(); + + @Override + @Nullable + public T deserialize( + @Nonnull final JsonParser jsonParser, + @Nonnull final DeserializationContext deserializationContext ) + throws IOException + { + final ConvertedObject cal = getStringCalendarConverterInstance().toDomain(jsonParser.getText()); + + try { + return getCalendarConverterInstance().fromDomain(cal.orNull()).orNull(); + } + catch( final Exception e ) { + throw new IOException("Could not convert the read Calendar: " + cal, e); + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/AbstractJacksonCalendarSerializer.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/AbstractJacksonCalendarSerializer.java new file mode 100644 index 0000000000..b2ff89fd5e --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/AbstractJacksonCalendarSerializer.java @@ -0,0 +1,88 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.io.IOException; +import java.util.Calendar; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.fasterxml.jackson.databind.ser.std.StringSerializer; +import com.sap.cloud.sdk.typeconverter.AbstractTypeConverter; +import com.sap.cloud.sdk.typeconverter.ConvertedObject; + +/** + * Abstract base class to be used to easily write fields which can be read as a {@link Calendar} object as a Jackson + * {@link StdSerializer}. + *

+ * This may be used to specify a deserializer for the new Java date API (e.g. {@link java.time.LocalDateTime}) based on + * a common conversion logic. This way the logic is split the following way: + *

+ * + * + * + * + * + * + * + * + * + * + * + * + * + *
General Conversion logicAbstractTypeConverter subclasses (*CalendarConverter)
Gson AdapterAbstractCalendarAdapter subclasses
Jackson (De)SerializerAbstractJacksonCalendar(De)Serializer and subclasses
+ *

+ * + * @param + * The type this serializer should read. + */ +public abstract class AbstractJacksonCalendarSerializer extends StdSerializer +{ + private static final long serialVersionUID = -7620182674695291969L; + + /** + * Constructor needed by the super class. + * + * @param t + * The class to be written by this serializer. + */ + protected AbstractJacksonCalendarSerializer( final Class t ) + { + super(t); + } + + /** + * Getter for an instance of the common conversion logic from and to {@code Calendar}. + * + * @return The conversion logic for this serializer. + */ + @Nonnull + protected abstract AbstractTypeConverter getConverterInstance(); + + /** + * Getter for the conversion of a Calendar object into a String. The selection of implementation depends on what + * kind of date object the string value should represent. + * + * @return {@code AbstractTypeConverter} instance that converts a Calendar into a String. + */ + @Nonnull + protected abstract AbstractTypeConverter getStringCalendarConverterInstance(); + + @Override + public void serialize( + @Nullable final T value, + @Nonnull final JsonGenerator jsonGenerator, + @Nonnull final SerializerProvider serializerProvider ) + throws IOException + { + final Calendar cal = getConverterInstance().toDomain(value).orNull(); + final ConvertedObject maybeJson = getStringCalendarConverterInstance().fromDomain(cal); + + if( maybeJson.isConvertible() ) { + new StringSerializer().serialize(maybeJson.get(), jsonGenerator, serializerProvider); + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/IdentityConverter.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/IdentityConverter.java new file mode 100644 index 0000000000..3ea18c1139 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/IdentityConverter.java @@ -0,0 +1,41 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.typeconverter.ConvertedObject; +import com.sap.cloud.sdk.typeconverter.TypeConverter; + +/** + * Default implementation of the {@link TypeConverter} interface, returning all given objects unchanged. + */ +public class IdentityConverter implements TypeConverter +{ + @Override + @Nonnull + public ConvertedObject toDomain( @Nullable final Object object ) + { + return ConvertedObject.of(object); + } + + @Override + @Nonnull + public ConvertedObject fromDomain( @Nullable final Object domainObject ) + { + return ConvertedObject.of(domainObject); + } + + @Override + @Nonnull + public Class getType() + { + return Object.class; + } + + @Override + @Nonnull + public Class getDomainType() + { + return Object.class; + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonLocalDateTimeDeserializer.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonLocalDateTimeDeserializer.java new file mode 100644 index 0000000000..cb8790412c --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonLocalDateTimeDeserializer.java @@ -0,0 +1,39 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.time.LocalDateTime; +import java.util.Calendar; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.typeconverter.AbstractTypeConverter; + +/** + * Jackson deserializer that is able to read {@link LocalDateTime} fields, based on a common logic reading from + * {@link Calendar}. + */ +public class JacksonLocalDateTimeDeserializer extends AbstractJacksonCalendarDeserializer +{ + private static final long serialVersionUID = 2970121420707666471L; + + /** + * Default constructor needed by the framework. + */ + public JacksonLocalDateTimeDeserializer() + { + super(LocalDateTime.class); + } + + @Nonnull + @Override + protected AbstractTypeConverter getCalendarConverterInstance() + { + return new LocalDateTimeCalendarConverter(); + } + + @Nonnull + @Override + protected AbstractTypeConverter getStringCalendarConverterInstance() + { + return new ODataDateTimeStringCalendarConverter(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonLocalDateTimeSerializer.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonLocalDateTimeSerializer.java new file mode 100644 index 0000000000..56f8442d39 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonLocalDateTimeSerializer.java @@ -0,0 +1,39 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.time.LocalDateTime; +import java.util.Calendar; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.typeconverter.AbstractTypeConverter; + +/** + * Jackson deserializer that is able to write {@link LocalDateTime} fields, based on a common logic writing a + * {@link Calendar}. + */ +public class JacksonLocalDateTimeSerializer extends AbstractJacksonCalendarSerializer +{ + private static final long serialVersionUID = 17342648935256631L; + + /** + * Default constructor needed by the framework. + */ + protected JacksonLocalDateTimeSerializer() + { + super(LocalDateTime.class); + } + + @Override + @Nonnull + protected AbstractTypeConverter getConverterInstance() + { + return new LocalDateTimeCalendarConverter(); + } + + @Nonnull + @Override + protected AbstractTypeConverter getStringCalendarConverterInstance() + { + return new ODataDateTimeStringCalendarConverter(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonLocalTimeDeserializer.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonLocalTimeDeserializer.java new file mode 100644 index 0000000000..e604abc7ff --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonLocalTimeDeserializer.java @@ -0,0 +1,39 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.time.LocalTime; +import java.util.Calendar; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.typeconverter.AbstractTypeConverter; + +/** + * Jackson deserializer that is able to read {@link LocalTime} fields, based on a common logic reading from + * {@link Calendar}. + */ +public class JacksonLocalTimeDeserializer extends AbstractJacksonCalendarDeserializer +{ + private static final long serialVersionUID = -6461230022477505904L; + + /** + * Default constructor needed by the framework. + */ + protected JacksonLocalTimeDeserializer() + { + super(LocalTime.class); + } + + @Override + @Nonnull + protected AbstractTypeConverter getCalendarConverterInstance() + { + return new LocalTimeCalendarConverter(); + } + + @Nonnull + @Override + protected AbstractTypeConverter getStringCalendarConverterInstance() + { + return new ODataTimeStringCalendarConverter(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonLocalTimeSerializer.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonLocalTimeSerializer.java new file mode 100644 index 0000000000..280df73f73 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonLocalTimeSerializer.java @@ -0,0 +1,39 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.time.LocalTime; +import java.util.Calendar; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.typeconverter.AbstractTypeConverter; + +/** + * Jackson deserializer that is able to write {@link LocalTime} fields, based on a common logic writing a + * {@link Calendar}. + */ +public class JacksonLocalTimeSerializer extends AbstractJacksonCalendarSerializer +{ + private static final long serialVersionUID = 5786928813508456930L; + + /** + * Default constructor needed by the framework. + */ + protected JacksonLocalTimeSerializer() + { + super(LocalTime.class); + } + + @Override + @Nonnull + protected AbstractTypeConverter getConverterInstance() + { + return new LocalTimeCalendarConverter(); + } + + @Nonnull + @Override + protected AbstractTypeConverter getStringCalendarConverterInstance() + { + return new ODataTimeStringCalendarConverter(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonZonedDateTimeDeserializer.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonZonedDateTimeDeserializer.java new file mode 100644 index 0000000000..37cfdb63a6 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonZonedDateTimeDeserializer.java @@ -0,0 +1,39 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.time.ZonedDateTime; +import java.util.Calendar; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.typeconverter.AbstractTypeConverter; + +/** + * Jackson deserializer that is able to read {@link ZonedDateTime} fields, based on a common logic reading from + * {@link Calendar}. + */ +public class JacksonZonedDateTimeDeserializer extends AbstractJacksonCalendarDeserializer +{ + private static final long serialVersionUID = -7432353030570821745L; + + /** + * Default constructor needed by the framework. + */ + protected JacksonZonedDateTimeDeserializer() + { + super(ZonedDateTime.class); + } + + @Override + @Nonnull + protected AbstractTypeConverter getCalendarConverterInstance() + { + return new ZonedDateTimeCalendarConverter(); + } + + @Nonnull + @Override + protected AbstractTypeConverter getStringCalendarConverterInstance() + { + return new ODataDateTimeOffsetStringCalendarConverter(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonZonedDateTimeSerializer.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonZonedDateTimeSerializer.java new file mode 100644 index 0000000000..24d8e61e06 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/JacksonZonedDateTimeSerializer.java @@ -0,0 +1,39 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.time.ZonedDateTime; +import java.util.Calendar; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.typeconverter.AbstractTypeConverter; + +/** + * Jackson deserializer that is able to write {@link ZonedDateTime} fields, based on a common logic writing a + * {@link Calendar}. + */ +public class JacksonZonedDateTimeSerializer extends AbstractJacksonCalendarSerializer +{ + private static final long serialVersionUID = -3595139411304341322L; + + /** + * Default constructor needed by the framework. + */ + protected JacksonZonedDateTimeSerializer() + { + super(ZonedDateTime.class); + } + + @Override + @Nonnull + protected AbstractTypeConverter getConverterInstance() + { + return new ZonedDateTimeCalendarConverter(); + } + + @Nonnull + @Override + protected AbstractTypeConverter getStringCalendarConverterInstance() + { + return new ODataDateTimeOffsetStringCalendarConverter(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalDateTimeAdapter.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalDateTimeAdapter.java new file mode 100644 index 0000000000..d9c251c347 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalDateTimeAdapter.java @@ -0,0 +1,36 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.time.LocalDateTime; +import java.util.Calendar; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.typeconverter.ConvertedObject; + +/** + * Gson adapter to (de-)serialize fields of type {@link LocalDateTime} from and to Json. + */ +public class LocalDateTimeAdapter extends AbstractCalendarAdapter +{ + private static final ODataDateTimeStringCalendarConverter STRING_CALENDAR_CONVERTER = + new ODataDateTimeStringCalendarConverter(); + private static final LocalDateTimeCalendarConverter LOCAL_DATE_TIME_CALENDAR_CONVERTER = + new LocalDateTimeCalendarConverter(); + + @Nonnull + @Override + protected ConvertedObject convertStringToType( @Nonnull final String jsonString ) + { + final ConvertedObject maybeCalendar = STRING_CALENDAR_CONVERTER.toDomain(jsonString); + return LOCAL_DATE_TIME_CALENDAR_CONVERTER.fromDomain(maybeCalendar.orNull()); + } + + @Nonnull + @Override + protected ConvertedObject convertTypeToString( @Nullable final LocalDateTime entity ) + { + final ConvertedObject convertedCalendar = LOCAL_DATE_TIME_CALENDAR_CONVERTER.toDomain(entity); + return STRING_CALENDAR_CONVERTER.fromDomain(convertedCalendar.orNull()); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalDateTimeCalendarConverter.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalDateTimeCalendarConverter.java new file mode 100644 index 0000000000..ab43eb57d0 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalDateTimeCalendarConverter.java @@ -0,0 +1,73 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoField; +import java.util.Calendar; +import java.util.TimeZone; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.typeconverter.AbstractTypeConverter; +import com.sap.cloud.sdk.typeconverter.ConvertedObject; + +/** + * Converts between the deprecated {@link Calendar} type and the new {@link LocalDateTime}. + *

+ * In combination with the {@link ODataField} annotation this can be used to expose fields of OData value which would be + * exposed as {@code Calendar} as {@code LocalDateTime}. + */ +public class LocalDateTimeCalendarConverter extends AbstractTypeConverter +{ + private static final int MILLI_TO_NANO_FACTOR = 1_000_000; + + @Override + @Nonnull + public ConvertedObject toDomainNonNull( @Nonnull final LocalDateTime object ) + { + // Used with the EdmDateTime class of the service SDK this calender has to be in timezone UTC, see + // Calendar.getTimeInMillis(), where the UTC usage is specified. + final Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + + calendar.set(Calendar.YEAR, object.get(ChronoField.YEAR)); + calendar.set(Calendar.MONTH, object.get(ChronoField.MONTH_OF_YEAR) - 1); // convert 1-based to 0-based months + calendar.set(Calendar.DAY_OF_MONTH, object.get(ChronoField.DAY_OF_MONTH)); + calendar.set(Calendar.HOUR_OF_DAY, object.get(ChronoField.HOUR_OF_DAY)); + calendar.set(Calendar.MINUTE, object.get(ChronoField.MINUTE_OF_HOUR)); + calendar.set(Calendar.SECOND, object.get(ChronoField.SECOND_OF_MINUTE)); + calendar.set(Calendar.MILLISECOND, object.get(ChronoField.MILLI_OF_SECOND)); + + return ConvertedObject.of(calendar); + } + + @Override + @Nonnull + public ConvertedObject fromDomainNonNull( @Nonnull final Calendar domainObject ) + { + final LocalDateTime localDateTime = + LocalDateTime + .of( + domainObject.get(Calendar.YEAR), + domainObject.get(Calendar.MONTH) + 1, // convert 0-based to 1-based months + domainObject.get(Calendar.DAY_OF_MONTH), + domainObject.get(Calendar.HOUR_OF_DAY), + domainObject.get(Calendar.MINUTE), + domainObject.get(Calendar.SECOND), + domainObject.get(Calendar.MILLISECOND) * MILLI_TO_NANO_FACTOR); + + return ConvertedObject.of(localDateTime); + } + + @Override + @Nonnull + public Class getType() + { + return LocalDateTime.class; + } + + @Override + @Nonnull + public Class getDomainType() + { + return Calendar.class; + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalTimeAdapter.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalTimeAdapter.java new file mode 100644 index 0000000000..250349718a --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalTimeAdapter.java @@ -0,0 +1,35 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.time.LocalTime; +import java.util.Calendar; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.typeconverter.ConvertedObject; + +/** + * Gson adapter to (de-)serialize fields of type {@link LocalTime} from and to Json. + */ +public class LocalTimeAdapter extends AbstractCalendarAdapter +{ + private static final ODataTimeStringCalendarConverter TIME_STRING_CALENDAR_CONVERTER = + new ODataTimeStringCalendarConverter(); + private static final LocalTimeCalendarConverter LOCAL_TIME_CALENDAR_CONVERTER = new LocalTimeCalendarConverter(); + + @Nonnull + @Override + protected ConvertedObject convertStringToType( @Nonnull final String jsonString ) + { + final ConvertedObject maybeCalendar = TIME_STRING_CALENDAR_CONVERTER.toDomain(jsonString); + return LOCAL_TIME_CALENDAR_CONVERTER.fromDomain(maybeCalendar.orNull()); + } + + @Nonnull + @Override + protected ConvertedObject convertTypeToString( @Nullable final LocalTime entity ) + { + final ConvertedObject convertedCalendar = LOCAL_TIME_CALENDAR_CONVERTER.toDomain(entity); + return TIME_STRING_CALENDAR_CONVERTER.fromDomain(convertedCalendar.orNull()); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalTimeCalendarConverter.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalTimeCalendarConverter.java new file mode 100644 index 0000000000..61f550e2d3 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalTimeCalendarConverter.java @@ -0,0 +1,71 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.time.LocalTime; +import java.time.temporal.ChronoField; +import java.util.Calendar; +import java.util.TimeZone; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.typeconverter.AbstractTypeConverter; +import com.sap.cloud.sdk.typeconverter.ConvertedObject; + +/** + * Converts between the deprecated {@link Calendar} type and the new {@link LocalTime}. + *

+ * The year, month, and day fields on the {@code Calendar} instance are ignored/cleared, as the {@code LocalTime} does + * not contain any values for those + *

+ * In combination with the {@link ODataField} annotation this can be used to expose fields of OData value which would be + * exposed as {@code Calendar} as {@code LocalTime}. + */ +public class LocalTimeCalendarConverter extends AbstractTypeConverter +{ + private static final int MILLI_TO_NANO_FACTOR = 1_000_000; + + @Override + @Nonnull + public ConvertedObject toDomainNonNull( @Nonnull final LocalTime object ) + { + final Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + + calendar.clear(); + calendar.set(Calendar.HOUR_OF_DAY, object.getHour()); + calendar.set(Calendar.MINUTE, object.getMinute()); + calendar.set(Calendar.SECOND, object.getSecond()); + if( object.isSupported(ChronoField.MILLI_OF_SECOND) ) { + calendar.set(Calendar.MILLISECOND, object.get(ChronoField.MILLI_OF_SECOND)); + } + + return ConvertedObject.of(calendar); + } + + @Override + @Nonnull + public ConvertedObject fromDomainNonNull( @Nonnull final Calendar domainObject ) + { + final LocalTime localDateTime = + LocalTime + .of( + domainObject.get(Calendar.HOUR_OF_DAY), + domainObject.get(Calendar.MINUTE), + domainObject.get(Calendar.SECOND), + domainObject.get(Calendar.MILLISECOND) * MILLI_TO_NANO_FACTOR); + + return ConvertedObject.of(localDateTime); + } + + @Override + @Nonnull + public Class getType() + { + return LocalTime.class; + } + + @Override + @Nonnull + public Class getDomainType() + { + return Calendar.class; + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataBinaryAdapter.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataBinaryAdapter.java new file mode 100644 index 0000000000..cf9706f7f7 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataBinaryAdapter.java @@ -0,0 +1,54 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +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 lombok.extern.slf4j.Slf4j; + +/** + * For internal use only by data model classes + */ +@Slf4j +public class ODataBinaryAdapter extends TypeAdapter +{ + @Override + public void write( @Nonnull final JsonWriter jsonWriter, @Nullable final byte[] bytes ) + throws IOException + { + if( bytes == null ) { + jsonWriter.nullValue(); + return; + } + final String result = new String(Base64.getEncoder().encode(bytes), StandardCharsets.UTF_8); + jsonWriter.value(result); + } + + @Override + @Nullable + public byte[] read( @Nonnull final JsonReader jsonReader ) + throws IOException + { + if( jsonReader.peek() != JsonToken.STRING ) { + jsonReader.skipValue(); + return null; + } + + final String jsonValue = jsonReader.nextString(); + try { + return Base64.getDecoder().decode(jsonValue.getBytes(StandardCharsets.UTF_8)); + } + catch( final IllegalArgumentException e ) { + log.debug("Cannot decode String as byte array: " + e.getMessage()); + return null; + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataBooleanAdapter.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataBooleanAdapter.java new file mode 100644 index 0000000000..c2420d8694 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataBooleanAdapter.java @@ -0,0 +1,43 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.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; + +/** + * For internal use only by data model classes + */ +public class ODataBooleanAdapter extends TypeAdapter +{ + /** + * For internal use only by data model classes + */ + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final Boolean entityValue ) + throws IOException + { + out.value(entityValue); + } + + /** + * For internal use only by data model classes + */ + @Override + @Nonnull + public Boolean read( @Nonnull final JsonReader in ) + throws IOException + { + if( in.peek() == JsonToken.BOOLEAN ) { + return in.nextBoolean(); + } else { + in.skipValue(); + return Boolean.FALSE; + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataCustomFieldAdapter.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataCustomFieldAdapter.java new file mode 100644 index 0000000000..e985b6520e --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataCustomFieldAdapter.java @@ -0,0 +1,112 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +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.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import lombok.RequiredArgsConstructor; + +/** + * For internal use only by data model classes + */ +@RequiredArgsConstructor +public class ODataCustomFieldAdapter extends TypeAdapter +{ + private final Gson gson; + + /** + * For internal use only by data model classes + */ + @Override + @Nullable + public Object read( @Nonnull final JsonReader in ) + throws IOException + { + switch( in.peek() ) { + case NUMBER: { + try { + return in.nextInt(); + } + catch( final NumberFormatException | IllegalStateException notAnInteger ) { + try { + return in.nextLong(); + } + catch( final NumberFormatException | IllegalStateException notALong ) { + return in.nextDouble(); + } + } + } + case BOOLEAN: { + return in.nextBoolean(); + } + case STRING: { + final String value = in.nextString(); + + if( !value.matches("/Date\\((-?\\p{Digit}+)\\)/") ) { + return value; + } + + return new ODataDateTimeStringCalendarConverter().toDomainNonNull(value).orNull(); + } + case BEGIN_ARRAY: { + in.beginArray(); + final List valueList = new ArrayList<>(); + + while( in.hasNext() ) { + valueList.add(read(in)); + } + + in.endArray(); + return valueList; + } + case BEGIN_OBJECT: { + in.beginObject(); + final Map valueObject = new HashMap<>(); + + while( in.hasNext() ) { + final String key = in.nextName(); + if( "__deferred".equals(key) ) { + in.skipValue(); + in.endObject(); + return null; + } else if( "__metadata".equals(key) ) { + in.skipValue(); + } else if( "results".equals(key) ) { + final Object valueList = read(in); + in.endObject(); + return valueList; + } else { + valueObject.put(key, read(in)); + } + } + + in.endObject(); + return valueObject; + } + default: { + in.skipValue(); + } + } + return null; + } + + /** + * For internal use only by data model classes + */ + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final Object value ) + throws IOException + { + // No need to do anything here. Serialization to JSON is handled generically by Gson. + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataDateTimeOffsetStringCalendarConverter.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataDateTimeOffsetStringCalendarConverter.java new file mode 100644 index 0000000000..96f5adeb27 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataDateTimeOffsetStringCalendarConverter.java @@ -0,0 +1,91 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.util.Calendar; +import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.typeconverter.AbstractTypeConverter; +import com.sap.cloud.sdk.typeconverter.ConvertedObject; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +class ODataDateTimeOffsetStringCalendarConverter extends AbstractTypeConverter +{ + private static final Pattern JSON_PATTERN = + Pattern.compile("/Date\\((-?\\p{Digit}+)(?:(\\+|-)(\\p{Digit}{1,4}))?\\)/"); + + @Nonnull + @Override + public ConvertedObject toDomainNonNull( @Nonnull final String value ) + { + final Matcher jsonMatcher = JSON_PATTERN.matcher(value); + if( jsonMatcher.matches() ) { + long millis; + try { + millis = Long.parseLong(jsonMatcher.group(1)); + } + catch( final NumberFormatException e ) { + log.debug("The given date string cannot be converted to milliseconds: " + e.getMessage()); + return ConvertedObject.ofNotConvertible(); + } + + String timeZone = "GMT"; + if( jsonMatcher.group(2) != null ) { + final int offsetInMinutes = Integer.parseInt(jsonMatcher.group(3)); + if( offsetInMinutes >= 24 * 60 ) { + log.debug("The given offset is higher than minutes in a day: " + offsetInMinutes); + return ConvertedObject.ofNotConvertible(); + } + if( offsetInMinutes != 0 ) { + timeZone += + jsonMatcher.group(2) + offsetInMinutes / 60 + ":" + String.format("%02d", offsetInMinutes % 60); + // Convert the local-time milliseconds to UTC. + millis -= ("+".equals(jsonMatcher.group(2)) ? 1 : -1) * offsetInMinutes * 60 * 1000L; + } + } + final Calendar dateTimeValue = Calendar.getInstance(TimeZone.getTimeZone(timeZone)); + dateTimeValue.clear(); + dateTimeValue.setTimeInMillis(millis); + return ConvertedObject.of(dateTimeValue); + } + + return ConvertedObject.ofNotConvertible(); + } + + @Nonnull + @Override + public ConvertedObject fromDomainNonNull( @Nonnull final Calendar value ) + { + // number of milliseconds since 1970-01-01T00:00:00Z + long milliSeconds = value.getTimeInMillis(); + // offset in milliseconds from GMT to the requested time zone + final int offset = value.get(Calendar.ZONE_OFFSET) + value.get(Calendar.DST_OFFSET); + + milliSeconds += offset; // Convert from UTC to local time. + final int offsetInMinutes = offset / 60 / 1000; + + if( offset == 0 ) { + return ConvertedObject.of("/Date(" + milliSeconds + ")/"); + } + + return ConvertedObject.of("/Date(" + milliSeconds + String.format("%+05d", offsetInMinutes) + ")/"); + } + + @Nonnull + @Override + public Class getType() + { + return String.class; + } + + @Nonnull + @Override + public Class getDomainType() + { + return Calendar.class; + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataDateTimeStringCalendarConverter.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataDateTimeStringCalendarConverter.java new file mode 100644 index 0000000000..3a0a8ce05d --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataDateTimeStringCalendarConverter.java @@ -0,0 +1,66 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.util.Calendar; +import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.typeconverter.AbstractTypeConverter; +import com.sap.cloud.sdk.typeconverter.ConvertedObject; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +class ODataDateTimeStringCalendarConverter extends AbstractTypeConverter +{ + private static final Pattern JSON_PATTERN = Pattern.compile("/Date\\((-?\\p{Digit}+)\\)/"); + + @Nonnull + @Override + public ConvertedObject toDomainNonNull( @Nonnull final String value ) + { + final Matcher matcher = JSON_PATTERN.matcher(value); + if( !matcher.matches() ) { + return ConvertedObject.ofNotConvertible(); + } + + final long millis; + try { + millis = Long.parseLong(matcher.group(1)); + } + catch( final NumberFormatException e ) { + log.debug("The given date string cannot be converted to milliseconds: " + e.getMessage()); + return ConvertedObject.ofNotConvertible(); + } + final Calendar dateTimeValue = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + dateTimeValue.clear(); + dateTimeValue.setTimeInMillis(millis); + return ConvertedObject.of(dateTimeValue); + + } + + @Nonnull + @Override + public ConvertedObject fromDomainNonNull( @Nonnull final Calendar value ) + { + final long timeInMillis = value.getTimeInMillis(); + + return ConvertedObject.of("/Date(" + timeInMillis + ")/"); + } + + @Nonnull + @Override + public Class getType() + { + return String.class; + } + + @Nonnull + @Override + public Class getDomainType() + { + return Calendar.class; + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataField.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataField.java new file mode 100644 index 0000000000..7e02ac7a50 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataField.java @@ -0,0 +1,36 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.typeconverter.TypeConverter; + +/** + * Annotation to be used to link fields to their OData property as well as converting between the domain type of a field + * and the actually exposed type. + */ +@Target( ElementType.FIELD ) +@Retention( RetentionPolicy.RUNTIME ) +@Documented +public @interface ODataField { + /** + * The name of the OData property this field gets mapped to. + * + * @return The name of the corresponding OData property. + */ + @Nonnull + String odataName(); + + /** + * The converter to be used to convert between the domain and the exposed type of the annotated field. + * + * @return The type of the converter to use for the annotated field. + */ + @Nonnull + Class> converter() default IdentityConverter.class; +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataTimeStringCalendarConverter.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataTimeStringCalendarConverter.java new file mode 100644 index 0000000000..6c3f03bf10 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataTimeStringCalendarConverter.java @@ -0,0 +1,124 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.util.Calendar; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.typeconverter.AbstractTypeConverter; +import com.sap.cloud.sdk.typeconverter.ConvertedObject; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +class ODataTimeStringCalendarConverter extends AbstractTypeConverter +{ + private static final Pattern PATTERN = Pattern.compile(""" + P(?:(\\p{Digit}{1,2})Y)?(?:(\\p{Digit}{1,2})M)?(?:(\\p{Digit}{1,2})D)?\ + T(?:(\\p{Digit}{1,2})H)?(?:(\\p{Digit}{1,4})M)?(?:(\\p{Digit}{1,5})(?:\\.(\\p{Digit}+?)0*)?S)?\ + """); + + @Nonnull + @Override + public ConvertedObject toDomainNonNull( @Nonnull final String jsonValue ) + { + final Matcher matcher = PATTERN.matcher(jsonValue); + if( !matcher.matches() ) { + return ConvertedObject.ofNotConvertible(); + } + if( matcher.group(1) == null + && matcher.group(2) == null + && matcher.group(3) == null + && matcher.group(4) == null + && matcher.group(5) == null + && matcher.group(6) == null ) { + return ConvertedObject.ofNotConvertible(); + } + + final Calendar dateTimeValue = Calendar.getInstance(); + dateTimeValue.clear(); + + if( matcher.group(1) != null ) { + dateTimeValue.set(Calendar.YEAR, Integer.parseInt(matcher.group(1))); + } + if( matcher.group(2) != null ) { + dateTimeValue.set(Calendar.MONTH, Integer.parseInt(matcher.group(2))); + } + if( matcher.group(3) != null ) { + dateTimeValue.set(Calendar.DAY_OF_YEAR, Integer.parseInt(matcher.group(3))); + } + dateTimeValue.set(Calendar.HOUR_OF_DAY, matcher.group(4) == null ? 0 : Integer.parseInt(matcher.group(4))); + dateTimeValue.set(Calendar.MINUTE, matcher.group(5) == null ? 0 : Integer.parseInt(matcher.group(5))); + dateTimeValue.set(Calendar.SECOND, matcher.group(6) == null ? 0 : Integer.parseInt(matcher.group(6))); + + if( matcher.group(7) != null ) { + final String decimals = matcher.group(7); + final int nanoSeconds = Integer.parseInt(decimals + "000000000".substring(decimals.length())); + if( nanoSeconds % (1000 * 1000) == 0 ) { + dateTimeValue.set(Calendar.MILLISECOND, nanoSeconds / (1000 * 1000)); + } else { + log + .debug( + "The given date has a precision that cannot be represented in milliseconds. Nanoseconds: " + + nanoSeconds); + return ConvertedObject.ofNotConvertible(); + } + } + + return ConvertedObject.of(dateTimeValue); + } + + @Nonnull + @Override + public ConvertedObject fromDomainNonNull( @Nonnull final Calendar calendar ) + { + final StringBuilder result = new StringBuilder(21); // 21 characters are enough for nanosecond precision. + result.append('P'); + result.append('T'); + result.append(calendar.get(Calendar.HOUR_OF_DAY)); + result.append('H'); + result.append(calendar.get(Calendar.MINUTE)); + result.append('M'); + result.append(calendar.get(Calendar.SECOND)); + + final int fractionalSecs = calendar.get(Calendar.MILLISECOND); + appendFractionalSeconds(result, fractionalSecs); + result.append('S'); + + return ConvertedObject.of(result.toString()); + } + + protected static void appendFractionalSeconds( final StringBuilder result, final int fractionalSeconds ) + { + if( fractionalSeconds > 0 ) { + // Determine the number of significant digits. + int output = fractionalSeconds; + while( output % 10 == 0 ) { + output /= 10; + } + + result.append('.'); + for( int d = 100; d > 0; d /= 10 ) { + final byte digit = (byte) (fractionalSeconds % (d * 10) / d); + if( digit > 0 || fractionalSeconds % d > 0 ) { + result.append((char) ('0' + digit)); + } + } + } + } + + @Nonnull + @Override + public Class getType() + { + return String.class; + } + + @Nonnull + @Override + public Class getDomainType() + { + return Calendar.class; + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataVdmEntityAdapter.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataVdmEntityAdapter.java new file mode 100644 index 0000000000..0426b5e285 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataVdmEntityAdapter.java @@ -0,0 +1,301 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import static java.util.function.Predicate.not; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.common.base.Strings; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.google.gson.reflect.TypeToken; +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.helper.VdmEntity; +import com.sap.cloud.sdk.datamodel.odata.helper.VdmObject; +import com.sap.cloud.sdk.result.ElementName; + +import io.vavr.control.Option; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * For internal use only by data model classes + * + * @param + * The generic VDM object type + */ +@Slf4j +public class ODataVdmEntityAdapter extends TypeAdapter> +{ + @Nonnull + private final Gson gson; + + @Nonnull + private final Class entityRawType; + + @Nullable + private TypeAdapter> delegateAdapter = null; + + @Nullable + private ODataVdmEntityAdapter superClassAdapter = null; + + @Nonnull + private final TypeAdapter customFieldAdapter; + + // (1) Field has stateful accessibility flag + // (2) Multiple threads may access the Map, therefore state must be isolated with ThreadLocal + // (3) ThreadLocal is to be defined statically, therefore the properties must be mapped to a class reference + private static final ThreadLocal, Map>> fieldProperties = + ThreadLocal.withInitial(IdentityHashMap::new); + + @Nonnull + private Map getFieldProperties( @Nonnull final Class type ) + { + return fieldProperties.get().computeIfAbsent(type, ODataVdmEntityAdapter::createFieldProperties); + } + + @Nonnull + private static Map createFieldProperties( @Nonnull final Class type ) + { + final Map result = new LinkedHashMap<>(); + for( final Field field : type.getDeclaredFields() ) { + if( field.isAnnotationPresent(ElementName.class) || field.isAnnotationPresent(SerializedName.class) ) { + final String odataName = + field.isAnnotationPresent(ElementName.class) + ? field.getAnnotation(ElementName.class).value() + : field.getAnnotation(SerializedName.class).value(); + result.put(odataName, field); + } + } + return result; + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + private static class ODataV2Metadata + { + @Nullable + @SerializedName( "uri" ) + @ODataField( odataName = "uri" ) + private String uri; + + @Nullable + @SerializedName( "etag" ) + @ODataField( odataName = "etag" ) + private String etag; + + @Nullable + @SerializedName( "type" ) + @ODataField( odataName = "type" ) + private String type; + } + + @Nonnull + private static TypeAdapter getAdapterFromField( @Nonnull final Field entityField, @Nonnull final Gson gson ) + { + if( entityField.isAnnotationPresent(JsonAdapter.class) ) { + try { + return (TypeAdapter) entityField + .getAnnotation(JsonAdapter.class) + .value() + .getDeclaredConstructor() + .newInstance(); + } + catch( final + InstantiationException + | IllegalAccessException + | NoSuchMethodException + | InvocationTargetException e ) { + log.warn("Could not instantiate the field '" + entityField.getName() + "'.", e); + } + } + if( Iterable.class.isAssignableFrom(entityField.getType()) ) { + final ParameterizedType entityFieldTypeParams = (ParameterizedType) entityField.getGenericType(); + final Type listEntityType = entityFieldTypeParams.getActualTypeArguments()[0]; + final TypeAdapter innerTypeAdapter = gson.getAdapter(TypeToken.get(listEntityType)); + return new ODataVdmEntityListAdapter<>(gson, innerTypeAdapter); + } + return gson.getAdapter(entityField.getType()); + } + + /** + * For internal use only by data model classes + * + * @param adapterFactory + * The GSON type adapter factory + * @param gson + * The GSON reference + * @param entityRawType + * The generic entity raw type + */ + @SuppressWarnings( "unchecked" ) + public ODataVdmEntityAdapter( + @Nonnull final TypeAdapterFactory adapterFactory, + @Nonnull final Gson gson, + @Nonnull final Class entityRawType ) + { + this.gson = gson; + this.entityRawType = entityRawType; + customFieldAdapter = new ODataCustomFieldAdapter(gson); + + final Class entityRawSuperType = entityRawType.getSuperclass(); + if( Object.class == entityRawSuperType ) { + delegateAdapter = + (TypeAdapter>) gson.getDelegateAdapter(adapterFactory, TypeToken.get(entityRawType)); + } else { + superClassAdapter = new ODataVdmEntityAdapter<>(adapterFactory, gson, entityRawSuperType); + } + } + + /** + * For internal use only by data model classes + */ + @Override + @Nullable + public VdmObject read( @Nonnull final JsonReader jsonReader ) + throws IOException + { + try { + @SuppressWarnings( "unchecked" ) + final VdmObject entity = (VdmObject) entityRawType.getDeclaredConstructor().newInstance(); + + if( jsonReader.peek() == JsonToken.BEGIN_OBJECT ) { + jsonReader.beginObject(); + + while( jsonReader.hasNext() ) { + final String propertyKey = jsonReader.nextName(); + + if( "__metadata".equals(propertyKey) ) { + if( jsonReader.peek() == JsonToken.BEGIN_OBJECT ) { + final ODataV2Metadata metadata = new Gson().fromJson(jsonReader, ODataV2Metadata.class); + final Option maybeEtag = + Option.of(metadata).map(ODataV2Metadata::getEtag).filter(not(Strings::isNullOrEmpty)); + if( maybeEtag.isDefined() && entity instanceof VdmEntity ) { + ((VdmEntity) entity).setVersionIdentifier(maybeEtag.get()); + } + } else { + log.warn("Expected JSON value \"__metadata\" to be an object."); + jsonReader.skipValue(); + } + } else if( "__deferred".equals(propertyKey) ) { + jsonReader.skipValue(); + jsonReader.endObject(); + return null; + } else { + final Field entityField = getPropertySerializationInfo(propertyKey); + if( entityField != null ) { + final TypeAdapter fieldAdapter = getAdapterFromField(entityField, gson); + final Object attributeValue = fieldAdapter.read(jsonReader); + + // To be safe/secure, since fields are declared private in the VDM. + final boolean oldAccessibleValue = entityField.canAccess(entity); + entityField.setAccessible(true); + entityField.set(entity, attributeValue); + entityField.setAccessible(oldAccessibleValue); + } else { + entity.getCustomFields().put(propertyKey, customFieldAdapter.read(jsonReader)); + } + } + } + + jsonReader.endObject(); + } else if( jsonReader.peek() == JsonToken.NULL ) { + jsonReader.nextNull(); + return null; + } + + return entity; + } + catch( final + InstantiationException + | IllegalAccessException + | NoSuchMethodException + | InvocationTargetException e ) { + log.error("Could not instantiate or initialize '{}'. Returning null instead.", entityRawType.getName(), e); + } + return null; + } + + @Nullable + private Field getPropertySerializationInfo( final String propertyKey ) + { + Field result = getFieldProperties(entityRawType).get(propertyKey); + if( result == null && superClassAdapter != null ) { + result = superClassAdapter.getPropertySerializationInfo(propertyKey); + } + return result; + } + + /** + * For internal use only by data model classes + */ + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final VdmObject value ) + throws IOException + { + if( value != null ) { + final JsonObject entityAsJson = getEntityAsJsonObject(value); + final JsonObject customFieldsAsJson = gson.toJsonTree(value.getCustomFields()).getAsJsonObject(); + + for( final Map.Entry customField : customFieldsAsJson.entrySet() ) { + entityAsJson.add(customField.getKey(), customField.getValue()); + } + + gson.toJson(entityAsJson, out); + } else { + out.nullValue(); + } + } + + @SuppressWarnings( "unchecked" ) + private JsonObject getEntityAsJsonObject( final VdmObject value ) + { + if( delegateAdapter != null ) { + return delegateAdapter.toJsonTree(value).getAsJsonObject(); + } else { + final JsonObject entityAsJson = superClassAdapter.getEntityAsJsonObject(value); + + for( final Map.Entry entityProperty : getFieldProperties(entityRawType).entrySet() ) { + try { + final Field propertyField = entityProperty.getValue(); + + // To be safe/secure, since fields are declared private in the VDM. + final boolean oldAccessibleValue = propertyField.canAccess(value); + propertyField.setAccessible(true); + final Object propertyValue = propertyField.get(value); + propertyField.setAccessible(oldAccessibleValue); + + final TypeAdapter fieldAdapter = + (TypeAdapter) getAdapterFromField(propertyField, gson); + final JsonElement propertyValueAsJson = fieldAdapter.toJsonTree(propertyValue); + + // Overwrites JSON property from the superclass if this class has a property with the same name. + entityAsJson.add(entityProperty.getKey(), propertyValueAsJson); + } + catch( final IllegalAccessException e ) { + log.error("Could not serialize property '{}'. Returning null instead.", entityProperty.getKey(), e); + } + } + return entityAsJson; + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataVdmEntityAdapterFactory.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataVdmEntityAdapterFactory.java new file mode 100644 index 0000000000..f563087624 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataVdmEntityAdapterFactory.java @@ -0,0 +1,34 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.sap.cloud.sdk.datamodel.odata.helper.VdmObject; + +/** + * For internal use only by data model classes. + */ +@SuppressWarnings( "unchecked" ) +public class ODataVdmEntityAdapterFactory implements TypeAdapterFactory +{ + /** + * For internal use only by data model classes. + * + * {@inheritDoc} + */ + @Override + @Nullable + public TypeAdapter create( @Nonnull final Gson gson, @Nonnull final TypeToken type ) + { + final Class entityType = type.getRawType(); + + if( VdmObject.class.isAssignableFrom(entityType) ) { + return (TypeAdapter) new ODataVdmEntityAdapter<>(this, gson, entityType); + } + return null; + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataVdmEntityListAdapter.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataVdmEntityListAdapter.java new file mode 100644 index 0000000000..4093f95aec --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataVdmEntityListAdapter.java @@ -0,0 +1,103 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +/** + * For internal use only by data model classes + * + * @param + * The generic value type. + */ +public class ODataVdmEntityListAdapter extends TypeAdapter> +{ + private final Gson gson; + private final TypeAdapter entityAdapter; + + /** + * For internal use only by data model classes + * + * @param gson + * The GSON instance to access serialization and desrialization. + * @param entityAdapter + * The entity type adapter to be used. + */ + public ODataVdmEntityListAdapter( @Nonnull final Gson gson, @Nonnull final TypeAdapter entityAdapter ) + { + this.gson = gson; + this.entityAdapter = entityAdapter; + } + + private List readArray( final JsonReader in ) + throws IOException + { + in.beginArray(); + final List entityList = new ArrayList<>(); + + while( in.hasNext() ) { + @SuppressWarnings( "unchecked" ) + final T entity = entityAdapter.read(in); + entityList.add(entity); + } + + in.endArray(); + return entityList; + } + + /** + * For internal use only by data model classes + */ + @Override + @Nullable + public List read( @Nonnull final JsonReader in ) + throws IOException + { + List entityList = null; + if( in.peek() == JsonToken.BEGIN_OBJECT ) { + in.beginObject(); + if( in.peek() == JsonToken.NAME ) { + final String resultsKey = in.nextName(); + if( "results".equals(resultsKey) && in.peek() == JsonToken.BEGIN_ARRAY ) { + entityList = readArray(in); + } else { + in.skipValue(); + } + } + in.endObject(); + } else if( in.peek() == JsonToken.BEGIN_ARRAY ) { + entityList = readArray(in); + } else { + in.skipValue(); + } + return entityList; + } + + /** + * For internal use only by data model classes + */ + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final List entityList ) + throws IOException + { + if( entityList != null ) { + final JsonArray entityListAsJson = new JsonArray(); + for( final T entity : entityList ) { + entityListAsJson.add(entityAdapter.toJsonTree(entity)); + } + gson.toJson(entityListAsJson, out); + } else { + out.nullValue(); + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ZonedDateTimeAdapter.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ZonedDateTimeAdapter.java new file mode 100644 index 0000000000..1a2bf9eaf4 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ZonedDateTimeAdapter.java @@ -0,0 +1,36 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.time.ZonedDateTime; +import java.util.Calendar; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.typeconverter.ConvertedObject; + +/** + * Gson adapter to (de-)serialize fields of type {@link ZonedDateTime} from and to Json. + */ +public class ZonedDateTimeAdapter extends AbstractCalendarAdapter +{ + private static final ODataDateTimeOffsetStringCalendarConverter DATE_TIME_OFFSET_STRING_CALENDAR_CONVERTER = + new ODataDateTimeOffsetStringCalendarConverter(); + private static final ZonedDateTimeCalendarConverter ZONED_DATE_TIME_CALENDAR_CONVERTER = + new ZonedDateTimeCalendarConverter(); + + @Nonnull + @Override + protected ConvertedObject convertStringToType( @Nonnull final String jsonString ) + { + final ConvertedObject maybeCalendar = DATE_TIME_OFFSET_STRING_CALENDAR_CONVERTER.toDomain(jsonString); + return ZONED_DATE_TIME_CALENDAR_CONVERTER.fromDomain(maybeCalendar.orNull()); + } + + @Nonnull + @Override + protected ConvertedObject convertTypeToString( @Nullable final ZonedDateTime entity ) + { + final ConvertedObject convertedCalendar = ZONED_DATE_TIME_CALENDAR_CONVERTER.toDomain(entity); + return DATE_TIME_OFFSET_STRING_CALENDAR_CONVERTER.fromDomain(convertedCalendar.orNull()); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ZonedDateTimeCalendarConverter.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ZonedDateTimeCalendarConverter.java new file mode 100644 index 0000000000..a9d0e2c15c --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ZonedDateTimeCalendarConverter.java @@ -0,0 +1,51 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import java.time.ZonedDateTime; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.TimeZone; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.typeconverter.AbstractTypeConverter; +import com.sap.cloud.sdk.typeconverter.ConvertedObject; + +/** + * Converts between the deprecated {@link Calendar} type and the new {@link ZonedDateTime}. + *

+ * In combination with the {@link ODataField} annotation this can be used to expose fields of OData value which would be + * exposed as {@code Calendar} as {@code ZonedDateTime}. + */ +public class ZonedDateTimeCalendarConverter extends AbstractTypeConverter +{ + @Override + @Nonnull + public ConvertedObject toDomainNonNull( @Nonnull final ZonedDateTime object ) + { + final Calendar cal = GregorianCalendar.from(object); + return ConvertedObject.of(cal); + } + + @Override + @Nonnull + public ConvertedObject fromDomainNonNull( @Nonnull final Calendar domainObject ) + { + final TimeZone timeZone = domainObject.getTimeZone(); + final ZonedDateTime zdt = ZonedDateTime.ofInstant(domainObject.toInstant(), timeZone.toZoneId()); + return ConvertedObject.of(zdt); + } + + @Override + @Nonnull + public Class getType() + { + return ZonedDateTime.class; + } + + @Override + @Nonnull + public Class getDomainType() + { + return Calendar.class; + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/annotation/Key.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/annotation/Key.java new file mode 100644 index 0000000000..7ee198fa07 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/annotation/Key.java @@ -0,0 +1,17 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to mark a field in a VDM entity as being a key field. This means that the annotated field plus any other + * fields with this annotation uniquely identify an instance of + * {@link com.sap.cloud.sdk.datamodel.odata.helper.VdmEntity}. The VDM generator will add this annotation to the entity + * classes it creates based on the OData EDMX ({@code } tag under {@code }) + */ +@Retention( RetentionPolicy.RUNTIME ) +@Target( ElementType.FIELD ) +public @interface Key { +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/exception/NoSuchEntityFieldException.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/exception/NoSuchEntityFieldException.java new file mode 100644 index 0000000000..e865b80a7b --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/exception/NoSuchEntityFieldException.java @@ -0,0 +1,49 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.exception; + +import javax.annotation.Nullable; + +import lombok.NoArgsConstructor; + +/** + * Throws if a certain field cannot be found for an entity. + */ +@NoArgsConstructor +public class NoSuchEntityFieldException extends RuntimeException +{ + private static final long serialVersionUID = -5897105662911702521L; + + /** + * Initializes a new {@link NoSuchEntityFieldException} instance. + * + * @param message + * The exception message. + */ + public NoSuchEntityFieldException( @Nullable final String message ) + { + super(message); + } + + /** + * Initializes a new {@link NoSuchEntityFieldException} instance. + * + * @param cause + * The exception cause. + */ + public NoSuchEntityFieldException( @Nullable final Throwable cause ) + { + super(cause); + } + + /** + * Initializes a new {@link NoSuchEntityFieldException} instance. + * + * @param message + * The exception message. + * @param cause + * The exception cause. + */ + public NoSuchEntityFieldException( @Nullable final String message, @Nullable final Throwable cause ) + { + super(message, cause); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/exception/ODataPayloadParsingFailedException.java b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/exception/ODataPayloadParsingFailedException.java new file mode 100644 index 0000000000..ddc84b08d0 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/s4hana/datamodel/odata/exception/ODataPayloadParsingFailedException.java @@ -0,0 +1,37 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.exception; + +import javax.annotation.Nullable; + +/** + * ODataPayloadParsingFailedException will be thrown whenever the VDM encounters errors during parsing that cannot be + * recovered from. Possible causes are when the ODataJsonMapResolver is instructed to throw exceptions when encountering + * null values or when TypeConverters for specific fields cannot be instantiated. + */ +public class ODataPayloadParsingFailedException extends RuntimeException +{ + private static final long serialVersionUID = 6446357797006978261L; + + /** + * Returns a new ODataPayloadParsingFailedException instance. + * + * @param message + * The error message. + * @param cause + * The exception causing the error. + */ + public ODataPayloadParsingFailedException( @Nullable final String message, @Nullable final Exception cause ) + { + super(message, cause); + } + + /** + * Returns a new ODataPayloadParsingFailedException instance. + * + * @param message + * The error message. + */ + public ODataPayloadParsingFailedException( @Nullable final String message ) + { + super(message); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/adapter/LocalDateTimeCalendarConverterTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/adapter/LocalDateTimeCalendarConverterTest.java new file mode 100644 index 0000000000..d0395546dd --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/adapter/LocalDateTimeCalendarConverterTest.java @@ -0,0 +1,77 @@ +package com.sap.cloud.sdk.datamodel.odata.adapter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.time.Month; +import java.time.temporal.ChronoField; +import java.util.Calendar; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.LocalDateTimeCalendarConverter; + +class LocalDateTimeCalendarConverterTest +{ + @Test + void testFromDomain() + { + final int someYear = 2017; + final int someMonth = Calendar.MAY; + final int someDay = 24; + final int someHour = 15; + final int someMinute = 57; + final int someSecond = 42; + final int someMillisecond = 852; + + final Calendar input = Calendar.getInstance(); + input.clear(); + input.set(Calendar.YEAR, someYear); + input.set(Calendar.MONTH, someMonth); + input.set(Calendar.DAY_OF_MONTH, someDay); + input.set(Calendar.HOUR_OF_DAY, someHour); + input.set(Calendar.MINUTE, someMinute); + input.set(Calendar.SECOND, someSecond); + input.set(Calendar.MILLISECOND, someMillisecond); + + final LocalDateTime result = new LocalDateTimeCalendarConverter().fromDomain(input).get(); + + assertThat(result).isNotNull(); + + assertThat(result.get(ChronoField.YEAR)).isEqualTo(someYear); + assertThat(result.get(ChronoField.MONTH_OF_YEAR)).isEqualTo(Month.MAY.getValue()); + assertThat(result.get(ChronoField.DAY_OF_MONTH)).isEqualTo(someDay); + assertThat(result.get(ChronoField.HOUR_OF_DAY)).isEqualTo(someHour); + assertThat(result.get(ChronoField.MINUTE_OF_HOUR)).isEqualTo(someMinute); + assertThat(result.get(ChronoField.SECOND_OF_MINUTE)).isEqualTo(someSecond); + assertThat(result.get(ChronoField.MILLI_OF_SECOND)).isEqualTo(someMillisecond); + } + + @Test + void testToDomain() + { + final int someYear = 2017; + final int someMonth = Month.MAY.getValue(); + final int someDay = 24; + final int someHour = 15; + final int someMinute = 57; + final int someSecond = 42; + final int someMillisecond = 852; + final int millisAsNanoseconds = someMillisecond * 1_000_000; + + final LocalDateTime input = + LocalDateTime.of(someYear, someMonth, someDay, someHour, someMinute, someSecond, millisAsNanoseconds); + + final Calendar result = new LocalDateTimeCalendarConverter().toDomain(input).get(); + + assertThat(result).isNotNull(); + + assertThat(result.get(Calendar.YEAR)).isEqualTo(someYear); + assertThat(result.get(Calendar.MONTH)).isEqualTo(Calendar.MAY); + assertThat(result.get(Calendar.DAY_OF_MONTH)).isEqualTo(someDay); + assertThat(result.get(Calendar.HOUR_OF_DAY)).isEqualTo(someHour); + assertThat(result.get(Calendar.MINUTE)).isEqualTo(someMinute); + assertThat(result.get(Calendar.SECOND)).isEqualTo(someSecond); + assertThat(result.get(Calendar.MILLISECOND)).isEqualTo(someMillisecond); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/adapter/LocalTimeCalendarConverterTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/adapter/LocalTimeCalendarConverterTest.java new file mode 100644 index 0000000000..11062fa961 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/adapter/LocalTimeCalendarConverterTest.java @@ -0,0 +1,60 @@ +package com.sap.cloud.sdk.datamodel.odata.adapter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalTime; +import java.time.temporal.ChronoField; +import java.util.Calendar; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.LocalTimeCalendarConverter; + +class LocalTimeCalendarConverterTest +{ + @Test + void testFromDomain() + { + final int someHour = 15; + final int someMinute = 57; + final int someSecond = 42; + final int someMillisecond = 852; + + final Calendar input = Calendar.getInstance(); + input.clear(); + input.set(Calendar.HOUR_OF_DAY, someHour); + input.set(Calendar.MINUTE, someMinute); + input.set(Calendar.SECOND, someSecond); + input.set(Calendar.MILLISECOND, someMillisecond); + + final LocalTime result = new LocalTimeCalendarConverter().fromDomain(input).get(); + + assertThat(result).isNotNull(); + + assertThat(result.get(ChronoField.HOUR_OF_DAY)).isEqualTo(someHour); + assertThat(result.get(ChronoField.MINUTE_OF_HOUR)).isEqualTo(someMinute); + assertThat(result.get(ChronoField.SECOND_OF_MINUTE)).isEqualTo(someSecond); + assertThat(result.get(ChronoField.MILLI_OF_SECOND)).isEqualTo(someMillisecond); + } + + @Test + void testToDomain() + { + final int someHour = 15; + final int someMinute = 57; + final int someSecond = 42; + final int someMillisecond = 852; + final int millisAsNanoseconds = someMillisecond * 1_000_000; + + final LocalTime input = LocalTime.of(someHour, someMinute, someSecond, millisAsNanoseconds); + + final Calendar result = new LocalTimeCalendarConverter().toDomain(input).get(); + + assertThat(result).isNotNull(); + + assertThat(result.get(Calendar.HOUR_OF_DAY)).isEqualTo(someHour); + assertThat(result.get(Calendar.MINUTE)).isEqualTo(someMinute); + assertThat(result.get(Calendar.SECOND)).isEqualTo(someSecond); + assertThat(result.get(Calendar.MILLISECOND)).isEqualTo(someMillisecond); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/adapter/ZonedDateTimeAdapterTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/adapter/ZonedDateTimeAdapterTest.java new file mode 100644 index 0000000000..4572fd5764 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/adapter/ZonedDateTimeAdapterTest.java @@ -0,0 +1,32 @@ +package com.sap.cloud.sdk.datamodel.odata.adapter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.StringReader; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +import org.junit.jupiter.api.Test; + +import com.google.gson.Strictness; +import com.google.gson.stream.JsonReader; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ZonedDateTimeAdapter; + +class ZonedDateTimeAdapterTest +{ + + @Test + void testDeserialization() + throws IOException + { + final String jsonInput = "\"/Date(1525730400000-0120)/\""; + + final ZonedDateTimeAdapter sut = new ZonedDateTimeAdapter(); + final JsonReader reader = new JsonReader(new StringReader(jsonInput)); + reader.setStrictness(Strictness.LENIENT); + final ZonedDateTime result = sut.read(reader); + + assertThat(result).isEqualTo(ZonedDateTime.of(2018, 5, 8, 2, 0, 0, 0, ZoneId.of("UTC+2"))); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/adapter/ZonedDateTimeCalendarConverterTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/adapter/ZonedDateTimeCalendarConverterTest.java new file mode 100644 index 0000000000..a8a7b118e6 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/adapter/ZonedDateTimeCalendarConverterTest.java @@ -0,0 +1,84 @@ +package com.sap.cloud.sdk.datamodel.odata.adapter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Month; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoField; +import java.util.Calendar; +import java.util.TimeZone; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ZonedDateTimeCalendarConverter; + +class ZonedDateTimeCalendarConverterTest +{ + @Test + void testFromDomain() + { + final int someYear = 2017; + final int someMonth = Calendar.MAY; + final int someDay = 24; + final int someHour = 15; + final int someMinute = 57; + final int someSecond = 42; + final int someMillisecond = 852; + final TimeZone someTimeZone = TimeZone.getTimeZone("America/Los_Angeles"); + + final Calendar input = Calendar.getInstance(someTimeZone); + input.clear(); + input.set(Calendar.YEAR, someYear); + input.set(Calendar.MONTH, someMonth); + input.set(Calendar.DAY_OF_MONTH, someDay); + input.set(Calendar.HOUR_OF_DAY, someHour); + input.set(Calendar.MINUTE, someMinute); + input.set(Calendar.SECOND, someSecond); + input.set(Calendar.MILLISECOND, someMillisecond); + + final ZonedDateTime result = new ZonedDateTimeCalendarConverter().fromDomain(input).get(); + + assertThat(result).isNotNull(); + + assertThat(result.getZone()).isEqualTo(someTimeZone.toZoneId()); + assertThat(result.get(ChronoField.YEAR)).isEqualTo(someYear); + assertThat(result.get(ChronoField.MONTH_OF_YEAR)).isEqualTo(Month.MAY.getValue()); + assertThat(result.get(ChronoField.DAY_OF_MONTH)).isEqualTo(someDay); + assertThat(result.get(ChronoField.HOUR_OF_DAY)).isEqualTo(someHour); + assertThat(result.get(ChronoField.MINUTE_OF_HOUR)).isEqualTo(someMinute); + assertThat(result.get(ChronoField.SECOND_OF_MINUTE)).isEqualTo(someSecond); + assertThat(result.get(ChronoField.MILLI_OF_SECOND)).isEqualTo(someMillisecond); + } + + @Test + void testToDomain() + { + final int someYear = 2017; + final int someMonth = Month.MAY.getValue(); + final int someDay = 24; + final int someHour = 15; + final int someMinute = 57; + final int someSecond = 42; + final int someMillisecond = 852; + final int millisAsNanoseconds = someMillisecond * 1_000_000; + final ZoneId someTimeZone = ZoneId.of("America/Los_Angeles"); + + final ZonedDateTime input = + ZonedDateTime + .of(someYear, someMonth, someDay, someHour, someMinute, someSecond, millisAsNanoseconds, someTimeZone); + + final Calendar result = new ZonedDateTimeCalendarConverter().toDomain(input).get(); + + assertThat(result).isNotNull(); + + assertThat(result.getTimeZone()).isEqualTo(TimeZone.getTimeZone(someTimeZone)); + assertThat(result.get(Calendar.YEAR)).isEqualTo(someYear); + assertThat(result.get(Calendar.MONTH)).isEqualTo(Calendar.MAY); + assertThat(result.get(Calendar.DAY_OF_MONTH)).isEqualTo(someDay); + assertThat(result.get(Calendar.HOUR_OF_DAY)).isEqualTo(someHour); + assertThat(result.get(Calendar.MINUTE)).isEqualTo(someMinute); + assertThat(result.get(Calendar.SECOND)).isEqualTo(someSecond); + assertThat(result.get(Calendar.MILLISECOND)).isEqualTo(someMillisecond); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/CreateViaNavigationPropertyTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/CreateViaNavigationPropertyTest.java new file mode 100644 index 0000000000..a1ffb17005 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/CreateViaNavigationPropertyTest.java @@ -0,0 +1,236 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.created; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +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.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +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 org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.apache.hc.core5.http.HttpHeaders; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataServiceErrorDetails; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataServiceErrorException; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataField; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataVdmEntityAdapterFactory; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.With; + +@WireMockTest +class CreateViaNavigationPropertyTest +{ + private static final String ODATA_SERVICE_PATH = "/service/path"; + private static final String ODATA_ENTITY = "A_TestEntity"; + private static final String ODATA_ENTITY_RELATED = "A_RelatedEntity"; + private static final String KEY = "('2')"; + private static final String RELATIONS_NAV_PROP = "to_Relations"; + + private static final String JSON_MOCK_REQUEST = "{\"Description\" :\"DE\"}"; + private static final String JSON_CREATED_RESPONSE = """ + { + "d": { + "__metadata": { + "id": "https://127.0.0.1/service/path/A_TestEntity(Name='2',Relation='652138')", + "uri": "https://127.0.0.1/service/path/A_TestEntity(Name='2',Relation='652138')", + "type": "TEST_SERVICE.A_RelatedEntityType" + } + } + } + """; + private static final String JSON_NOT_IMPLEMENTED_RESPONSE = """ + { + "error": { + "code": "AB/100", + "message": { + "lang": "en", + "value": "Invalid method invocation: 'CREATE' method is called on the non-root entity 'A_RelatedEntity'" + }, + "innererror": { + "application": { + "component_id": "AB-CDE-FGH-IJ", + "service_namespace": "/SAP/", + "service_id": "TEST_SERVICE", + "service_version": "0001" + }, + "transactionid": "10000", + "timestamp": "20180815132618.3364870", + "errordetails": [ + { + "code": "INNER-CODE-1", + "message": "INNER-MESSAGE-1", + "propertyref": "", + "severity": "error", + "target": "" + } + ] + } + } + } + """; + + private DefaultHttpDestination destination; + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + stubFor(head(urlEqualTo(ODATA_SERVICE_PATH + "/")).willReturn(ok())); + } + + @Test + void happyPathWorksAsExpected() + { + stubFor( + post(urlEqualTo(ODATA_SERVICE_PATH + "/" + ODATA_ENTITY + KEY + "/" + RELATIONS_NAV_PROP)) + .withRequestBody(equalToJson(JSON_MOCK_REQUEST)) + .willReturn(created().withBody(JSON_CREATED_RESPONSE))); + + FluentHelperFactory + .withServicePath(ODATA_SERVICE_PATH) + .create(ODATA_ENTITY, new RelatedEntity("DE")) + .asChildOf(new TestEntity().withName("2"), TestEntity.TO_RELATIONS) + .executeRequest(destination); + + verify(1, headRequestedFor(urlEqualTo(ODATA_SERVICE_PATH + "/"))); + verify( + 1, + postRequestedFor(urlEqualTo(ODATA_SERVICE_PATH + "/" + ODATA_ENTITY + KEY + "/" + RELATIONS_NAV_PROP))); + } + + @Test + void nullParametersResultInDefaultBehavior() + { + stubFor( + post(urlEqualTo(ODATA_SERVICE_PATH + "/" + ODATA_ENTITY_RELATED)) + .withRequestBody(equalToJson(JSON_MOCK_REQUEST)) + .willReturn(created().withBody(JSON_CREATED_RESPONSE))); + + FluentHelperFactory + .withServicePath(ODATA_SERVICE_PATH) + .create(ODATA_ENTITY_RELATED, new RelatedEntity("DE")) + .asChildOf(null, null) + .executeRequest(destination); + + verify(1, headRequestedFor(urlEqualTo(ODATA_SERVICE_PATH + "/"))); + verify(1, postRequestedFor(urlEqualTo(ODATA_SERVICE_PATH + "/" + ODATA_ENTITY_RELATED))); + } + + @Test + void directCreationFails() + { + stubFor( + post(urlEqualTo(ODATA_SERVICE_PATH + "/" + ODATA_ENTITY_RELATED)) + .withRequestBody(equalToJson(JSON_MOCK_REQUEST)) + .willReturn( + aResponse() + .withStatus(501) + .withHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .withBody(JSON_NOT_IMPLEMENTED_RESPONSE))); + + assertThatCode( + () -> FluentHelperFactory + .withServicePath(ODATA_SERVICE_PATH) + .create(ODATA_ENTITY_RELATED, new RelatedEntity("DE")) + .executeRequest(destination)) + .hasMessage( + "The HTTP response code (501) indicates an error. The OData service responded with an error message.") + .asInstanceOf(InstanceOfAssertFactories.type(ODataServiceErrorException.class)) + .matches(e -> e.getHttpCode() == 501) + .extracting(ODataServiceErrorException::getOdataError) + .satisfies(e -> { + assertThat(e.getODataCode()).isEqualTo("AB/100"); + assertThat(e.getODataMessage()).startsWith("Invalid method invocation: 'CREATE'"); + + final List details = e.getDetails(); + assertThat(details).isNotNull().hasSize(1); + assertThat(details.get(0).getODataCode()).isEqualTo("INNER-CODE-1"); + assertThat(details.get(0).getODataMessage()).isEqualTo("INNER-MESSAGE-1"); + }); + + verify(1, headRequestedFor(urlEqualTo(ODATA_SERVICE_PATH + "/"))); + verify(1, postRequestedFor(urlEqualTo(ODATA_SERVICE_PATH + "/" + ODATA_ENTITY_RELATED))); + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + @EqualsAndHashCode( callSuper = true ) + @JsonAdapter( ODataVdmEntityAdapterFactory.class ) + public static class TestEntity extends VdmEntity + { + private final String entityCollection = ODATA_ENTITY; + private final Class type = TestEntity.class; + + @SerializedName( "Name" ) + @JsonProperty( "Name" ) + @ODataField( odataName = "Name" ) + @With + private String name; + + @SerializedName( RELATIONS_NAV_PROP ) + @JsonProperty( RELATIONS_NAV_PROP ) + @ODataField( odataName = RELATIONS_NAV_PROP ) + private List relations; + + public static final TestEntityLink TO_RELATIONS = new TestEntityLink<>(RELATIONS_NAV_PROP); + + @Nonnull + @Override + protected Map getKey() + { + return Collections.singletonMap("Name", getName()); + } + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + @EqualsAndHashCode( callSuper = true ) + @JsonAdapter( ODataVdmEntityAdapterFactory.class ) + public static class RelatedEntity extends VdmEntity + { + private final String entityCollection = ODATA_ENTITY_RELATED; + private final Class type = RelatedEntity.class; + + @SerializedName( "Description" ) + @JsonProperty( "Description" ) + @ODataField( odataName = "Description" ) + private String description; + } + + public static class TestEntityLink> + extends + EntityLink, TestEntity, ObjectT> + { + public TestEntityLink( final String name ) + { + super(name); + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/CustomFieldTypeConverterTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/CustomFieldTypeConverterTest.java new file mode 100644 index 0000000000..1291dac152 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/CustomFieldTypeConverterTest.java @@ -0,0 +1,256 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +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.urlEqualTo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDateTime; +import java.time.Month; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.TimeZone; + +import javax.annotation.Nonnull; + +import org.assertj.core.api.Assertions; +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.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.LocalDateTimeCalendarConverter; +import com.sap.cloud.sdk.typeconverter.AbstractTypeConverter; +import com.sap.cloud.sdk.typeconverter.ConvertedObject; +import com.sap.cloud.sdk.typeconverter.exception.ObjectNotConvertibleException; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@WireMockTest +class CustomFieldTypeConverterTest +{ + private static final String ODATA_ENDPOINT_URL = "/service/path"; + private static final String ENTITY_SET = "A_TestEntity"; + private static final String MY_CUSTOM_FIELD = "MyCustomField"; + private static final String RESPONSE = """ + { + "d": { + "results": [ + { + "__metadata": { + "id": "https://127.0.0.1/service/path/A_TestEntity('1')", + "uri": "https://127.0.0.1/service/path/A_TestEntity('1')", + "type": "API_TEST.A_TestEntityType" + }, + "Id": "1", + "SomeField": "123", + "ETag": "SOME_ETAG", + "MyCustomField": %1$s + } + ] + } + } + """; + + private DefaultHttpDestination destination; + + @BeforeEach + void before( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + } + + @Data + @EqualsAndHashCode( callSuper = true ) + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataVdmEntityAdapterFactory.class ) + public static class TestEntity extends VdmEntity + { + private final String entityCollection = ENTITY_SET; + private final Class type = TestEntity.class; + } + + @Getter + private static class LocalDateTimeBooleanConverter extends AbstractTypeConverter + { + private final Class type = LocalDateTime.class; + private final Class domainType = Boolean.class; + + @Nonnull + @Override + public ConvertedObject toDomainNonNull( @Nonnull final LocalDateTime object ) + throws Exception + { + throw new Exception("This implementation should fail."); + } + + @Nonnull + @Override + public ConvertedObject fromDomainNonNull( @Nonnull final Boolean domainObject ) + throws Exception + { + throw new Exception("This implementation should fail."); + } + } + + @Test + void calendarToLocalDateTimeOnGet() + { + mockResponses("\"/Date(1507075200000)/\""); + + final EntityField myCustomField = + new EntityField<>(MY_CUSTOM_FIELD, new LocalDateTimeCalendarConverter()); + + final TestEntity testEntity = + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .read(TestEntity.class, ENTITY_SET) + .executeRequest(destination) + .get(0); + + final LocalDateTime customField = testEntity.getCustomField(myCustomField); + + assertThat(customField).isInstanceOf(LocalDateTime.class); + assertThat(customField).isEqualTo(LocalDateTime.of(2017, Month.OCTOBER, 4, 0, 0, 0)); + } + + @Test + void returnsNullOnNullValue() + { + mockResponses("null"); + final EntityField myCustomField = + new EntityField<>(MY_CUSTOM_FIELD, new LocalDateTimeCalendarConverter()); + + final TestEntity testEntity = + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .read(TestEntity.class, ENTITY_SET) + .executeRequest(destination) + .get(0); + + final LocalDateTime customField = testEntity.getCustomField(myCustomField); + assertThat(customField).isNull(); + } + + @Test + void failsOnConversionErrorOnGet() + { + mockResponses("\"/Date(1507075200000)/\""); + final EntityField myCustomField = + new EntityField<>(MY_CUSTOM_FIELD, new LocalDateTimeBooleanConverter()); + + final TestEntity testEntity = + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .read(TestEntity.class, ENTITY_SET) + .executeRequest(destination) + .get(0); + + assertThatThrownBy(() -> testEntity.getCustomField(myCustomField)) + .isExactlyInstanceOf(ObjectNotConvertibleException.class); + } + + // this test verifies backwards compatibility of the API changes + @Test + void entityFieldWithoutConverterStillWorksForGet() + { + mockResponses("\"/Date(1507075200000)/\""); + + final EntityField myCustomField = new EntityField<>(MY_CUSTOM_FIELD); + + final TestEntity TestEntity = + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .read(TestEntity.class, ENTITY_SET) + .executeRequest(destination) + .get(0); + + final GregorianCalendar customField = TestEntity.getCustomField(myCustomField); + assertThat(customField).isNotNull(); + } + + @Test + void localDateTimeToCalendarOnSet() + { + final EntityField myCustomField = + new EntityField<>(MY_CUSTOM_FIELD, new LocalDateTimeCalendarConverter()); + + final TestEntity TestEntity = new TestEntity(); + TestEntity.setCustomField(myCustomField, LocalDateTime.of(2017, Month.OCTOBER, 4, 0, 0, 0)); + + final Object myCustomFieldValue = TestEntity.getCustomField(MY_CUSTOM_FIELD); + assertThat(myCustomFieldValue).isInstanceOf(GregorianCalendar.class); + final Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + calendar.clear(); + calendar.set(2017, Calendar.OCTOBER, 4, 0, 0, 0); + assertThat(myCustomFieldValue).isEqualTo(calendar); + } + + @Test + void setsNullOnNullValue() + { + final EntityField myCustomField = + new EntityField<>(MY_CUSTOM_FIELD, new LocalDateTimeCalendarConverter()); + + final TestEntity testEntity = new TestEntity(); + testEntity.setCustomField(myCustomField, null); + + final Object myCustomFieldValue = testEntity.getCustomField(MY_CUSTOM_FIELD); + assertThat(myCustomFieldValue).isNull(); + final LocalDateTime customField = testEntity.getCustomField(myCustomField); + assertThat(customField).isNull(); + } + + @Test + void failsOnConversionErrorOnSet() + { + final EntityField myCustomField = + new EntityField<>(MY_CUSTOM_FIELD, new LocalDateTimeBooleanConverter()); + + final TestEntity testEntity = new TestEntity(); + assertThatThrownBy( + () -> testEntity.setCustomField(myCustomField, LocalDateTime.of(2017, Month.OCTOBER, 4, 0, 0, 0))) + .isExactlyInstanceOf(ObjectNotConvertibleException.class); + } + + // this test verifies backwards compatibility of the API changes + @Test + void entityFieldWithoutConverterStillWorksForSet() + { + final EntityField myCustomField = new EntityField<>(MY_CUSTOM_FIELD); + + final Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + calendar.clear(); + calendar.set(2017, Calendar.OCTOBER, 4, 0, 0, 0); + + final TestEntity testEntity = new TestEntity(); + testEntity.setCustomField(myCustomField, calendar); + } + + @Test + void entityFieldHasNoTypeConverterWhenRetrievedWithoutTypeConverter() + { + final EntityField myCustomField = new EntityField<>(MY_CUSTOM_FIELD); + Assertions.assertThat(myCustomField.getTypeConverter()).isNull(); + } + + @Test + void entityFieldHasTypeConverterWhenRetrievedWithTypeConverter() + { + final EntityField myCustomField = + new EntityField<>(MY_CUSTOM_FIELD, new LocalDateTimeCalendarConverter()); + Assertions.assertThat(myCustomField.getTypeConverter()).isNotNull(); + Assertions.assertThat(myCustomField.getFieldName()).isEqualTo(MY_CUSTOM_FIELD); + } + + private void mockResponses( final String customFieldValue ) + { + final String mockedUrl = ODATA_ENDPOINT_URL + "/" + ENTITY_SET; + stubFor(get(urlEqualTo(mockedUrl)).willReturn(okJson(String.format(RESPONSE, customFieldValue)))); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperEtagParsingTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperEtagParsingTest.java new file mode 100644 index 0000000000..b9a5b0f986 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/FluentHelperEtagParsingTest.java @@ -0,0 +1,368 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +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.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5CacheBuilder; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestinationProperties; + +import lombok.Getter; +import lombok.SneakyThrows; + +class FluentHelperEtagParsingTest +{ + private static final String SERVICE_PATH = "/service"; + private static final String ENTITY_COLLECTION = "/EntityCollection"; + private static final HttpDestinationProperties DESTINATION = DefaultHttpDestination.builder("http://1").build(); + private static final String FUNCTION_NAME = "FUNCTION_NAME"; + private static final String ETAG_HEAD = "foo"; + private static final String ETAG_BODY = "bar"; + private static final FluentHelperFactory FACTORY = FluentHelperFactory.withServicePath(SERVICE_PATH); + private static final Map HEADERS_NONE = Collections.emptyMap(); + private static final Map HEADERS_WITH_ETAG = Collections.singletonMap("Etag", ETAG_HEAD); + + private HttpClient httpClient; + + @BeforeEach + void setupConnectivity() + { + httpClient = mock(HttpClient.class); + ApacheHttpClient5Accessor + .setHttpClientCache(new ApacheHttpClient5CacheBuilder().durationInMilliseconds(0).build()); + ApacheHttpClient5Accessor.setHttpClientFactory(dest -> { + assertThat(dest).isSameAs(DESTINATION); + return httpClient; + }); + } + + @AfterEach + void teardownConnectivity() + { + ApacheHttpClient5Accessor + .setHttpClientCache(new ApacheHttpClient5CacheBuilder().duration(Duration.ofMinutes(5)).build()); + ApacheHttpClient5Accessor.setHttpClientFactory(null); + } + + /** + * ETag via HTTP header. + */ + @SneakyThrows + @Test + void testParseNoEtagGetByKey() + { + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response("{d:{}}", HEADERS_NONE)); + + final TestEntity entity = + FACTORY + .readByKey(TestEntity.class, ENTITY_COLLECTION, Collections.singletonMap("key", "val")) + .executeRequest(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).isEmpty(); + } + + @SneakyThrows + @Test + void testParseNoEtagFunctionImport() + { + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response(String.format("{d:{%s:{}}}", FUNCTION_NAME), HEADERS_NONE)); + + // GET + TestEntity entity = + FACTORY + .functionSingleGet(Collections.singletonMap("key", "val"), FUNCTION_NAME, TestEntity.class) + .executeRequest(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).isEmpty(); + + // POST + entity = + FACTORY + .functionSinglePost(Collections.singletonMap("key", "val"), FUNCTION_NAME, TestEntity.class) + .executeRequest(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).isEmpty(); + } + + @SneakyThrows + @Test + void testParseNoEtagUpdate() + { + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response("{d:{}}", HEADERS_NONE)); + + final TestEntity requestEntity = new TestEntity(); + + final ModificationResponse result = + FACTORY.update(ENTITY_COLLECTION, requestEntity).executeRequest(DESTINATION); + + assertThat(result).isNotNull(); + assertThat(result.getModifiedEntity()).isNotNull(); + assertThat(result.getResponseEntity()).isNotNull().isNotEmpty(); + assertThat(result.getRequestEntity()).isNotNull().isSameAs(requestEntity); + + assertThat(result.getModifiedEntity().getVersionIdentifier()).isEmpty(); + assertThat(result.getResponseEntity().get().getVersionIdentifier()).isEmpty(); + assertThat(result.getRequestEntity().getVersionIdentifier()).isEmpty(); + } + + @SneakyThrows + @Test + void testParseHeaderEtagGetByKey() + { + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response("{d:{}}", HEADERS_WITH_ETAG)); + + final TestEntity entity = + FACTORY + .readByKey(TestEntity.class, ENTITY_COLLECTION, Collections.singletonMap("key", "val")) + .executeRequest(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_HEAD); + } + + @SneakyThrows + @Test + void testParseHeaderEtagFunctionImport() + { + when(httpClient.executeOpen(isNull(), any(), isNull())) + .thenReturn(response(String.format("{d:{%s:{}}}", FUNCTION_NAME), HEADERS_WITH_ETAG)); + + // GET + TestEntity entity = + FACTORY + .functionSingleGet(Collections.singletonMap("key", "val"), FUNCTION_NAME, TestEntity.class) + .executeRequest(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_HEAD); + + // POST + entity = + FACTORY + .functionSinglePost(Collections.singletonMap("key", "val"), FUNCTION_NAME, TestEntity.class) + .executeRequest(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_HEAD); + } + + @SneakyThrows + @Test + void testParseHeaderEtagUpdate() + { + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response("{d:{}}", HEADERS_WITH_ETAG)); + + final TestEntity requestEntity = new TestEntity(); + + final ModificationResponse result = + FACTORY.update(ENTITY_COLLECTION, requestEntity).executeRequest(DESTINATION); + + assertThat(result).isNotNull(); + assertThat(result.getModifiedEntity()).isNotNull(); + assertThat(result.getResponseEntity()).isNotNull().isNotEmpty(); + assertThat(result.getRequestEntity()).isNotNull().isSameAs(requestEntity); + + assertThat(result.getModifiedEntity().getVersionIdentifier()).containsExactly(ETAG_HEAD); + assertThat(result.getResponseEntity().get().getVersionIdentifier()).containsExactly(ETAG_HEAD); + assertThat(result.getRequestEntity().getVersionIdentifier()).isEmpty(); + } + + /** + * ETag via HTTP payload. + */ + @SneakyThrows + @Test + void testParsePayloadEtagGetAll() + { + final String payload = String.format("{d:{results:[{__metadata:{etag:\"%s\"}}]}}", ETAG_BODY); + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response(payload, HEADERS_NONE)); + + final List entities = FACTORY.read(TestEntity.class, ENTITY_COLLECTION).executeRequest(DESTINATION); + + assertThat(entities).isNotNull().hasSize(1); + assertThat(entities.get(0).getVersionIdentifier()).containsExactly(ETAG_BODY); + } + + @SneakyThrows + @Test + void testParsePayloadEtagGetByKey() + { + when(httpClient.executeOpen(isNull(), any(), isNull())) + .thenReturn(response(String.format("{d:{__metadata:{etag:\"%s\"}}}", ETAG_BODY), HEADERS_NONE)); + + final TestEntity entity = + FACTORY + .readByKey(TestEntity.class, ENTITY_COLLECTION, Collections.singletonMap("key", "val")) + .executeRequest(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_BODY); + } + + @SneakyThrows + @Test + void testParsePayloadEtagFunctionImport() + { + when(httpClient.executeOpen(isNull(), any(), isNull())) + .thenReturn( + response(String.format("{d:{%s:{__metadata:{etag:\"%s\"}}}}", FUNCTION_NAME, ETAG_BODY), HEADERS_NONE)); + + // GET + TestEntity entity = + FACTORY + .functionSingleGet(Collections.singletonMap("key", "val"), FUNCTION_NAME, TestEntity.class) + .executeRequest(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_BODY); + + // POST + entity = + FACTORY + .functionSinglePost(Collections.singletonMap("key", "val"), FUNCTION_NAME, TestEntity.class) + .executeRequest(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_BODY); + } + + @SneakyThrows + @Test + void testParsePayloadEtagUpdate() + { + when(httpClient.executeOpen(isNull(), any(), isNull())) + .thenReturn(response(String.format("{d:{__metadata:{etag:\"%s\"}}}", ETAG_BODY), HEADERS_NONE)); + + final TestEntity requestEntity = new TestEntity(); + + final ModificationResponse result = + FACTORY.update(ENTITY_COLLECTION, requestEntity).executeRequest(DESTINATION); + + assertThat(result).isNotNull(); + assertThat(result.getModifiedEntity()).isNotNull(); + assertThat(result.getResponseEntity()).isNotNull().isNotEmpty(); + assertThat(result.getRequestEntity()).isNotNull().isSameAs(requestEntity); + + assertThat(result.getModifiedEntity().getVersionIdentifier()).containsExactly(ETAG_BODY); + assertThat(result.getResponseEntity().get().getVersionIdentifier()).containsExactly(ETAG_BODY); + assertThat(result.getRequestEntity().getVersionIdentifier()).isEmpty(); + } + + /** + * ETag via HTTP header + HTTP payload. + */ + @SneakyThrows + @Test + void testParseHeaderAndPayloadEtagGetByKey() + { + when(httpClient.executeOpen(isNull(), any(), isNull())) + .thenReturn(response(String.format("{d:{__metadata:{etag:\"%s\"}}}", ETAG_BODY), HEADERS_WITH_ETAG)); + + final TestEntity entity = + FACTORY + .readByKey(TestEntity.class, ENTITY_COLLECTION, Collections.singletonMap("key", "val")) + .executeRequest(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_HEAD); + } + + @SneakyThrows + @Test + void testParseHeaderAndPayloadEtagFunctionImport() + { + when(httpClient.executeOpen(isNull(), any(), isNull())) + .thenReturn( + response( + String.format("{d:{%s:{__metadata:{etag:\"%s\"}}}}", FUNCTION_NAME, ETAG_BODY), + HEADERS_WITH_ETAG)); + + // GET + TestEntity entity = + FACTORY + .functionSingleGet(Collections.singletonMap("key", "val"), FUNCTION_NAME, TestEntity.class) + .executeRequest(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_HEAD); + + // POST + entity = + FACTORY + .functionSinglePost(Collections.singletonMap("key", "val"), FUNCTION_NAME, TestEntity.class) + .executeRequest(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_HEAD); + } + + @SneakyThrows + @Test + void testParseHeaderAndPayloadEtagUpdate() + { + when(httpClient.executeOpen(isNull(), any(), isNull())) + .thenReturn(response(String.format("{d:{__metadata:{etag:\"%s\"}}}", ETAG_BODY), HEADERS_WITH_ETAG)); + + final TestEntity requestEntity = new TestEntity(); + + final ModificationResponse result = + FACTORY.update(ENTITY_COLLECTION, requestEntity).executeRequest(DESTINATION); + + assertThat(result).isNotNull(); + assertThat(result.getModifiedEntity()).isNotNull(); + assertThat(result.getResponseEntity()).isNotNull().isNotEmpty(); + assertThat(result.getRequestEntity()).isNotNull().isSameAs(requestEntity); + + assertThat(result.getModifiedEntity().getVersionIdentifier()).containsExactly(ETAG_HEAD); + assertThat(result.getResponseEntity().get().getVersionIdentifier()).containsExactly(ETAG_HEAD); + assertThat(result.getRequestEntity().getVersionIdentifier()).isEmpty(); + } + + /** + * HELPER METHODS. + */ + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataVdmEntityAdapterFactory.class ) + public static class TestEntity extends VdmEntity + { + @Getter + private final String entityCollection = ENTITY_COLLECTION; + @Getter + private final Class type = TestEntity.class; + } + + @Nonnull + static ClassicHttpResponse response( @Nonnull final String payload, @Nonnull final Map headers ) + { + final BasicClassicHttpResponse result = new BasicClassicHttpResponse(200, "Ok"); + result.setEntity(new StringEntity(payload, StandardCharsets.UTF_8)); + result.setHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString()); + headers.forEach(result::setHeader); + return result; + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/FunctionImportSingleDeserializationTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/FunctionImportSingleDeserializationTest.java new file mode 100644 index 0000000000..3c088d4fa3 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/FunctionImportSingleDeserializationTest.java @@ -0,0 +1,153 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static com.github.tomakehurst.wiremock.client.WireMock.get; +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.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import java.util.stream.Stream; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHeaders; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataField; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataVdmEntityAdapterFactory; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@WireMockTest +class FunctionImportSingleDeserializationTest +{ + private static final String SERVICE_PATH = "/some/path/service"; + private static final String FUNCTION_NAME = "SomeFunction"; + + @ParameterizedTest + @MethodSource + void testDeserializeSingleResponse( @Nonnull final TestInput testInput, @Nonnull final WireMockRuntimeInfo wm ) + { + stub(testInput); + + final DefaultHttpDestination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + final Object actualReturnValue = testInput.functionSingleGet().executeSingle(destination); + + if( testInput.expectedReturnValue == null ) { + assertThat(actualReturnValue).isNull(); + } else { + assertThat(actualReturnValue).isEqualTo(testInput.expectedReturnValue); + } + } + + @Nonnull + static Stream> testDeserializeSingleResponse() + { + return Stream + .of( + new TestInput<>( + String.class, + """ + + + + + + """, + "{\"d\":{\"" + + FUNCTION_NAME + + "\":\"" + + "\\n" + + "\\n" + + "\\n" + + "\\n" + + "\\n\"}}"), + new TestInput<>(Boolean.class, true, "{\"d\":{\"" + FUNCTION_NAME + "\":true}}"), + new TestInput<>(Long.class, 42L, "{\"d\":{\"" + FUNCTION_NAME + "\":\"42\"}}"), + new TestInput<>(Void.class, null, null), + new TestInput<>( + TestingEntity.class, + new TestingEntity("id", "text"), + "{\"d\":{\"" + FUNCTION_NAME + "\":{\"id\":\"id\",\"text\":\"text\"}}}")); + } + + private void stub( @Nonnull final TestInput testInput ) + { + stubFor(head(urlPathEqualTo(SERVICE_PATH)).willReturn(ok().withHeader("x-csrf-token", "some-csrf-token"))); + + final ResponseDefinitionBuilder responseBuilder; + if( testInput.serializedReturnValue == null ) { + responseBuilder = noContent(); + } else { + responseBuilder = + ok() + .withBody(testInput.serializedReturnValue) + .withHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.getMimeType()); + } + + stubFor(get(urlPathEqualTo(SERVICE_PATH + "/" + FUNCTION_NAME)).willReturn(responseBuilder)); + } + + @AllArgsConstructor + @Getter + private static class TestInput + { + @Nonnull + private Class returnValueType; + @Nullable + private T expectedReturnValue; + @Nullable + private String serializedReturnValue; + + @Nonnull + public FluentHelperFunction functionSingleGet() + { + return FluentHelperFactory + .withServicePath(SERVICE_PATH) + .functionSingleGet(Collections.emptyMap(), FUNCTION_NAME, returnValueType); + } + + @Override + public String toString() + { + return "Single Function import with " + returnValueType.getSimpleName() + " return type"; + } + } + + @EqualsAndHashCode( callSuper = true ) + @NoArgsConstructor + @AllArgsConstructor + @JsonAdapter( ODataVdmEntityAdapterFactory.class ) + public static class TestingEntity extends VdmEntity + { + @Getter( AccessLevel.PROTECTED ) + private final String entityCollection = "TestingCollection"; + + @Getter + private final Class type = TestingEntity.class; + + @SerializedName( "id" ) + @ODataField( odataName = "id" ) + private String id; + + @SerializedName( "text" ) + @ODataField( odataName = "text" ) + private String text; + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/FunctionImportSingleEntityTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/FunctionImportSingleEntityTest.java new file mode 100644 index 0000000000..0d7b721f68 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/FunctionImportSingleEntityTest.java @@ -0,0 +1,260 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder.okForJson; +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.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.head; +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.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import java.net.URI; +import java.util.Map; +import java.util.UUID; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpUriRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.client.MappingBuilder; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; +import com.google.gson.JsonElement; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestinationProperties; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataField; + +import io.vavr.control.Option; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@WireMockTest +class FunctionImportSingleEntityTest +{ + private static final String TEST_SERVICE_PATH = "/some/service/path"; + private static final String TEST_FUNCTION_NAME = "Testing"; + private static final String CSRF = "secret"; + private static final TestingEntity TEST_ENTITY = new TestingEntity("hello", "world"); + + private HttpDestinationProperties destination; + + @BeforeEach + void assignMockedDestination( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + final Map> functionResult = + ImmutableMap.of("d", ImmutableMap.of(TEST_FUNCTION_NAME, TEST_ENTITY)); + stubFor(get(anyUrl()).willReturn(okForJson(functionResult))); + stubFor(head(anyUrl()).willReturn(ok().withHeader("x-csrf-token", CSRF))); + } + + @EqualsAndHashCode( callSuper = true ) + @NoArgsConstructor + @AllArgsConstructor + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataVdmEntityAdapterFactory.class ) + public static class TestingEntity extends VdmEntity + { + @Getter( AccessLevel.PROTECTED ) + private final String entityCollection = "TestingCollection"; + + @Getter + private final Class type = TestingEntity.class; + + @SerializedName( "id" ) + @ODataField( odataName = "id" ) + private String id; + + @SerializedName( "text" ) + @ODataField( odataName = "text" ) + private String text; + } + + private static class SingleEntityFunctionImport + extends + FluentHelperFunction + { + @Getter + private final Map parameters = Maps.newHashMap(); + + @Getter + private final String functionName = TEST_FUNCTION_NAME; + + @Getter + private final Class entityClass = TestingEntity.class; + + public SingleEntityFunctionImport() + { + super(TEST_SERVICE_PATH); + } + + @Nullable + @Override + protected JsonElement refineJsonResponse( @Nullable final JsonElement jsonElement ) + { + return Option.of(jsonElement).toTry().map(o -> o.getAsJsonObject().get(getFunctionName())).getOrNull(); + } + + @Nonnull + @Override + protected HttpUriRequest createRequest( @Nonnull final URI uri ) + { + return new HttpGet(uri); + } + + @Nullable + @Override + public TestingEntity executeRequest( @Nonnull final Destination destination ) + { + return super.executeSingle(destination); + } + + private void addParameter( final String key, final Object value ) + { + parameters.put(key, value); + } + } + + @Test + void testFunctionParameterWithPlusSign() + { + final SingleEntityFunctionImport functionImport = new SingleEntityFunctionImport(); + functionImport.addParameter("foo", "ba+r"); + + final TestingEntity responseEntity = functionImport.executeRequest(destination); + + verify(getRequestedFor(urlEqualTo(TEST_SERVICE_PATH + "/Testing?foo='ba%2Br'"))); + assertThat(responseEntity).isEqualTo(TEST_ENTITY); + } + + @Test + void testFunctionParameterWithWhitespace() + { + final SingleEntityFunctionImport functionImport = new SingleEntityFunctionImport(); + functionImport.addParameter("foo", "b a+ r "); + + final TestingEntity responseEntity = functionImport.executeRequest(destination); + + verify(getRequestedFor(urlEqualTo(TEST_SERVICE_PATH + "/Testing?foo='b%20a%2B%20%20r%20'"))); + assertThat(responseEntity).isEqualTo(TEST_ENTITY); + } + + @Test + void testFunctionParameterWithQuotes() + { + final SingleEntityFunctionImport functionImport = new SingleEntityFunctionImport(); + functionImport.addParameter("GUID", UUID.fromString("571c74ab-c66a-4d34-ab32-ff16ec4653a5")); + + final TestingEntity responseEntity = functionImport.executeRequest(destination); + + verify( + getRequestedFor( + urlEqualTo(TEST_SERVICE_PATH + "/Testing?GUID=guid'571c74ab-c66a-4d34-ab32-ff16ec4653a5'"))); + assertThat(responseEntity).isEqualTo(TEST_ENTITY); + } + + @Test + void testFunctionParameterWithSpecialCharacters() + { + final SingleEntityFunctionImport functionImport = new SingleEntityFunctionImport(); + functionImport.addParameter("Address", "Unusual? Road #99 with 100%"); + + final TestingEntity responseEntity = functionImport.executeRequest(destination); + + verify( + getRequestedFor( + urlEqualTo(TEST_SERVICE_PATH + "/Testing?Address='Unusual%3F%20Road%20%2399%20with%20100%25'"))); + assertThat(responseEntity).isEqualTo(TEST_ENTITY); + } + + @Test + void testFunctionParameterWithParentheses() + { + final SingleEntityFunctionImport functionImport = new SingleEntityFunctionImport(); + functionImport.addParameter("Address", "Potsdam (Germany)"); + + final TestingEntity responseEntity = functionImport.executeRequest(destination); + + verify(getRequestedFor(urlEqualTo(TEST_SERVICE_PATH + "/Testing?Address='Potsdam%20(Germany)'"))); + assertThat(responseEntity).isEqualTo(TEST_ENTITY); + } + + @Test + void testCustomJsonResponseParsing() + { + final String jsonResponse = "{\"d\":{\"CUSTOM_OBJECT\":{\"id\":\"hello\",\"text\":\"world\"}}}"; + final MappingBuilder requestMapping = get(urlEqualTo(TEST_SERVICE_PATH + "/Testing")); + stubFor(requestMapping.willReturn(okJson(jsonResponse))); + + final SingleEntityFunctionImport originalFunctionImport = new SingleEntityFunctionImport() + { + @Nonnull + @Override + public String getFunctionName() + { + return "Testing"; + } + + @Override + protected JsonElement refineJsonResponse( @Nullable final JsonElement jsonElement ) + { + return jsonElement.getAsJsonObject().get("CUSTOM_OBJECT"); + } + }; + + final TestingEntity responseEntity = originalFunctionImport.executeRequest(destination); + assertThat(responseEntity).isEqualTo(TEST_ENTITY); + } + + @Test + void testFunctionParameterWithConventionalValue() + { + final SingleEntityFunctionImport functionImport = new SingleEntityFunctionImport(); + functionImport.addParameter("Address", "Potsdam"); + + final TestingEntity responseEntity = functionImport.executeRequest(destination); + + verify(getRequestedFor(urlEqualTo(TEST_SERVICE_PATH + "/Testing?Address='Potsdam'"))); + assertThat(responseEntity).isEqualTo(TEST_ENTITY); + } + + @Test + void testFunctionImportWithoutParameters() + { + final SingleEntityFunctionImport functionImport = new SingleEntityFunctionImport(); + + final TestingEntity responseEntity = functionImport.executeRequest(destination); + + verify(getRequestedFor(urlEqualTo(TEST_SERVICE_PATH + "/Testing"))); + assertThat(responseEntity).isEqualTo(TEST_ENTITY); + } + + @Test + void testFunctionImportReturnsNullAsResponseBody() + { + final SingleEntityFunctionImport functionImport = new SingleEntityFunctionImport(); + + stubFor(get(urlEqualTo(TEST_SERVICE_PATH + "/Testing")).willReturn(null)); + + assertThatExceptionOfType(ODataException.class) + .isThrownBy(() -> functionImport.executeRequest(destination)) + .withMessageContaining("Unable to read OData 2.0 response."); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/HttpResponseEvaluationTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/HttpResponseEvaluationTest.java new file mode 100644 index 0000000000..14a9a3fb5f --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/HttpResponseEvaluationTest.java @@ -0,0 +1,197 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import static org.apache.hc.core5.http.ContentType.APPLICATION_JSON; +import static org.apache.hc.core5.http.ContentType.TEXT_PLAIN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.Map; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpUriRequest; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.io.entity.InputStreamEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.api.AfterEach; +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 lombok.SneakyThrows; + +public class HttpResponseEvaluationTest +{ + private static final Destination DESTINATION = DefaultHttpDestination.builder("foo").build(); + + private HttpClient httpClient; + private BasicClassicHttpResponse httpResponse; + private InputStreamEntity httpEntity; + private InputStream inputStream; + + @SneakyThrows + void mockHttpResponse( final ContentType contentType, final String payload ) + { + httpClient = mock(HttpClient.class); + inputStream = spy(new ByteArrayInputStream(payload.getBytes(UTF_8))); + httpEntity = spy(new InputStreamEntity(inputStream, contentType)); + httpResponse = spy(new BasicClassicHttpResponse(HttpStatus.SC_OK, "OK")); + httpResponse.setEntity(httpEntity); + ApacheHttpClient5Accessor.setHttpClientFactory(destination -> httpClient); + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(httpResponse); + } + + @AfterEach + void teardown() + { + ApacheHttpClient5Accessor.setHttpClientFactory(null); + ApacheHttpClient5Accessor.setHttpClientCache(null); + } + + @SneakyThrows + @Test + void testCreate() + { + mockHttpResponse(APPLICATION_JSON, "{\"d\": {}}"); + + final ModificationResponse result = + FluentHelperFactory.withServicePath("/path").create(new TestVdmEntity()).executeRequest(DESTINATION); + + verify(httpClient, times(1)).executeOpen(isNull(), any(HttpUriRequest.class), isNull()); + verify(httpResponse, times(1)).getEntity(); + verify(httpEntity, times(1)).writeTo(any(OutputStream.class)); + verify(inputStream, times(2)).close(); + + assertThat(result.getResponseStatusCode()).isEqualTo(200); + } + + @SneakyThrows + @Test + void testUpdate() + { + mockHttpResponse(APPLICATION_JSON, "{\"d\": {}}"); + + final TestVdmEntity testEntity = new TestVdmEntity(); + testEntity.setIntegerValue(42); + + final ModificationResponse result = + FluentHelperFactory.withServicePath("/path").update(testEntity).executeRequest(DESTINATION); + + verify(httpClient, times(1)).executeOpen(isNull(), any(HttpUriRequest.class), isNull()); + verify(httpResponse, times(1)).getEntity(); + verify(httpEntity, times(1)).writeTo(any(OutputStream.class)); + verify(inputStream, times(2)).close(); + + assertThat(result.getResponseStatusCode()).isEqualTo(200); + } + + @SneakyThrows + @Test + void testDelete() + { + mockHttpResponse(APPLICATION_JSON, "{\"d\": {}}"); + + final ModificationResponse result = + FluentHelperFactory.withServicePath("/path").delete(new TestVdmEntity()).executeRequest(DESTINATION); + + verify(httpClient, times(1)).executeOpen(isNull(), any(HttpUriRequest.class), isNull()); + verify(httpResponse, times(1)).getEntity(); + verify(httpEntity, times(1)).writeTo(any(OutputStream.class)); + verify(inputStream, times(2)).close(); + + assertThat(result.getResponseStatusCode()).isEqualTo(200); + } + + @SneakyThrows + @Test + void testReadAll() + { + mockHttpResponse(APPLICATION_JSON, "{\"d\": {\"results\": []}}"); + + final List result = + FluentHelperFactory + .withServicePath("/path") + .read(TestVdmEntity.class, "TestEntitySet") + .executeRequest(DESTINATION); + + assertThat(result).isNotNull(); + + verify(httpClient, times(1)).executeOpen(isNull(), any(HttpUriRequest.class), isNull()); + verify(httpResponse, times(1)).getEntity(); + verify(httpEntity, times(1)).writeTo(any(OutputStream.class)); + verify(inputStream, times(2)).close(); + } + + @SneakyThrows + @Test + void testReadByKey() + { + mockHttpResponse(APPLICATION_JSON, "{\"d\":{}}"); + + final TestVdmEntity result = + FluentHelperFactory + .withServicePath("/path") + .readByKey(TestVdmEntity.class, "TestEntitySet", Map.of("IntegerValue", 42)) + .executeRequest(DESTINATION); + + assertThat(result).isNotNull(); + + verify(httpClient, times(1)).executeOpen(isNull(), any(HttpUriRequest.class), isNull()); + verify(httpResponse, times(1)).getEntity(); + verify(httpEntity, times(1)).writeTo(any(OutputStream.class)); + verify(inputStream, times(2)).close(); + } + + @SneakyThrows + @Test + void testFunction() + { + mockHttpResponse(APPLICATION_JSON, "{\"d\": {\"results\": []}}"); + + final List result = + FluentHelperFactory + .withServicePath("/path") + .functionMultipleGet(Map.of("para", "meter"), "functionName", String.class) + .executeRequest(DESTINATION); + + assertThat(result).isNotNull(); + + verify(httpClient, times(1)).executeOpen(isNull(), any(HttpUriRequest.class), isNull()); + verify(httpResponse, times(1)).getEntity(); + verify(httpEntity, times(1)).writeTo(any(OutputStream.class)); + verify(inputStream, times(2)).close(); + } + + @SneakyThrows + @Test + void testReadCount() + { + mockHttpResponse(TEXT_PLAIN, "42"); + + final long result = + FluentHelperFactory + .withServicePath("/path") + .read(TestVdmEntity.class, "TestEntitySet") + .count() + .executeRequest(DESTINATION); + + verify(httpClient, times(1)).executeOpen(isNull(), any(HttpUriRequest.class), isNull()); + verify(httpResponse, times(1)).getEntity(); + verify(httpEntity, times(1)).writeTo(any(OutputStream.class)); + verify(inputStream, times(2)).close(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ModificationResponseTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ModificationResponseTest.java new file mode 100644 index 0000000000..c5899778ed --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ModificationResponseTest.java @@ -0,0 +1,175 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import static com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol.V4; +import static org.assertj.core.api.Assertions.assertThat; +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 static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.Header; +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.apache.hc.core5.http.message.BasicHeader; +import org.junit.jupiter.api.Test; + +import com.google.gson.annotations.JsonAdapter; +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.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestUpdate; +import com.sap.cloud.sdk.datamodel.odata.client.request.UpdateStrategy; +import com.sap.cloud.sdk.result.ElementName; + +import io.vavr.control.Option; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.SneakyThrows; +import lombok.ToString; + +class ModificationResponseTest +{ + private static final String SERVICE_PATH = "/service-path"; + + private static final Destination destination = mock(Destination.class); + + @Data + @NoArgsConstructor + @AllArgsConstructor + @ToString( doNotUseGetters = true, callSuper = true ) + @EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataVdmEntityAdapterFactory.class ) + public static class TestObject extends VdmEntity + { + @Getter + private final String odataType = "TestObject"; + + @Getter + private final Class type = TestObject.class; + + @ElementName( "foo" ) + private String name; + + @Nonnull + @Override + protected String getEntityCollection() + { + return odataType; + } + } + + @Test + void testEntityResponse() + { + final TestObject inputObject = new TestObject(); + + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + when(request.getProtocol()).thenReturn(ODataProtocol.V4); + + when(request.getServicePath()).thenReturn(SERVICE_PATH); + + final Header[] responseHeaders = { new BasicHeader("fizz", "buzz"), new BasicHeader("fizz", "fuzz, bizz=1") }; + + final ClassicHttpResponse response = mock(ClassicHttpResponse.class); + doReturn(responseHeaders).when(response).getHeaders(); + doReturn(responseHeaders).when(response).getHeaders("ETag"); + doReturn(new StringEntity("{\"foo\":\"bar\"}", UTF_8)).when(response).getEntity(); + doReturn(HttpStatus.SC_OK).when(response).getCode(); + + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, response); + final ModificationResponse modification = ModificationResponse.of(result, inputObject, destination); + + assertThat(modification).isNotNull(); + assertThat(modification.getResponseStatusCode()).isEqualTo(HttpStatus.SC_OK); + assertThat(modification.getRequestEntity()).isSameAs(inputObject); + + assertThat(modification.getResponseEntity().get()).isNotSameAs(inputObject); + assertThat(modification.getResponseEntity().get()).isEqualTo(new TestObject("bar")); + assertThat(modification.getModifiedEntity()).isEqualTo(new TestObject("bar")); + + assertThat(modification.getModifiedEntity().getDestinationForFetch()).isSameAs(destination); + assertThat(modification.getModifiedEntity().getServicePathForFetch()).isEqualTo(SERVICE_PATH); + + assertThat(modification.getResponseHeaders()).containsOnlyKeys("fizz"); + assertThat(modification.getResponseHeaders().get("fizz")).containsExactly("buzz", "fuzz, bizz=1"); + } + + @Test + void testEmptyResponse() + { + final TestObject inputObject = new TestObject(); + + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + when(request.getProtocol()).thenReturn(ODataProtocol.V4); + + when(request.getServicePath()).thenReturn(SERVICE_PATH); + + final ClassicHttpResponse response = mock(ClassicHttpResponse.class); + doReturn(new Header[0]).when(response).getHeaders(); + doReturn(new Header[0]).when(response).getHeaders("ETag"); + doReturn(new StringEntity("", UTF_8)).when(response).getEntity(); + doReturn(HttpStatus.SC_NO_CONTENT).when(response).getCode(); + + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, response); + final ModificationResponse modification = ModificationResponse.of(result, inputObject, destination); + + assertThat(modification).isNotNull(); + assertThat(modification.getResponseStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT); + assertThat(modification.getRequestEntity()).isSameAs(inputObject); + assertThat(modification.getModifiedEntity()).isNotSameAs(inputObject); + assertThat(modification.getModifiedEntity().getDestinationForFetch()).isSameAs(destination); + assertThat(modification.getModifiedEntity().getServicePathForFetch()).isEqualTo(SERVICE_PATH); + assertThat(modification.getModifiedEntity()).isEqualTo(inputObject); + assertThat(modification.getResponseHeaders()).isEmpty(); + } + + @SneakyThrows + @Test + void testResponseIsOnlyEvaluatedOnce() + { + final TestObject inputObject = new TestObject(); + + final ClassicHttpResponse response = spy(new BasicClassicHttpResponse(HttpStatus.SC_OK, "OK")); + response.setHeaders(new Header[0]); + response.setEntity(new StringEntity("{\"foo\":\"bar\"}", UTF_8)); + + final ODataEntityKey key = ODataEntityKey.of(Map.of("id", 42), V4); + final ODataRequestUpdate request = + new ODataRequestUpdate("service/path", "EntitySet", key, "{}", UpdateStrategy.REPLACE_WITH_PUT, null, V4); + + final HttpClient httpClient = mock(HttpClient.class); + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response); + + final ODataRequestResultGeneric result = request.execute(httpClient); + final ModificationResponse modification = ModificationResponse.of(result, inputObject, destination); + + modification.getResponseEntity(); + final Option responseEntity = modification.getResponseEntity(); + assertThat(responseEntity).isNotNull(); + + modification.getModifiedEntity(); + final TestObject modifiedEntity = modification.getModifiedEntity(); + assertThat(modifiedEntity).isNotNull(); + + verify(response, times(1)).getEntity(); + verify(httpClient, times(1)).executeOpen(isNull(), any(), isNull()); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataBatchRequestTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataBatchRequestTest.java new file mode 100644 index 0000000000..72a6350462 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataBatchRequestTest.java @@ -0,0 +1,436 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; +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.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.okForContentType; +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.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +import javax.annotation.Nonnull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.google.common.collect.Lists; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import com.sap.cloud.sdk.datamodel.odata.helper.batch.BatchChangeSetFluentHelperBasic; +import com.sap.cloud.sdk.datamodel.odata.helper.batch.BatchFluentHelperBasic; +import com.sap.cloud.sdk.datamodel.odata.helper.batch.BatchResponse; +import com.sap.cloud.sdk.datamodel.odata.helper.batch.BatchResponseChangeSet; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataField; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataVdmEntityAdapterFactory; + +import io.vavr.control.Try; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.With; + +@WireMockTest +class ODataBatchRequestTest +{ + private static final String ODATA_SERVICE_PATH = "/service/path"; + + private static String multipartCreatedEntity( final String key, final String description ) + { + final String url = String.format("https://127.0.0.1/service/path/A_TestEntity('%s')", key); + final String metadata = String.format("{\"id\":\"%s\",\"uri\":\"%s\",\"type\":\"A_TestEntityType\"}", url, url); + return "Content-Type: application/http\r\n" + + "Content-Length: 2811\r\n" + + "content-transfer-encoding: binary\r\n" + + "\r\n" + + "HTTP/1.1 201 Created\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: 2553\r\n" + + ("location: " + url + ")\r\n") + + "dataserviceversion: 2.0\r\n" + + "\r\n" + + "{" + + ("\"d\":{\"__metadata\":" + metadata + ",") + + ("\"Name\":\"" + key + "\",") + + ("\"GeneratedProperty\":\"" + UUID.randomUUID() + "'\",") + + ("\"Description\":\"" + description + "\"") + + "}}\r\n"; + } + + private static String multipartNoContent() + { + return """ + Content-Type: application/http + Content-Length: 71 + content-transfer-encoding: binary + + HTTP/1.1 204 No Content + Content-Length: 0 + dataserviceversion: 2.0 + """; + } + + private static final String SINGLE_CHANGESET_RESPONSE_MULTIPART = + "--FB687BFCC8917ABB09014537111216650\r\n" + + "Content-Type: multipart/mixed; boundary=FB687BFCC8917ABB09014537111216651\r\n" + + "Content-Length: 5921\r\n" // ignored + + "\r\n" + + "--FB687BFCC8917ABB09014537111216651\r\n" + + multipartCreatedEntity("501", "Same description") + + "--FB687BFCC8917ABB09014537111216651\r\n" + + multipartCreatedEntity("502", "Same description") + + "--FB687BFCC8917ABB09014537111216651--\r\n" + + "\r\n" + + "--FB687BFCC8917ABB09014537111216650--"; + + private static final String TWO_CHANGESETS_RESPONSE_MULTIPART = + "--9955BD2AAB53BE8D34D8913DDB06697B0\r\n" + + "Content-Type: multipart/mixed; boundary=9955BD2AAB53BE8D34D8913DDB06697B1\r\n" + + "Content-Length: 2980\r\n" // ignored + + "\r\n" + + "--9955BD2AAB53BE8D34D8913DDB06697B1\r\n" + + multipartCreatedEntity("503", "Alternate description") + + "--9955BD2AAB53BE8D34D8913DDB06697B1--\r\n" + + "\r\n" + + "--9955BD2AAB53BE8D34D8913DDB06697B0\r\n" + + "Content-Type: multipart/mixed; boundary=9955BD2AAB53BE8D34D8913DDB06697B1\r\n" + + "Content-Length: 2980\r\n" // ignored + + "\r\n" + + "--9955BD2AAB53BE8D34D8913DDB06697B1\r\n" + + multipartCreatedEntity("504", "Strange description") + + "--9955BD2AAB53BE8D34D8913DDB06697B1--\r\n" + + "\r\n" + + "--9955BD2AAB53BE8D34D8913DDB06697B0--"; + + private static final String SINGLE_UPDATE_DELETE_CHANGESET_RESPONSE_MULTIPART = + "--4729730C52B1E9D05AAAB969F2FEE6970\r\n" + + "Content-Type: multipart/mixed; boundary=4729730C52B1E9D05AAAB969F2FEE6971\r\n" + + "Content-Length: 437\r\n" // ignored + + "\r\n" + + "--4729730C52B1E9D05AAAB969F2FEE6971\r\n" + + multipartNoContent() + + "\r\n" + + "\r\n" + + "--4729730C52B1E9D05AAAB969F2FEE6971\r\n" + + multipartNoContent() + + "\r\n" + + "\r\n" + + "--4729730C52B1E9D05AAAB969F2FEE6971--\r\n" + + "\r\n" + + "--4729730C52B1E9D05AAAB969F2FEE6970--"; + + private HttpDestination destination; + + @BeforeEach + void before( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + stubFor(head(urlEqualTo(ODATA_SERVICE_PATH)).willReturn(ok())); + } + + @Test + void testTwoCreatesInOneChangeSet() + { + // prepare mocked data + final ResponseDefinitionBuilder response = + okForContentType( + "multipart/mixed; boundary=FB687BFCC8917ABB09014537111216650", + SINGLE_CHANGESET_RESPONSE_MULTIPART); + stubFor(post(urlEqualTo(ODATA_SERVICE_PATH + "/$batch")).willReturn(response)); + + final TestEntity address1 = new TestEntity(); + address1.setDescription("Same description"); + + final TestEntity address2 = new TestEntity(); + address2.setDescription("Same description"); + + final BatchResponse result = + new TestBatch() + .beginChangeSet() + .createTestEntity(address1) + .createTestEntity(address2) + .endChangeSet() + .executeRequest(destination); + + final Try changeSet = result.get(0); + assertThat(changeSet.isSuccess()).isTrue(); + + final List> createdEntities = changeSet.get().getCreatedEntities(); + assertResponse(createdEntities, 2, address1); + } + + @Test + void testTwoCreatesInMultipleChangeSets() + { + // prepare mocked data + final ResponseDefinitionBuilder response = + okForContentType( + "multipart/mixed; boundary=9955BD2AAB53BE8D34D8913DDB06697B0", + TWO_CHANGESETS_RESPONSE_MULTIPART); + stubFor(post(urlEqualTo(ODATA_SERVICE_PATH + "/$batch")).willReturn(response)); + + final TestEntity address1 = new TestEntity(); + address1.setDescription("Alternate description"); + + final TestEntity address2 = new TestEntity(); + address2.setDescription("Strange description"); + + final BatchResponse result = + new TestBatch() + + .beginChangeSet() + .createTestEntity(address1) + .endChangeSet() + + .beginChangeSet() + .createTestEntity(address2) + .endChangeSet() + + .executeRequest(destination); + + assertThat(result.get(0)).isNotEmpty(); + assertResponse(result.get(0).get().getCreatedEntities(), 1, address1); + + assertThat(result.get(1)).isNotEmpty(); + assertResponse(result.get(1).get().getCreatedEntities(), 1, address2); + } + + @Test + void testUpdateDeleteOperationInOneChangeSet() + { + final String concreteNameEndpoint = "A_TestEntity(%27Test%27)"; + + final TestEntity address = new TestEntity().withName("Test"); + + // prepare mocked data + final ResponseDefinitionBuilder response = + okForContentType( + "multipart/mixed; boundary=4729730C52B1E9D05AAAB969F2FEE6970", + SINGLE_UPDATE_DELETE_CHANGESET_RESPONSE_MULTIPART); + stubFor( + post(urlEqualTo(ODATA_SERVICE_PATH + "/$batch")) + .withRequestBody(containing("PATCH " + concreteNameEndpoint)) + .withRequestBody(containing("DELETE " + concreteNameEndpoint)) + .willReturn(response)); + + // change entity + address.setDescription("Alternative Description"); + + final BatchResponse result = + new TestBatch() + .beginChangeSet() + .updateTestEntity(address) + .deleteTestEntity(address) + .endChangeSet() + .executeRequest(destination); + + final Try changeSet = result.get(0); + assertThat(changeSet.isSuccess()).isTrue(); + } + + @Test + void testUpdateWithPatchRetainingNullValues() + { + final String concreteNameEndpoint = "A_TestEntity(%27Test%27)"; + + final TestEntity address = new TestEntity().withName("Test").withDescription("Existing description"); + + // prepare mocked data + final ResponseDefinitionBuilder response = + okForContentType( + "multipart/mixed; boundary=4729730C52B1E9D05AAAB969F2FEE6970", + SINGLE_UPDATE_DELETE_CHANGESET_RESPONSE_MULTIPART); + stubFor( + post(urlEqualTo(ODATA_SERVICE_PATH + "/$batch")) + .withRequestBody(containing("PATCH " + concreteNameEndpoint)) + .willReturn(response)); + + // change entity + address.setDescription(null); + final BatchResponse result = + new TestBatch().beginChangeSet().updateTestEntity(address).endChangeSet().executeRequest(destination); + + verify( + postRequestedFor(urlEqualTo(ODATA_SERVICE_PATH + "/$batch")) + .withRequestBody(containing("\"Description\":" + null))); + final Try changeSet = result.get(0); + assertThat(changeSet.isSuccess()).isTrue(); + } + + /* + * Assertion helpers + */ + private void assertResponse( List> createdEntities, int expectedSize, TestEntity referenceEntity ) + { + // general assertion: two created entities not equal to each other or to the input instance + assertThat(createdEntities).hasSize(expectedSize).doesNotHaveDuplicates().doesNotContain((VdmEntity) null); + assertThat(createdEntities).allMatch(TestEntity.class::isInstance).doesNotContain(referenceEntity); + + // cast items to TestEntity + final List createdTestEntities = + createdEntities.stream().map(TestEntity.class::cast).collect(Collectors.toList()); + + // check for auto generated fields ID and UUID, must not be similar or equal to input + assertThat(createdTestEntities) + .extracting(TestEntity::getName) + .isNotEmpty() + .doesNotHaveDuplicates() + .doesNotContain(referenceEntity.getName(), "", null); + + assertThat(createdTestEntities) + .extracting(TestEntity::getGeneratedProperty) + .isNotEmpty() + .doesNotHaveDuplicates() + .doesNotContain(referenceEntity.getGeneratedProperty()); + + // check for valid input data + for( final Function handler : Lists + .> newArrayList(TestEntity::getDescription) ) { + assertThat(createdTestEntities).extracting(handler).containsOnly(handler.apply(referenceEntity)); + } + } + + @Test + void testEncodingInBatchedUpdate() + { + final ResponseDefinitionBuilder response = + okForContentType( + "multipart/mixed; boundary=FB687BFCC8917ABB09014537111216650", + SINGLE_CHANGESET_RESPONSE_MULTIPART); + stubFor( + post(urlEqualTo(ODATA_SERVICE_PATH + "/$batch")) + .withRequestBody(containing("Müller Straße")) + .willReturn(response)); + + final TestEntity address = new TestEntity().withName("Müller Straße"); + address.setDescription("Müller Straße"); + + new TestBatch().beginChangeSet().updateTestEntity(address).endChangeSet().executeRequest(destination); + + verify(1, postRequestedFor(urlEqualTo(ODATA_SERVICE_PATH + "/$batch"))); + verify(1, headRequestedFor(anyUrl()).withHeader("x-csrf-token", equalTo("fetch"))); + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + @EqualsAndHashCode( callSuper = true ) + @JsonAdapter( ODataVdmEntityAdapterFactory.class ) + public static class TestEntity extends VdmEntity + { + private final String entityCollection = "A_TestEntity"; + private final Class type = TestEntity.class; + + @SerializedName( "Name" ) + @JsonProperty( "Name" ) + @ODataField( odataName = "Name" ) + @With + private String name; + + @SerializedName( "Description" ) + @JsonProperty( "Description" ) + @ODataField( odataName = "Description" ) + @With + private String description; + + @SerializedName( "GeneratedProperty" ) + @JsonProperty( "GeneratedProperty" ) + @ODataField( odataName = "GeneratedProperty" ) + private String generatedProperty; + + @Nonnull + @Override + protected Map getKey() + { + return Collections.singletonMap("Name", getName()); + } + + @Nonnull + @Override + protected Map toMapOfFields() + { + final Map result = new LinkedHashMap<>(); + result.put("Name", getName()); + result.put("Description", getDescription()); + return result; + } + + public void setDescription( final String description ) + { + rememberChangedField("Description", this.description); + this.description = description; + } + } + + @Getter + public static class TestBatch extends BatchFluentHelperBasic + { + private final String servicePathForBatchRequest = ODATA_SERVICE_PATH; + + @Nonnull + @Override + protected TestBatch getThis() + { + return this; + } + + @Nonnull + @Override + public TestBatchChangeset beginChangeSet() + { + return new TestBatchChangeset(this); + } + } + + public static class TestBatchChangeset extends BatchChangeSetFluentHelperBasic + { + public TestBatchChangeset( final TestBatch parent ) + { + super(parent, parent); + } + + @Nonnull + @Override + protected TestBatchChangeset getThis() + { + return this; + } + + public TestBatchChangeset updateTestEntity( final TestEntity entity ) + { + return super.addRequestUpdate(FluentHelperFactory.withServicePath(ODATA_SERVICE_PATH)::update, entity); + } + + public TestBatchChangeset createTestEntity( final TestEntity entity ) + { + return super.addRequestCreate(FluentHelperFactory.withServicePath(ODATA_SERVICE_PATH)::create, entity); + } + + public TestBatchChangeset deleteTestEntity( final TestEntity entity ) + { + return super.addRequestDelete(FluentHelperFactory.withServicePath(ODATA_SERVICE_PATH)::delete, entity); + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataChangedFieldsTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataChangedFieldsTest.java new file mode 100644 index 0000000000..b25d5fcd0f --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataChangedFieldsTest.java @@ -0,0 +1,118 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +class ODataChangedFieldsTest +{ + @Test + void testEmpty() + { + final TestVdmEntity testEntity = new TestVdmEntity(); + assertThat(testEntity.getChangedFields()).isEmpty(); + } + + @Test + void testBuilder() + { + final TestVdmEntity testEntity = TestVdmEntity.builder().stringValue("Foo").build(); + assertThat(testEntity.getChangedFields()).isEmpty(); + } + + @Test + void testAccessor() + { + final TestVdmEntity testEntity = new TestVdmEntity(); + testEntity.setStringValue("Foo"); + assertThat(testEntity.getChangedFields()).containsEntry("StringValue", "Foo"); + } + + @Test + void testAccessorNull() + { + final TestVdmEntity testEntity = new TestVdmEntity(); + testEntity.setDecimalValue(null); + assertThat(testEntity.getChangedFields()).isEmpty(); + } + + @Test + void testCustomFields() + { + final TestVdmEntity testEntity = new TestVdmEntity(); + testEntity.setCustomField("BarValue", "Foo"); + assertThat(testEntity.getChangedFields()).containsEntry("BarValue", "Foo"); + } + + @Test + void testCustomFieldsNull() + { + final TestVdmEntity testEntity = new TestVdmEntity(); + testEntity.setCustomField("BarValue", null); + assertThat(testEntity.getChangedFields()).isEmpty(); + } + + @Test + void testRevertingValue() + { + final TestVdmEntity testEntity = new TestVdmEntity(); + testEntity.setIntegerValue(9000); + assertThat(testEntity.getChangedFields()).containsEntry("IntegerValue", 9000); + + testEntity.setIntegerValue(null); + assertThat(testEntity.getChangedFields()).isEmpty(); + } + + @Test + void testComplexValue() + { + final TestVdmEntity testEntity = new TestVdmEntity(); + final TestVdmComplex complex1 = TestVdmComplex.builder().someValue("Foo").build(); + final TestVdmComplex complex2 = new TestVdmComplex(); + complex2.setSomeValue("Bar"); + + testEntity.setComplexValue(complex1); + assertThat(testEntity.getChangedFields()).containsOnly(entry("ComplexValue", complex1)); + + testEntity.setComplexValue(complex2); + assertThat(testEntity.getChangedFields()).containsOnly(entry("ComplexValue", complex2)); + } + + @Disabled( "Not yet implemented. See CLOUDECOSYSTEM-9065" ) + @Test + void testInnerComplexValue() + { + final TestVdmComplex complex = TestVdmComplex.builder().someValue("Tic").build(); + final TestVdmEntity testEntity = TestVdmEntity.builder().complexValue(complex).build(); + assertThat(testEntity.getChangedFields()).isEmpty(); + + complex.setSomeValue("Tac"); + assertThat(testEntity.getChangedFields()).containsOnly(entry("ComplexValue", complex)); + } + + /** + * Updating changes on related entities in navigation properties are not supported in OData V2 (aka Deep Update). + */ + @Test + void testNavigationPropertyToOne() + { + final TestVdmEntity testEntity = new TestVdmEntity(); + final TestVdmEntity parent = TestVdmEntity.builder().stringValue("Foo").build(); + testEntity.setToParent(parent); + assertThat(testEntity.getChangedFields()).isEmpty(); + } + + /** + * Updating changes on related entities in navigation properties are not supported in OData V2 (aka Deep Update). + */ + @Test + void testNavigationPropertyToMany() + { + final TestVdmEntity testEntity = new TestVdmEntity(); + final TestVdmEntity child1 = TestVdmEntity.builder().stringValue("Foo").build(); + testEntity.addToChildren(child1); + assertThat(testEntity.getChangedFields()).isEmpty(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataEntitySerializerTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataEntitySerializerTest.java new file mode 100644 index 0000000000..681b6da9ea --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataEntitySerializerTest.java @@ -0,0 +1,104 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.FieldReference; + +class ODataEntitySerializerTest +{ + @Test + void testSerializeEntityForCreate() + { + final TestVdmEntity entity = TestVdmEntity.builder().stringValue("string").booleanValue(false).build(); + entity.setIntegerValue(42); + + final String payload = ODataEntitySerializer.serializeEntityForCreate(entity); + assertThat(payload).isEqualTo("{\"IntegerValue\":42,\"StringValue\":\"string\",\"BooleanValue\":false}"); + } + + @Test + void testSerializeEntityForUpdatePut() + { + final TestVdmEntity entity = TestVdmEntity.builder().stringValue("string").booleanValue(false).build(); + entity.setIntegerValue(42); + + final String payload = ODataEntitySerializer.serializeEntityForUpdatePut(entity, null); + assertThat(payload) + .isEqualTo( + "{\"IntegerValue\":42,\"GuidValue\":null,\"StringValue\":\"string\",\"OffsetDateTimeValue\":null,\"to_Parent\":null,\"to_Children\":null,\"DecimalValue\":null,\"DoubleValue\":null,\"LocalTimeValue\":null,\"LocalDateTimeValue\":null,\"BooleanValue\":false,\"ComplexValue\":null}"); + } + + @Test + void testSerializeEntityForUpdatePutWithExcludedFields() + { + final TestVdmEntity entity = TestVdmEntity.builder().stringValue("NewString").booleanValue(true).build(); + entity.setIntegerValue(45); + + final List fieldsToExclude = + Arrays.asList(FieldReference.of("DoubleValue"), FieldReference.of("ComplexValue")); + final String payload = ODataEntitySerializer.serializeEntityForUpdatePut(entity, fieldsToExclude); + assertThat(payload) + .isEqualTo( + "{\"IntegerValue\":45,\"GuidValue\":null,\"StringValue\":\"NewString\",\"OffsetDateTimeValue\":null,\"to_Parent\":null,\"to_Children\":null,\"DecimalValue\":null,\"LocalTimeValue\":null,\"LocalDateTimeValue\":null,\"BooleanValue\":true}"); + } + + @Test + void testSerializeEntityForUpdatePatch() + { + final TestVdmEntity entity = TestVdmEntity.builder().stringValue("string").booleanValue(false).build(); + entity.setIntegerValue(42); + + final Collection fields = Arrays.asList(FieldReference.of("a"), FieldReference.of("b")); + final String payload = ODataEntitySerializer.serializeEntityForUpdatePatchShallow(entity, fields); + assertThat(payload).isEqualTo("{\"a\":null,\"b\":null,\"IntegerValue\":42}"); + } + + @Test + void testSerializeEntityForUpdatePatchNested() + { + final TestVdmComplex grandchildComplex = TestVdmComplex.builder().someValue("initialGrandchildValue").build(); + final TestVdmComplex childComplex = + TestVdmComplex.builder().someValue("initialChildValue").complexValue(grandchildComplex).build(); + final TestVdmEntity rootEntity = + TestVdmEntity + .builder() + .stringValue("initialRootValue") + .booleanValue(false) + .complexValue(childComplex) + .build(); + + rootEntity.setStringValue("newRootValue"); + grandchildComplex.setSomeValue("newGrandchildValue"); + + final Collection additionalFields = Arrays.asList(FieldReference.of("customField")); + + final String fullPayload = + ODataEntitySerializer.serializeEntityForUpdatePatchRecursiveFull(rootEntity, additionalFields); + assertThat(fullPayload) + .isEqualTo( + "{\"StringValue\":\"newRootValue\",\"customField\":null,\"ComplexValue\":{\"SomeValue\":\"initialChildValue\",\"OtherValue\":null,\"ComplexValue\":{\"SomeValue\":\"newGrandchildValue\",\"OtherValue\":null,\"ComplexValue\":null}}}"); + + final String deltaPayload = + ODataEntitySerializer.serializeEntityForUpdatePatchRecursiveDelta(rootEntity, additionalFields); + assertThat(deltaPayload) + .isEqualTo( + "{\"customField\":null,\"ComplexValue\":{\"ComplexValue\":{\"SomeValue\":\"newGrandchildValue\"}},\"StringValue\":\"newRootValue\"}"); + + final TestVdmComplex siblingGrandchildComplex = + TestVdmComplex.builder().someValue("newSiblingGrandchildValue").build(); + childComplex.setComplexValue(siblingGrandchildComplex); + + final String siblingDeltaPayload = + ODataEntitySerializer.serializeEntityForUpdatePatchRecursiveDelta(rootEntity, additionalFields); + assertThat(siblingDeltaPayload) + .isEqualTo( + "{\"customField\":null,\"ComplexValue\":{\"ComplexValue\":{\"SomeValue\":\"newSiblingGrandchildValue\",\"OtherValue\":null,\"ComplexValue\":null}},\"StringValue\":\"newRootValue\"}"); + } + +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataFilterTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataFilterTest.java new file mode 100644 index 0000000000..69c59c59a2 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataFilterTest.java @@ -0,0 +1,111 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +import javax.annotation.Nonnull; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueBoolean; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +class ODataFilterTest +{ + private static final EntityField STRING_FIELD = new EntityField<>("field"); + private static final EntityField ZONED_DATE_TIME_FIELD = new EntityField<>("field"); + private static final EntityField LOCAL_DATE_TIME_FIELD = new EntityField<>("field"); + private static final EntityField LOCAL_TIME_FIELD = new EntityField<>("field"); + private static final EntityField INT_FIELD = new EntityField<>("field"); + + @ParameterizedTest + @EnumSource( TestInput.class ) + void testFilterExpression( @Nonnull final TestInput testInput ) + { + final ValueBoolean clientExpression = testInput.helper.getDelegateExpressionWithoutOuterParentheses(); + assertThat(clientExpression.getExpression(ODataProtocol.V2)).isEqualTo(testInput.expectedFilterString); + + final ODataRequestRead request = + FluentHelperFactory + .withServicePath("/foo/bar") + .read(TestEntity.class, "foo") + .filter(testInput.helper) + .toRequest(); + assertThat(request.getRelativeUri()).hasQuery("$filter=" + testInput.expectedFilterString); + } + + @AllArgsConstructor + enum TestInput + { + STRING_EQUALITY(STRING_FIELD.eq("va'l''ue"), "field eq 'va''l''''ue'"), + STRING_SUBSTRING(STRING_FIELD.substringOf("foo"), "substringof('foo',field)"), + STRING_ENDS_WITH(STRING_FIELD.endsWith("foo"), "endswith(field,'foo')"), + STRING_STARTS_WITH(STRING_FIELD.startsWith("foo"), "startswith(field,'foo')"), + STRING_DISJUNCTION( + STRING_FIELD.startsWith("foo").or(STRING_FIELD.ne("bar")), + "startswith(field,'foo') or (field ne 'bar')"), + STRING_NEGATION(STRING_FIELD.endsWith("foo").not(), "not endswith(field,'foo')"), + STRING_COMPLEX_EXPRESSION( + STRING_FIELD + .substringOf("bar") + .and(STRING_FIELD.endsWith("foo")) + .or(STRING_FIELD.startsWith("foobar").not()) + .not(), + "not ((substringof('bar',field) and endswith(field,'foo')) or (not startswith(field,'foobar')))"), + ZONED_DATE_TIME( + ZONED_DATE_TIME_FIELD + .eq(ZonedDateTime.of(LocalDate.of(2001, 1, 1), LocalTime.of(20, 15), ZoneId.of("UTC"))), + "field eq datetimeoffset'2001-01-01T20:15:00Z'"), + LOCAL_DATE_TIME( + LOCAL_DATE_TIME_FIELD.eq(LocalDateTime.of(LocalDate.of(2001, 1, 1), LocalTime.of(20, 15))), + "field eq datetime'2001-01-01T20:15:00'"), + LOCAL_TIME(LOCAL_TIME_FIELD.eq(LocalTime.of(20, 15)), "field eq time'PT20H15M'"), + INT_LOGICAL_OPERATORS( + INT_FIELD.eq(1).and(ExpressionFluentHelper.not(INT_FIELD.ne(2))).or(INT_FIELD.eq(3)).not(), + "not (((field eq 1) and (not (field ne 2))) or (field eq 3))"), + INT_RELATIVE_OPERATORS( + INT_FIELD.le(1).and(INT_FIELD.ge(2)).and(INT_FIELD.lt(3).and(INT_FIELD.gt(4))), + "((field le 1) and (field ge 2)) and ((field lt 3) and (field gt 4))"), + INT_EQUALITY(INT_FIELD.eq(1), "field eq 1"), + INT_INEQUALITY(INT_FIELD.ne(1), "field ne 1"), + INT_GREATER_THAN(INT_FIELD.gt(1), "field gt 1"), + INT_GREATER_THAN_OR_EQUAL(INT_FIELD.ge(1), "field ge 1"), + INT_LESS_THAN(INT_FIELD.lt(1), "field lt 1"), + INT_LESS_THAN_OR_EQUAL(INT_FIELD.le(1), "field le 1"), + EQUAL_NULL(INT_FIELD.eq(null), "field eq null"), + EQUALNULL(INT_FIELD.eqNull(), "field eq null"), + NOT_EQUAL_NULL(INT_FIELD.ne(null), "field ne null"), + NOT_EQUALNULL(INT_FIELD.neNull(), "field ne null"), + GREATER_THAN_NULL(INT_FIELD.gt(null), "field gt null"), + GREATER_THAN_OR_EQUAL_NULL(INT_FIELD.ge(null), "field ge null"), + LESS_THAN_NULL(INT_FIELD.lt(null), "field lt null"), + LESS_THAN_OR_EQUAL_NULL(INT_FIELD.le(null), "field le null"),; + + @Nonnull + private final ExpressionFluentHelper helper; + @Nonnull + private final String expectedFilterString; + } + + private static class TestEntity extends VdmEntity + { + @Getter + final String entityCollection = null; + + @Getter + private final String defaultServicePath = "/"; + + @Getter + private final Class type = TestEntity.class; + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataHeaderTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataHeaderTest.java new file mode 100644 index 0000000000..5369da9865 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataHeaderTest.java @@ -0,0 +1,248 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +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.headRequestedFor; +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.patch; +import static com.github.tomakehurst.wiremock.client.WireMock.patchRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; + +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.github.tomakehurst.wiremock.matching.UrlPathPattern; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; + +@WireMockTest +class ODataHeaderTest +{ + private static final String DEFAULT_SERVICE_PATH = new TestVdmEntity().getDefaultServicePath(); + private static final String ENTITY_COLLECTION_PATH = new TestVdmEntity().getEntityCollection(); + private static final UrlPathPattern GET_ALL = urlPathEqualTo(DEFAULT_SERVICE_PATH + '/' + ENTITY_COLLECTION_PATH); + private static final UrlPathPattern CREATE = urlPathEqualTo(DEFAULT_SERVICE_PATH + '/' + ENTITY_COLLECTION_PATH); + private static final UrlPathPattern UPDATE = + urlPathEqualTo(DEFAULT_SERVICE_PATH + '/' + ENTITY_COLLECTION_PATH + "(123)"); + private static final UrlPathPattern DELETE = UPDATE; + private static final UrlPathPattern CSRF = urlPathEqualTo(DEFAULT_SERVICE_PATH + "/"); + + private DefaultHttpDestination destination; + private TestVdmEntity entity; + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + stubFor(get(GET_ALL).willReturn(okJson("{\"d\":{\"results\":[]}}"))); + stubFor(patch(UPDATE).willReturn(okJson("{\"d\":{}"))); + stubFor(post(CREATE).willReturn(okJson("{\"d\":{}"))); + stubFor(delete(DELETE).willReturn(ok())); + stubFor( + head(CSRF).withHeader("x-csrf-token", equalTo("fetch")).willReturn(ok().withHeader("x-csrf-token", "abc"))); + + entity = TestVdmEntity.builder().integerValue(123).build(); + } + + // Test for implicitly missing CSRF header token and ETag header when GETTING an entity + @Test + void testNonExistingHeadersForGetDefault() + { + new TestEntityReadFluentHelper().executeRequest(destination); + verify(0, headRequestedFor(CSRF)); + verify(getRequestedFor(GET_ALL).withoutHeader("If-Match").withoutHeader("x-csrf-token")); + } + + // Test for CSRF token header and implicitly without ETag header when UPDATING an entity + @Test + void testUpdateDefaultHeader() + { + new TestEntityUpdateFluentHelper(entity).executeRequest(destination); + verify(headRequestedFor(CSRF).withHeader("x-csrf-token", equalTo("fetch"))); + verify(patchRequestedFor(UPDATE).withHeader("x-csrf-token", equalTo("abc")).withoutHeader("If-Match")); + } + + // Test for CSRF token header and explicit ETag header when UPDATING an entity + @Test + void testUpdateSpecificVersionVersionHeader() + { + entity.setVersionIdentifier("ver"); + new TestEntityUpdateFluentHelper(entity).executeRequest(destination); + verify(headRequestedFor(CSRF).withHeader("x-csrf-token", equalTo("fetch"))); + + verify( + patchRequestedFor(UPDATE) + .withHeader("x-csrf-token", equalTo("abc")) + .withHeader("If-Match", equalTo("ver"))); + } + + // Test for CSRF token header and explicit ETag-wildcard header when UPDATING an entity + @Test + void testUpdateWildcardVersionVersionHeader() + { + new TestEntityUpdateFluentHelper(entity).matchAnyVersionIdentifier().executeRequest(destination); + verify(headRequestedFor(CSRF).withHeader("x-csrf-token", equalTo("fetch"))); + + verify( + patchRequestedFor(UPDATE).withHeader("x-csrf-token", equalTo("abc")).withHeader("If-Match", equalTo("*"))); + } + + // Test for CSRF token header, explicitly without ETag header when UPDATING an entity + @Test + void testUpdateWithoutVersionHeader() + { + new TestEntityUpdateFluentHelper(entity).disableVersionIdentifier().executeRequest(destination); + verify(headRequestedFor(CSRF).withHeader("x-csrf-token", equalTo("fetch"))); + verify(patchRequestedFor(UPDATE).withHeader("x-csrf-token", equalTo("abc")).withoutHeader("If-Match")); + } + + // Test for custom header, implicitly without CSRF token header or ETag header when GETTING an entity + @Test + void testGetCustomHeaderHeader() + { + new TestEntityReadFluentHelper() + .withHeader("Authentication", "yes") + .withHeader("Cookie", "tasty") + .executeRequest(destination); + verify(0, headRequestedFor(CSRF)); + + verify( + getRequestedFor(GET_ALL) + .withHeader("Authentication", equalTo("yes")) + .withHeader("Cookie", equalTo("tasty")) + .withoutHeader("x-csrf-token") + .withoutHeader("If-Match")); + } + + // Test for CSRF token header, explicitly with custom headers when UPDATING an entity + @Test + void testUpdateCustomHeader() + { + new TestEntityUpdateFluentHelper(entity).withHeader("Authentication", "yes").executeRequest(destination); + + // Note: with Apache HttpClient 5 the CSRF token HEAD request is issued by the client-level + // CsrfTokenInterceptor, which does not propagate the request's custom headers. + verify(headRequestedFor(CSRF).withHeader("x-csrf-token", equalTo("fetch"))); + + verify( + patchRequestedFor(UPDATE) + .withHeader("Authentication", equalTo("yes")) + .withHeader("x-csrf-token", equalTo("abc"))); + } + + // fluent helpers + + // Test that withoutCsrfToken() disables the CSRF HEAD probe and sends neither a + // CSRF token nor the internal skip marker on the actual write request. + @Test + void testUpdateWithoutCsrfTokenSkipsHead() + { + new TestEntityUpdateFluentHelper(entity).withoutCsrfToken().executeRequest(destination); + + verify(0, headRequestedFor(CSRF)); + verify( + patchRequestedFor(UPDATE) + .withoutHeader("x-csrf-token") + .withoutHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER)); + } + + // Test that the deprecated withCsrfToken() is a no-op on a read: no CSRF HEAD probe is fired. + @SuppressWarnings( "deprecation" ) + @Test + void testReadWithCsrfTokenIsNoOp() + { + new TestEntityReadFluentHelper().withCsrfToken().executeRequest(destination); + + verify(0, headRequestedFor(CSRF)); + verify(getRequestedFor(GET_ALL).withoutHeader("x-csrf-token")); + } + + private static class TestEntityDeleteFluentHelper + extends + FluentHelperDelete + { + private final TestVdmEntity entity; + + @SuppressWarnings( "deprecation" ) + private TestEntityDeleteFluentHelper( @Nonnull final TestVdmEntity entity ) + { + super(DEFAULT_SERVICE_PATH, entity.getEntityCollection()); + this.entity = entity; + } + + @Override + protected TestVdmEntity getEntity() + { + return entity; + } + } + + private static class TestEntityUpdateFluentHelper + extends + FluentHelperUpdate + { + private final TestVdmEntity entity; + + @SuppressWarnings( "deprecation" ) + private TestEntityUpdateFluentHelper( @Nonnull final TestVdmEntity entity ) + { + super(DEFAULT_SERVICE_PATH, entity.getEntityCollection()); + this.entity = entity; + } + + @Override + protected TestVdmEntity getEntity() + { + return entity; + } + } + + private static class TestEntityCreateFluentHelper + extends + FluentHelperCreate + { + private final TestVdmEntity entity; + + @SuppressWarnings( "deprecation" ) + private TestEntityCreateFluentHelper( @Nonnull final TestVdmEntity entity ) + { + super(DEFAULT_SERVICE_PATH, entity.getEntityCollection()); + this.entity = entity; + } + + @Override + protected TestVdmEntity getEntity() + { + return entity; + } + } + + private static class TestEntityReadFluentHelper + extends + FluentHelperRead + { + @SuppressWarnings( "deprecation" ) + private TestEntityReadFluentHelper() + { + super(DEFAULT_SERVICE_PATH, TestVdmEntity.builder().build().getEntityCollection()); + } + + @Nonnull + @Override + protected Class getEntityClass() + { + return TestVdmEntity.class; + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataSelectExpandTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataSelectExpandTest.java new file mode 100644 index 0000000000..d81be99c68 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataSelectExpandTest.java @@ -0,0 +1,215 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import java.util.List; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataField; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +class ODataSelectExpandTest +{ + @Test + void testSelectExpand() + { + final FluentHelperRead entityRead = + FluentHelperFactory.withServicePath("service/path/").read(TestEntity.class, "TestEntity"); + entityRead.select(TestEntity.FOO, TestEntity.TO_PARENT, TestEntity.TO_CHILDREN.select(TestEntity.BAR)); + + final ODataRequestRead requestRead = entityRead.toRequest(); + + assertThat(requestRead.getQueryString()) + .isEqualTo("$select=Foo,to_Parent/*,to_Children/Bar&$expand=to_Parent,to_Children"); + + assertThat(requestRead.getRelativeUri()) + .hasPath("/service/path/TestEntity") + .hasQuery("$select=Foo,to_Parent/*,to_Children/Bar&$expand=to_Parent,to_Children"); + } + + @Test + void testSelectSome() + { + final FluentHelperRead entityRead = + FluentHelperFactory.withServicePath("service/path/").read(TestEntity.class, "TestEntity"); + entityRead.select(TestEntity.FOO, TestEntity.BAR, TestEntity.FOO); + + final ODataRequestRead requestRead = entityRead.toRequest(); + + assertThat(requestRead.getQueryString()).isEqualTo("$select=Foo,Bar"); + + assertThat(requestRead.getRelativeUri()).hasPath("/service/path/TestEntity").hasQuery("$select=Foo,Bar"); + } + + @Test + void testSelectAll() + { + final FluentHelperRead entityRead = + FluentHelperFactory.withServicePath("service/path/").read(TestEntity.class, "TestEntity"); + entityRead.select(TestEntity.ALL_FIELDS); + + final ODataRequestRead requestRead = entityRead.toRequest(); + + assertThat(requestRead.getQueryString()).isEqualTo("$select=*"); + + assertThat(requestRead.getRelativeUri()).hasPath("/service/path/TestEntity").hasQuery("$select=*"); + } + + @Test + void testExpand() + { + final FluentHelperRead entityRead = + FluentHelperFactory.withServicePath("service/path/").read(TestEntity.class, "TestEntity"); + entityRead.select(TestEntity.TO_PARENT, TestEntity.TO_CHILDREN.select(TestEntity.TO_PARENT)); + + final ODataRequestRead requestRead = entityRead.toRequest(); + + assertThat(requestRead.getQueryString()) + .isEqualTo("$select=to_Parent/*,to_Children/to_Parent/*&$expand=to_Parent,to_Children/to_Parent"); + + assertThat(requestRead.getRelativeUri()) + .hasPath("/service/path/TestEntity") + .hasQuery("$select=to_Parent/*,to_Children/to_Parent/*&$expand=to_Parent,to_Children/to_Parent"); + } + + // full set of generated VDM classes for TestEntity + + @Builder + @Data + @NoArgsConstructor + @AllArgsConstructor + @ToString( doNotUseGetters = true, callSuper = true ) + @EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataVdmEntityAdapterFactory.class ) + public static class TestEntity extends VdmEntity + { + public final static TestEntitySelectable ALL_FIELDS = new TestEntitySelectable() + { + @Getter + public String fieldName = "*"; + @Getter + public List selections = Collections.singletonList("*"); + }; + + public static final TestEntitySelectableOneToOneLink TO_PARENT = + new TestEntitySelectableOneToOneLink<>("to_Parent"); + public static final TestEntitySelectableLink TO_CHILDREN = + new TestEntitySelectableLink<>("to_Children"); + public static final TestEntitySelectableField FOO = new TestEntitySelectableField<>("Foo"); + public static final TestEntitySelectableField BAR = new TestEntitySelectableField<>("Bar"); + + @Getter + final String entityCollection = "TestEntity"; + + @Getter + private final String defaultServicePath = "/"; + + @Getter + private final Class type = TestEntity.class; + + @SerializedName( "to_Parent" ) + @JsonProperty( "to_Parent" ) + @ODataField( odataName = "to_Parent" ) + @Nullable + @Getter( AccessLevel.NONE ) + @Setter( AccessLevel.NONE ) + private TestEntity toParent; + + @SerializedName( "to_Children" ) + @JsonProperty( "to_Children" ) + @ODataField( odataName = "to_Children" ) + @Getter( AccessLevel.NONE ) + @Setter( AccessLevel.NONE ) + private List toChildren; + + @SerializedName( "Foo" ) + @JsonProperty( "Foo" ) + @Nullable + @ODataField( odataName = "Foo" ) + private String foo; + + @SerializedName( "Bar" ) + @JsonProperty( "Bar" ) + @Nullable + @ODataField( odataName = "Bar" ) + private String bar; + } + + private interface TestEntitySelectable extends EntitySelectable + { + } + + private static class TestEntitySelectableField extends EntityField + implements + TestEntitySelectable + { + private TestEntitySelectableField( @Nonnull final String fieldName ) + { + super(fieldName); + } + } + + private static class TestEntitySelectableLink> + extends + EntityLink, TestEntity, ObjectT> + implements + TestEntitySelectable + { + private TestEntitySelectableLink( final String fieldName ) + { + super(fieldName); + } + + private TestEntitySelectableLink( + final EntityLink, TestEntity, ObjectT> toClone ) + { + super(toClone); + } + + @Nonnull + @Override + protected TestEntitySelectableLink translateLinkType( + final EntityLink, TestEntity, ObjectT> link ) + { + return new TestEntitySelectableLink(link); + } + } + + private static class TestEntitySelectableOneToOneLink> + extends + TestEntitySelectableLink + implements + OneToOneLink + { + private TestEntitySelectableOneToOneLink( final String fieldName ) + { + super(fieldName); + } + + @Nonnull + @Override + public ExpressionFluentHelper filter( + @Nonnull final ExpressionFluentHelper filterExpression ) + { + return super.filterOnOneToOneLink(filterExpression); + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataSerializationTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataSerializationTest.java new file mode 100644 index 0000000000..57c13e6506 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataSerializationTest.java @@ -0,0 +1,342 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.data.MapEntry.entry; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.Month; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import javax.annotation.Nullable; + +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.io.entity.StringEntity; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.common.collect.ImmutableMap; +import com.google.gson.Gson; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +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.request.ODataRequestGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataField; +import com.sap.cloud.sdk.s4hana.datamodel.odata.annotation.Key; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +class ODataSerializationTest +{ + // take note that we are NOT using the following values: + // "LocalDateTimeValue": "datetime'1992-01-01T00:00:00'" + // "OffsetDateTimeValue": "datetimeoffset'1992-01-01T00:00:00Z-04:00'" + // "LocalTimeValue": "time'PT13H20M'" + // "GuidValue": "guid'123e4567-e89b-12d3-a456-426614174000'" + private static final String SAMPLE_PAYLOAD = """ + { + "d" : { + "__metadata": { + "uri": "https://services.odata.org/OData/OData.svc/Categories(42)", + "etag": "W/\\"some-version-id\\"", + "type": "DataServiceProviderDemo.Category" + }, + "SByteValue": -127, + "Int16Value": 1337, + "IntegerValue": 42, + "Int64Value": 123456789000, + "DecimalValue": "123456.789", + "DoubleValue": 42.1, + "BooleanValue": true, + "StringValue": "Food", + "LocalDateTimeValue": "/Date(694224000000)/", + "OffsetDateTimeValue": "/Date(694224000000-0240)/", + "LocalTimeValue": "PT13H20M", + "GuidValue": "123e4567-e89b-12d3-a456-426614174000", + "to_Parent": { + "__deferred": { + "uri": "https://services.odata.org/OData/OData.svc/Categories(42)/to_Parent" + } + }, + "to_Children": { + "__deferred": { + "uri": "https://services.odata.org/OData/OData.svc/Categories(42)/to_Children" + } + }, + "UnmappedStringValue": "foo", + "UnmappedArrayValue": ["fizz","buzz"], + "UnmappedComplexValue": {"bar":"fizzbuzz"} + } + } + """; + + @Test + void testBrokenResponse() + { + // setup http response + final ClassicHttpResponse response = mock(ClassicHttpResponse.class); + doReturn(new Header[0]).when(response).getHeaders(); + doReturn(201).when(response).getCode(); + doReturn(new StringEntity("{\"d\":{\"broken\"}}", ContentType.APPLICATION_JSON)).when(response).getEntity(); + + // setup generic request for reference + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + doReturn(ODataProtocol.V2).when(request).getProtocol(); + + // test entity deserialization + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, response); + assertThatCode(() -> result.as(TestEntity.class)).isInstanceOf(ODataDeserializationException.class); + } + + @Test + void testNoValuesResponse() + { + // setup http response + final ClassicHttpResponse response = mock(ClassicHttpResponse.class); + doReturn(new Header[0]).when(response).getHeaders(); + doReturn(201).when(response).getCode(); + doReturn(new StringEntity("{\"d\":{}}", ContentType.APPLICATION_JSON)).when(response).getEntity(); + + // setup generic request for reference + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + doReturn(ODataProtocol.V2).when(request).getProtocol(); + + // test entity deserialization + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, response); + final TestEntity entity = result.as(TestEntity.class); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).isEmpty(); + assertThat(entity.getIntegerValue()).isNull(); + assertThat(entity.getDecimalValue()).isNull(); + assertThat(entity.getDoubleValue()).isNull(); + assertThat(entity.getBooleanValue()).isNull(); + assertThat(entity.getLocalTimeValue()).isNull(); + assertThat(entity.getStringValue()).isNull(); + assertThat(entity.getGuidValue()).isNull(); + assertThat(entity.getLocalDateTimeValue()).isNull(); + assertThat(entity.getOffsetDateTimeValue()).isNull(); + assertThat(entity.getCustomFields()).isEmpty(); + } + + @Test + void testCreateResponse() + { + // setup http response + final ClassicHttpResponse response = mock(ClassicHttpResponse.class); + doReturn(new Header[0]).when(response).getHeaders(); + doReturn(201).when(response).getCode(); + doReturn(new StringEntity(SAMPLE_PAYLOAD, ContentType.APPLICATION_JSON)).when(response).getEntity(); + + // setup generic request for reference + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + doReturn(ODataProtocol.V2).when(request).getProtocol(); + + // test entity deserialization + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, response); + final TestEntity entity = result.as(TestEntity.class); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly("W/\"some-version-id\""); + assertThat(entity.getSByteValue()).isEqualTo((byte) -127); + assertThat(entity.getInt16Value()).isEqualTo((short) 1337); + assertThat(entity.getIntegerValue()).isEqualTo(42); + assertThat(entity.getInt64Value()).isEqualTo(123456789000L); + assertThat(entity.getDecimalValue()).isEqualTo("123456.789"); + assertThat(entity.getDoubleValue()).isEqualTo(42.1); + assertThat(entity.getBooleanValue()).isTrue(); + assertThat(entity.getLocalTimeValue()).isEqualTo(LocalTime.of(13, 20, 0)); + assertThat(entity.getStringValue()).isEqualTo("Food"); + assertThat(entity.getGuidValue()).isEqualTo(UUID.fromString("123e4567-e89b-12d3-a456-426614174000")); + assertThat(entity.getLocalDateTimeValue()).isEqualTo(LocalDate.of(1992, Month.JANUARY, 1).atStartOfDay()); + assertThat(entity.getOffsetDateTimeValue()) + .isEqualTo( + LocalDate + .of(1992, Month.JANUARY, 1) + .atStartOfDay() + .atZone(ZoneId.ofOffset("GMT", ZoneOffset.ofHours(-4)))); + + assertThat(entity.getCustomFields()) + .containsExactly( + entry("UnmappedStringValue", "foo"), + entry("UnmappedArrayValue", Arrays.asList("fizz", "buzz")), + entry("UnmappedComplexValue", ImmutableMap.of("bar", "fizzbuzz"))); + } + + @Test + void testSerialisationForDateTimeAttributes() + { + final String SERIALIZED_ENTITY = """ + {\ + "IntegerValue":1,\ + "OffsetDateTimeValue":"/Date(694224000000-0240)/",\ + "LocalTimeValue":"PT13H20M0S",\ + "LocalDateTimeValue":"/Date(694224000000)/"\ + }\ + """; + final TestEntity entity = new TestEntity(); + + entity.setIntegerValue(1); + entity.setLocalDateTimeValue(LocalDate.of(1992, Month.JANUARY, 1).atStartOfDay()); + entity.setLocalTimeValue(LocalTime.of(13, 20, 0)); + entity + .setOffsetDateTimeValue( + LocalDate + .of(1992, Month.JANUARY, 1) + .atStartOfDay() + .atZone(ZoneId.ofOffset("GMT", ZoneOffset.ofHours(-4)))); + + final String serialisedEntity = new Gson().toJson(entity); + + assertThat(serialisedEntity).isEqualTo(SERIALIZED_ENTITY); + } + + @Builder + @Data + @NoArgsConstructor + @AllArgsConstructor + @ToString( doNotUseGetters = true, callSuper = true ) + @EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataVdmEntityAdapterFactory.class ) + public static class TestEntity extends VdmEntity + { + @Getter + final String entityCollection = null; + + @Getter + private final String defaultServicePath = "/"; + + @Getter + private final Class type = TestEntity.class; + + @Key + @SerializedName( "IntegerValue" ) + @JsonProperty( "IntegerValue" ) + @Nullable + @ODataField( odataName = "IntegerValue" ) + private Integer integerValue; + + @SerializedName( "GuidValue" ) + @JsonProperty( "GuidValue" ) + @Nullable + @ODataField( odataName = "GuidValue" ) + private UUID guidValue; + + @SerializedName( "StringValue" ) + @JsonProperty( "StringValue" ) + @Nullable + @ODataField( odataName = "StringValue" ) + private String stringValue; + + @SerializedName( "OffsetDateTimeValue" ) + @JsonProperty( "OffsetDateTimeValue" ) + @Nullable + @JsonSerialize( using = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.JacksonZonedDateTimeSerializer.class ) + @JsonDeserialize( + using = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.JacksonZonedDateTimeDeserializer.class ) + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ZonedDateTimeAdapter.class ) + @ODataField( + odataName = "OffsetDateTimeValue", + converter = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ZonedDateTimeCalendarConverter.class ) + private ZonedDateTime offsetDateTimeValue; + + @SerializedName( "to_Parent" ) + @JsonProperty( "to_Parent" ) + @ODataField( odataName = "to_Parent" ) + @Nullable + @Getter( AccessLevel.NONE ) + @Setter( AccessLevel.NONE ) + private TestEntity toParent; + + @SerializedName( "to_Children" ) + @JsonProperty( "to_Children" ) + @ODataField( odataName = "to_Children" ) + @Getter( AccessLevel.NONE ) + @Setter( AccessLevel.NONE ) + private List toChildren; + + @SerializedName( "DecimalValue" ) + @JsonProperty( "DecimalValue" ) + @Nullable + @ODataField( odataName = "DecimalValue" ) + private BigDecimal decimalValue; + + @SerializedName( "DoubleValue" ) + @JsonProperty( "DoubleValue" ) + @Nullable + @ODataField( odataName = "DoubleValue" ) + private Double doubleValue; + + @SerializedName( "LocalTimeValue" ) + @JsonProperty( "LocalTimeValue" ) + @Nullable + @JsonSerialize( using = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.JacksonLocalTimeSerializer.class ) + @JsonDeserialize( using = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.JacksonLocalTimeDeserializer.class ) + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.LocalTimeAdapter.class ) + @ODataField( + odataName = "LocalTimeValue", + converter = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.LocalTimeCalendarConverter.class ) + private LocalTime localTimeValue; + + @SerializedName( "LocalDateTimeValue" ) + @JsonProperty( "LocalDateTimeValue" ) + @Nullable + @JsonSerialize( using = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.JacksonLocalDateTimeSerializer.class ) + @JsonDeserialize( + using = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.JacksonLocalDateTimeDeserializer.class ) + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.LocalDateTimeAdapter.class ) + @ODataField( + odataName = "LocalDateTimeValue", + converter = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.LocalDateTimeCalendarConverter.class ) + private LocalDateTime localDateTimeValue; + + @SerializedName( "BooleanValue" ) + @JsonProperty( "BooleanValue" ) + @Nullable + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataBooleanAdapter.class ) + @ODataField( odataName = "BooleanValue" ) + private Boolean booleanValue; + + @SerializedName( "SByteValue" ) + @JsonProperty( "SByteValue" ) + @Nullable + @ODataField( odataName = "SByteValue" ) + private Byte sByteValue; + + @SerializedName( "Int16Value" ) + @JsonProperty( "Int16Value" ) + @Nullable + @ODataField( odataName = "Int16Value" ) + private Short int16Value; + + @SerializedName( "Int64Value" ) + @JsonProperty( "Int64Value" ) + @Nullable + @ODataField( odataName = "Int64Value" ) + private Long int64Value; + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataV2FunctionImportBatchIntegrationTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataV2FunctionImportBatchIntegrationTest.java new file mode 100644 index 0000000000..8c9960d45e --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataV2FunctionImportBatchIntegrationTest.java @@ -0,0 +1,96 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +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.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +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 java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +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.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.datamodel.odata.helper.batch.BatchChangeSetFluentHelperBasic; +import com.sap.cloud.sdk.datamodel.odata.helper.batch.BatchFluentHelperBasic; + +import lombok.Getter; + +@WireMockTest +class ODataV2FunctionImportBatchIntegrationTest +{ + private static final String ODATA_ENDPOINT_URL = "/path/to/service"; + private static final String ODATA_ENDPOINT_BATCH_URL = ODATA_ENDPOINT_URL + "/$batch"; + + @Getter + public static class TestBatch extends BatchFluentHelperBasic + { + private final String servicePathForBatchRequest = ODATA_ENDPOINT_URL; + + @Nonnull + @Override + protected TestBatch getThis() + { + return this; + } + + @Nonnull + @Override + public TestBatchChangeset beginChangeSet() + { + return new TestBatchChangeset(this); + } + } + + public static class TestBatchChangeset extends BatchChangeSetFluentHelperBasic + { + public TestBatchChangeset( final TestBatch parent ) + { + super(parent, parent); + } + + @Nonnull + @Override + protected TestBatchChangeset getThis() + { + return this; + } + } + + @Test + void testFunctionImportWithPostInChangeSet( @Nonnull final WireMockRuntimeInfo wm ) + { + stubFor(head(urlEqualTo(ODATA_ENDPOINT_URL)).willReturn(noContent())); + stubFor(post(urlEqualTo(ODATA_ENDPOINT_BATCH_URL)).willReturn(ok())); + + final DefaultHttpDestination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + final Map parameters = new LinkedHashMap<>(); + parameters.put("FileDocumentYear", "2015"); + parameters.put("FileDocument", "00281"); + parameters.put("FileDocumentItem", "1"); + parameters.put("PostingDate", LocalDateTime.of(2015, 1, 12, 12, 12)); + + final FluentHelperFunction functionImport = + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .functionSinglePost(parameters, "CancelItem", Void.class); + + new TestBatch().beginChangeSet().addFunctionImport(functionImport).endChangeSet().executeRequest(destination); + + final String functionImportPost = + "POST CancelItem?FileDocumentYear='2015'&FileDocument='00281'&FileDocumentItem='1'&PostingDate=datetime'2015-01-12T12:12:00' HTTP/1.1"; + + verify(postRequestedFor(urlEqualTo(ODATA_ENDPOINT_BATCH_URL)).withRequestBody(containing(functionImportPost))); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataV2FunctionImportGetBatchIntegrationTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataV2FunctionImportGetBatchIntegrationTest.java new file mode 100644 index 0000000000..cda983383e --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataV2FunctionImportGetBatchIntegrationTest.java @@ -0,0 +1,83 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +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.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +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 java.util.Collections; + +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.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.datamodel.odata.helper.batch.BatchFluentHelperBasic; +import com.sap.cloud.sdk.datamodel.odata.helper.batch.FluentHelperBatchEndChangeSet; + +@WireMockTest +class ODataV2FunctionImportGetBatchIntegrationTest +{ + private static final String ODATA_ENDPOINT_URL = "/path/to/service"; + private static final String ODATA_ENDPOINT_BATCH_URL = ODATA_ENDPOINT_URL + "/$batch"; + + public static class TestBatch extends BatchFluentHelperBasic + implements + FluentHelperBatchEndChangeSet + { + @Nonnull + @Override + protected String getServicePathForBatchRequest() + { + return ODATA_ENDPOINT_URL; + } + + @Nonnull + @Override + protected TestBatch getThis() + { + return this; + } + + @Nonnull + @Override + public TestBatch beginChangeSet() + { + return this; + } + + @Nonnull + @Override + public TestBatch endChangeSet() + { + return this; + } + } + + @Test + void testFunctionImportWithGetInReadOperation( @Nonnull final WireMockRuntimeInfo wm ) + { + stubFor(head(urlEqualTo(ODATA_ENDPOINT_URL)).willReturn(noContent())); + stubFor(post(urlEqualTo(ODATA_ENDPOINT_BATCH_URL)).willReturn(ok())); + + final DefaultHttpDestination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + final FluentHelperFunction functionImport = + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .functionSingleGet(Collections.emptyMap(), "Get_TestQueue", String.class); + + new TestBatch().addReadOperations(functionImport).executeRequest(destination); + + final String functionImportGet = "GET Get_TestQueue HTTP/1.1"; + + verify(postRequestedFor(urlEqualTo(ODATA_ENDPOINT_BATCH_URL)).withRequestBody(containing(functionImportGet))); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataV2FunctionImportIntegrationTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataV2FunctionImportIntegrationTest.java new file mode 100644 index 0000000000..ec846cf235 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ODataV2FunctionImportIntegrationTest.java @@ -0,0 +1,232 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToIgnoreCase; +import static com.github.tomakehurst.wiremock.client.WireMock.head; +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.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +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.urlMatching; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.core5.http.HttpStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataField; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataVdmEntityAdapterFactory; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@WireMockTest +class ODataV2FunctionImportIntegrationTest +{ + private static final String ODATA_ENDPOINT_URL = "/path/to/service"; + private static final String ODATA_FUNCTION_IMPORT_URL = ODATA_ENDPOINT_URL + "/CancelItem.*"; + private static final String CSRF_TOKEN = "awesome-token"; + private static final String CSRF_TOKEN_HEADER_KEY = "x-csrf-token"; + + private static final String XML_ERROR_STRING = "an exception was raised ";; + private static final String JSON_ERROR_STRING = "{\"error\": \"an exception was raised\"}"; + private static final String RESPONSE = """ + {"d": { + "CancelItem": { + "__metadata": { "type": "API_TEST_SRV/CancelItem" }, + "SomeField": "1010" + } + }} + """; + + @Data + @EqualsAndHashCode( callSuper = true ) + @JsonAdapter( ODataVdmEntityAdapterFactory.class ) + public static class TestEntity extends VdmEntity + { + private final String entityCollection = "TestEntities"; + private final Class type = TestEntity.class; + + @SerializedName( "SomeField" ) + @JsonProperty( "SomeField" ) + @ODataField( odataName = "SomeField" ) + private String someField; + } + + private DefaultHttpDestination destination; + + @BeforeEach + void before( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + } + + @Test + void testFunctionImportWithCsrfToken() + { + wiremockIssueCsrfTokenWithStatusCodeOk(); + + wiremockSendSuccesfulJsonResponse(); + + final TestEntity materialDocumentItem = getCancelItemFluentHelper().executeRequest(destination); + + assertOnFunctionImportResponseEntity(materialDocumentItem); + } + + @Test + void testFunctionImportWithCsrfTokenAndServerErrorResponse() + { + wiremockIssueCsrfToken(WireMock.serverError()); + + wiremockSendSuccesfulJsonResponse(); + + final TestEntity materialDocumentItem = getCancelItemFluentHelper().executeRequest(destination); + + assertOnFunctionImportResponseEntity(materialDocumentItem); + } + + @Test + void testFunctionImportWithCsrfTokenAndMethodNotAllowedResponse() + { + wiremockIssueCsrfToken(WireMock.status(HttpStatus.SC_METHOD_NOT_ALLOWED)); + + wiremockSendSuccesfulJsonResponse(); + + final TestEntity materialDocumentItem = getCancelItemFluentHelper().executeRequest(destination); + + assertOnFunctionImportResponseEntity(materialDocumentItem); + } + + @Test + void testFunctionImportWithoutCsrfToken() + { + //Return OK but without csrf token in response header + stubFor( + head(urlEqualTo(ODATA_ENDPOINT_URL + "/")) + .withHeader(CSRF_TOKEN_HEADER_KEY, equalToIgnoreCase("Fetch")) + .willReturn(WireMock.ok())); + + stubFor(post(urlMatching(ODATA_FUNCTION_IMPORT_URL)).willReturn(okJson(RESPONSE))); + + final TestEntity materialDocumentItem = getCancelItemFluentHelper().executeRequest(destination); + + assertOnFunctionImportResponseEntity(materialDocumentItem); + + verify(postRequestedFor(urlMatching(ODATA_FUNCTION_IMPORT_URL)).withoutHeader(CSRF_TOKEN_HEADER_KEY)); + } + + @Test + void testFunctionImportWithJsonError() + { + wiremockIssueCsrfTokenWithStatusCodeOk(); + + stubFor( + post(urlMatching(ODATA_FUNCTION_IMPORT_URL)).willReturn(WireMock.badRequest().withBody(JSON_ERROR_STRING))); + + assertThatExceptionOfType(ODataException.class) + .isThrownBy(() -> getCancelItemFluentHelper().executeRequest(destination)); + } + + @Test + void testFunctionImportReturnsSanitizedXmlErrorBody() + { + wiremockIssueCsrfTokenWithStatusCodeOk(); + + stubFor( + post(urlMatching(ODATA_FUNCTION_IMPORT_URL)).willReturn(WireMock.badRequest().withBody(XML_ERROR_STRING))); + + assertThatExceptionOfType(ODataException.class) + .isThrownBy(() -> getCancelItemFluentHelper().executeRequest(destination)); + } + + /* + * This test verifies that header manipulation attempts (sneaking in new line characters in "user-supplied" HTTP + * header) are prevented. At this place this should not be necessary, as the system we receive the CSRF token from + * is the same one receiving the next request with this token, so any manipulation would hit the system that sent us + * the manipulated csrf token in the first place. But, better be safe than sorry, so we remove all non-printable + * characters. + * Additional note: As tomcat (so also wiremock) automatically removes new line characters we cannot directly test + * this logic here, therefore we test it with a tab (\t) character. + */ + @Test + void testNonPrintableCharactersRemovedFromCsrfTokenValue() + { + final String tokenWithNonPrintableCharacters = CSRF_TOKEN + "\t%0a%0d"; + final String tokenWithoutNonPrintableCharacters = CSRF_TOKEN + "%0a%0d"; + + stubFor( + head(urlEqualTo(ODATA_ENDPOINT_URL + "/")) + .withHeader(CSRF_TOKEN_HEADER_KEY, equalToIgnoreCase("Fetch")) + .willReturn(ok().withHeader(CSRF_TOKEN_HEADER_KEY, tokenWithNonPrintableCharacters))); + + stubFor( + post(urlMatching(ODATA_FUNCTION_IMPORT_URL)) + .withHeader(CSRF_TOKEN_HEADER_KEY, equalTo(tokenWithoutNonPrintableCharacters)) + .willReturn(okJson(RESPONSE))); + + getCancelItemFluentHelper().executeRequest(destination); + + verify( + postRequestedFor(urlMatching(ODATA_FUNCTION_IMPORT_URL)) + .withHeader(CSRF_TOKEN_HEADER_KEY, equalTo(tokenWithoutNonPrintableCharacters))); + } + + private void wiremockIssueCsrfToken( final ResponseDefinitionBuilder responseDefinitionBuilder ) + { + stubFor( + head(urlEqualTo(ODATA_ENDPOINT_URL + "/")) + .withHeader(CSRF_TOKEN_HEADER_KEY, equalToIgnoreCase("Fetch")) + .willReturn(responseDefinitionBuilder.withHeader(CSRF_TOKEN_HEADER_KEY, CSRF_TOKEN))); + } + + private void wiremockIssueCsrfTokenWithStatusCodeOk() + { + wiremockIssueCsrfToken(WireMock.ok()); + } + + private void assertOnFunctionImportResponseEntity( @Nullable final TestEntity materialDocumentItem ) + { + assertThat(materialDocumentItem).isNotNull(); + assertThat(materialDocumentItem.getSomeField()).isEqualTo("1010"); + } + + private void wiremockSendSuccesfulJsonResponse() + { + stubFor( + post(urlMatching(ODATA_FUNCTION_IMPORT_URL)) + .withHeader(CSRF_TOKEN_HEADER_KEY, equalTo(CSRF_TOKEN)) + .willReturn(okJson(RESPONSE))); + } + + private static FluentHelperFunction getCancelItemFluentHelper() + { + final Map parameters = new LinkedHashMap<>(); + parameters.put("FileDocumentYear", "2015"); + parameters.put("FileDocument", "00281"); + parameters.put("FileDocumentItem", "1"); + parameters.put("PostingDate", LocalDateTime.of(2015, 1, 12, 12, 12)); + return FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .functionSinglePost(parameters, "CancelItem", TestEntity.class); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/PaginationIntegrationTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/PaginationIntegrationTest.java new file mode 100644 index 0000000000..f3f29fc6d8 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/PaginationIntegrationTest.java @@ -0,0 +1,134 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static com.sap.cloud.sdk.datamodel.odata.helper.PaginationUnitTest.Customer; +import static com.sap.cloud.sdk.datamodel.odata.helper.PaginationUnitTest.newCustomerRead; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.net.URI; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; + +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; + +@Disabled( "Test runs against a v4 reference service on odata.org. Use it only to manually verify behaviour." ) +class PaginationIntegrationTest +{ + private static final int PAGE_SIZE = 20; + private static final ArgumentMatcher URI_WITHOUT_SKIP_TOKEN = uri -> !uri.getQuery().contains("$skiptoken"); + private static final ArgumentMatcher URL_WITH_SKIP_TOKEN = uri -> uri.getQuery().contains("$skiptoken"); + + private final HttpDestination destination = DefaultHttpDestination.builder("https://services.odata.org").build(); + + @Test + void testGetAll() + { + final HttpDestination destination = spy(this.destination); + + final List result = + newCustomerRead().select(Customer.CUSTOMER_ID).withPreferredPageSize(PAGE_SIZE).executeRequest(destination); + + verify(destination, times(1)).getHeaders(argThat(URI_WITHOUT_SKIP_TOKEN)); + verify(destination, atLeastOnce()).getHeaders(argThat(URL_WITH_SKIP_TOKEN)); + + assertThat(result) + .isNotEmpty() + .extracting(PaginationUnitTest.Customer::getCustomerId) + .allMatch(Objects::nonNull); + } + + @Test + void testGetAllIteratingEntities() + { + final HttpDestination destination = spy(this.destination); + + final Iterable result = + newCustomerRead() + .select(Customer.CUSTOMER_ID) + .withPreferredPageSize(20) + .iteratingEntities() + .executeRequest(destination); + + verify(destination, times(1)).getHeaders(argThat(URI_WITHOUT_SKIP_TOKEN)); + verify(destination, never()).getHeaders(argThat(URL_WITH_SKIP_TOKEN)); + reset(destination); + + int countEntities = 0; + for( final Customer entity : result ) { + countEntities++; + assertThat(entity).extracting(Customer::getCustomerId).isNotNull(); + } + + verify(destination, never()).getHeaders(argThat(URI_WITHOUT_SKIP_TOKEN)); + verify(destination, atLeastOnce()).getHeaders(argThat(URL_WITH_SKIP_TOKEN)); + assertThat(countEntities).isGreaterThan(0); + } + + @Test + void testGetAllStreamingEntities() + { + final HttpDestination destination = spy(this.destination); + + final Stream result = + newCustomerRead() + .select(Customer.CUSTOMER_ID) + .withPreferredPageSize(20) + .streamingEntities() + .executeRequest(destination); + + verify(destination, times(1)).getHeaders(argThat(URI_WITHOUT_SKIP_TOKEN)); + verify(destination, never()).getHeaders(argThat(URL_WITH_SKIP_TOKEN)); + reset(destination); + + final Stream intermediateStream = result.map(Customer::getCustomerId).peek(Objects::requireNonNull); + verify(destination, never()).getHeaders(any()); + + final long countEntities = intermediateStream.count(); + assertThat(countEntities).isGreaterThan(0); + verify(destination, never()).getHeaders(argThat(URI_WITHOUT_SKIP_TOKEN)); + verify(destination, atLeastOnce()).getHeaders(argThat(URL_WITH_SKIP_TOKEN)); + } + + @Test + void testGetAllIteratingPages() + { + final HttpDestination destination = spy(this.destination); + + final Iterable> result = + newCustomerRead() + .select(Customer.CUSTOMER_ID) + .withPreferredPageSize(PAGE_SIZE) + .iteratingPages() + .executeRequest(destination); + + verify(destination, times(1)).getHeaders(argThat(URI_WITHOUT_SKIP_TOKEN)); + verify(destination, never()).getHeaders(argThat(URL_WITH_SKIP_TOKEN)); + reset(destination); + + int countPages = 0; + int countEntities = 0; + for( final List entities : result ) { + countPages++; + countEntities += entities.size(); + assertThat(entities).extracting(Customer::getCustomerId).allMatch(Objects::nonNull); + } + + verify(destination, never()).getHeaders(argThat(URI_WITHOUT_SKIP_TOKEN)); + verify(destination, times(countPages - 1)).getHeaders(argThat(URL_WITH_SKIP_TOKEN)); + assertThat(countEntities).isGreaterThan(0); + assertThat(countPages).isGreaterThan(1); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/PaginationUnitTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/PaginationUnitTest.java new file mode 100644 index 0000000000..181a72f2fa --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/PaginationUnitTest.java @@ -0,0 +1,232 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static com.github.tomakehurst.wiremock.client.WireMock.absent; +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.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.github.tomakehurst.wiremock.matching.UrlPattern; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataField; +import com.sap.cloud.sdk.s4hana.datamodel.odata.annotation.Key; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@WireMockTest +class PaginationUnitTest +{ + private static final int PAGE_SIZE = 20; + private static final int ENTITIES_COUNT = 91; + private static final int PAGES_COUNT = 5; + + private DefaultHttpDestination destination; + + // corresponds to https://services.odata.org/V4/Northwind/Northwind.svc/Customers?$select=CustomerID + final String page1 = + "{ \"d\" : { \"results\": [ { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ALFKI')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"ALFKI\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ANATR')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"ANATR\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ANTON')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"ANTON\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('AROUT')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"AROUT\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('BERGS')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"BERGS\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('BLAUS')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"BLAUS\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('BLONP')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"BLONP\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('BOLID')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"BOLID\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('BONAP')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"BONAP\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('BOTTM')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"BOTTM\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('BSBEV')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"BSBEV\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('CACTU')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"CACTU\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('CENTC')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"CENTC\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('CHOPS')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"CHOPS\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('COMMI')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"COMMI\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('CONSH')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"CONSH\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('DRACD')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"DRACD\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('DUMON')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"DUMON\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('EASTC')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"EASTC\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ERNSH')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"ERNSH\" } ], \"__next\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers?$select=CustomerID&$skiptoken='ERNSH'\" } }"; + final String page2 = + "{ \"d\" : { \"results\": [ { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('FAMIA')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"FAMIA\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('FISSA')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"FISSA\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('FOLIG')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"FOLIG\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('FOLKO')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"FOLKO\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('FRANK')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"FRANK\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('FRANR')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"FRANR\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('FRANS')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"FRANS\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('FURIB')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"FURIB\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('GALED')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"GALED\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('GODOS')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"GODOS\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('GOURL')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"GOURL\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('GREAL')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"GREAL\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('GROSR')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"GROSR\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('HANAR')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"HANAR\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('HILAA')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"HILAA\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('HUNGC')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"HUNGC\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('HUNGO')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"HUNGO\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ISLAT')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"ISLAT\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('KOENE')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"KOENE\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('LACOR')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"LACOR\" } ], \"__next\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers?$select=CustomerID&$skiptoken='LACOR'\" } }"; + final String page3 = + "{ \"d\" : { \"results\": [ { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('LAMAI')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"LAMAI\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('LAUGB')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"LAUGB\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('LAZYK')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"LAZYK\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('LEHMS')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"LEHMS\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('LETSS')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"LETSS\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('LILAS')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"LILAS\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('LINOD')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"LINOD\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('LONEP')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"LONEP\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('MAGAA')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"MAGAA\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('MAISD')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"MAISD\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('MEREP')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"MEREP\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('MORGK')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"MORGK\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('NORTS')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"NORTS\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('OCEAN')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"OCEAN\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('OLDWO')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"OLDWO\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('OTTIK')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"OTTIK\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('PARIS')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"PARIS\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('PERIC')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"PERIC\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('PICCO')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"PICCO\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('PRINI')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"PRINI\" } ], \"__next\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers?$select=CustomerID&$skiptoken='PRINI'\" } }"; + final String page4 = + "{ \"d\" : { \"results\": [ { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('QUEDE')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"QUEDE\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('QUEEN')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"QUEEN\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('QUICK')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"QUICK\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('RANCH')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"RANCH\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('RATTC')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"RATTC\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('REGGC')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"REGGC\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('RICAR')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"RICAR\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('RICSU')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"RICSU\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('ROMEY')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"ROMEY\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('SANTG')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"SANTG\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('SAVEA')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"SAVEA\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('SEVES')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"SEVES\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('SIMOB')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"SIMOB\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('SPECD')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"SPECD\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('SPLIR')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"SPLIR\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('SUPRD')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"SUPRD\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('THEBI')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"THEBI\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('THECR')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"THECR\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('TOMSP')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"TOMSP\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('TORTU')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"TORTU\" } ], \"__next\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers?$select=CustomerID&$skiptoken='TORTU'\" } }"; + final String page5 = + "{ \"d\" : { \"results\": [ { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('TRADH')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"TRADH\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('TRAIH')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"TRAIH\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('VAFFE')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"VAFFE\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('VICTE')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"VICTE\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('VINET')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"VINET\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('WANDK')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"WANDK\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('WARTH')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"WARTH\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('WELLI')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"WELLI\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('WHITC')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"WHITC\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('WILMK')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"WILMK\" }, { \"__metadata\": { \"uri\": \"https://services.odata.org/V2/Northwind/Northwind.svc/Customers('WOLZA')\", \"type\": \"NorthwindModel.Customer\" }, \"CustomerID\": \"WOLZA\" } ] } }"; + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + stubFor( + get(UrlPattern.ANY) + .withHeader("Prefer", equalTo("odata.maxpagesize=20")) + .withQueryParam("$skiptoken", absent()) + .willReturn(okJson(page1))); + stubFor( + get(UrlPattern.ANY) + .withHeader("Prefer", equalTo("odata.maxpagesize=20")) + .withQueryParam("$skiptoken", equalTo("'ERNSH'")) + .willReturn(okJson(page2))); + stubFor( + get(UrlPattern.ANY) + .withHeader("Prefer", equalTo("odata.maxpagesize=20")) + .withQueryParam("$skiptoken", equalTo("'LACOR'")) + .willReturn(okJson(page3))); + stubFor( + get(UrlPattern.ANY) + .withHeader("Prefer", equalTo("odata.maxpagesize=20")) + .withQueryParam("$skiptoken", equalTo("'PRINI'")) + .willReturn(okJson(page4))); + stubFor( + get(UrlPattern.ANY) + .withHeader("Prefer", equalTo("odata.maxpagesize=20")) + .withQueryParam("$skiptoken", equalTo("'TORTU'")) + .willReturn(okJson(page5))); + + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + } + + @Test + void testGetAll() + { + final List result = + newCustomerRead().select(Customer.CUSTOMER_ID).withPreferredPageSize(PAGE_SIZE).executeRequest(destination); + + verify(PAGES_COUNT, getRequestedFor(UrlPattern.ANY)); + + Assertions + .assertThat(result) + .hasSize(ENTITIES_COUNT) + .extracting(Customer::getCustomerId) + .allMatch(Objects::nonNull); + } + + @Test + void testGetAllIteratingEntities() + { + final Iterable result = + newCustomerRead() + .select(Customer.CUSTOMER_ID) + .withPreferredPageSize(PAGE_SIZE) + .iteratingEntities() + .executeRequest(destination); + + verify(1, getRequestedFor(UrlPattern.ANY)); + + int countPages = 0; + int countEntities = 0; + for( final Customer entity : result ) { + if( countEntities++ % PAGE_SIZE == 0 ) { + verify(++countPages, getRequestedFor(UrlPattern.ANY)); + } + assertThat(entity).extracting(Customer::getCustomerId).isNotNull(); + } + + assertThat(countEntities).isEqualTo(ENTITIES_COUNT); + verify(PAGES_COUNT, getRequestedFor(UrlPattern.ANY)); + } + + @Test + void testGetAllStreamingEntities() + { + final Stream result = + newCustomerRead() + .select(Customer.CUSTOMER_ID) + .withPreferredPageSize(PAGE_SIZE) + .streamingEntities() + .executeRequest(destination); + + verify(1, getRequestedFor(UrlPattern.ANY)); + + final Stream intermediateStream = result.map(Customer::getCustomerId).peek(Objects::requireNonNull); + verify(1, getRequestedFor(UrlPattern.ANY)); + + final long countEntities = intermediateStream.count(); + assertThat(countEntities).isEqualTo(ENTITIES_COUNT); + verify(PAGES_COUNT, getRequestedFor(UrlPattern.ANY)); + + // repeated access to stream is not permitted as of Java API + assertThatIllegalStateException() + .isThrownBy(result::count) + .withMessage("stream has already been operated upon or closed"); + } + + @Test + void testGetAllIteratingPages() + { + final Iterable> result = + newCustomerRead() + .select(Customer.CUSTOMER_ID) + .withPreferredPageSize(PAGE_SIZE) + .iteratingPages() + .executeRequest(destination); + + verify(1, getRequestedFor(UrlPattern.ANY)); + + int countPages = 0; + int countEntities = 0; + for( final List entities : result ) { + verify(++countPages, getRequestedFor(UrlPattern.ANY)); + countEntities += entities.size(); + Assertions.assertThat(entities).extracting(Customer::getCustomerId).allMatch(Objects::nonNull); + } + assertThat(countEntities).isEqualTo(ENTITIES_COUNT); + } + + @Builder + @Data + @NoArgsConstructor + @AllArgsConstructor + @ToString( doNotUseGetters = true, callSuper = true ) + @EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataVdmEntityAdapterFactory.class ) + public static class Customer extends VdmEntity + { + private static final String SERVICE_PATH = "/V2/Northwind/Northwind.svc"; + + @Getter + final String entityCollection = "Customers"; + + @Getter + private final String defaultServicePath = SERVICE_PATH; + + @Getter + private final Class type = + com.sap.cloud.sdk.datamodel.odata.helper.TestVdmEntity.class; + + @Key + @SerializedName( "CustomerID" ) + @JsonProperty( "CustomerID" ) + @Nullable + @ODataField( odataName = "CustomerID" ) + private String customerId; + + public final static CustomerField CUSTOMER_ID = new CustomerField("CustomerID"); + } + + public static class CustomerField extends EntityField implements CustomerSelectable + { + public CustomerField( final String fieldName ) + { + super(fieldName); + } + } + + public interface CustomerSelectable extends EntitySelectable + { + } + + static < + T extends FluentHelperRead> + FluentHelperRead + newCustomerRead() + { + return FluentHelperFactory.withServicePath(Customer.SERVICE_PATH).read(Customer.class, "Customers"); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/SerializerRaceConditionTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/SerializerRaceConditionTest.java new file mode 100644 index 0000000000..c3cdcac038 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/SerializerRaceConditionTest.java @@ -0,0 +1,46 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Collection; +import java.util.Collections; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import org.junit.jupiter.api.Test; + +import lombok.SneakyThrows; + +class SerializerRaceConditionTest +{ + @SneakyThrows + @Test + void testRaceCondition() + { + final TestVdmEntity entity = new TestVdmEntity(); + entity.setStringValue("string"); + entity.setDoubleValue(13.37); + entity.setBooleanValue(true); + entity.setIntegerValue(42); + entity.setDecimalValue(BigDecimal.ONE); + entity.setLocalDateTimeValue(LocalDateTime.of(2000, 1, 1, 0, 0)); + entity.setOffsetDateTimeValue(ZonedDateTime.of(2000, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC)); + + final String EXPECTED_JSON = + "{\"IntegerValue\":42,\"StringValue\":\"string\",\"OffsetDateTimeValue\":\"/Date(946684800000)/\",\"DecimalValue\":\"1\",\"DoubleValue\":\"13.37\",\"LocalDateTimeValue\":\"/Date(946684800000)/\",\"BooleanValue\":true}"; + + final Collection> tasks = + Collections.nCopies(10_000, () -> ODataEntitySerializer.serializeEntityForCreate(entity)); + + final ExecutorService executor = Executors.newCachedThreadPool(); + for( final Future future : executor.invokeAll(tasks) ) { + assertThat(future.get()).isEqualTo(EXPECTED_JSON); + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ServicePathTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ServicePathTest.java new file mode 100644 index 0000000000..f53cb2406d --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/ServicePathTest.java @@ -0,0 +1,433 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static java.lang.String.format; + +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.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.okForContentType; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.patch; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +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.urlPathMatching; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.google.common.collect.ImmutableMap; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataField; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataVdmEntityAdapterFactory; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/* + * SERVICE A: + * GET /FunctionToMedia(Param1,Param2) -> multiple entities from A_MediaType + * GET /A_MediaEntitySet(Id)/$value -> media lookup + * + * SERVICE B: + * GET /A_EntitySet -> multiple entities from A_EntityType + * GET /A_EntitySet(Id) -> single entity from A_EntityType + * GET /A_EntitySet(Id)/to_RelatedEntity -> single entity from A_RelatedType + * GET /A_EntitySet(Id)/to_RelatedEntity/to_TransitiveEntity -> multiple entities from A_TransitiveType + * GET /A_RelatedEntitySet(Id) -> single entity from A_RelatedType + * GET /A_RelatedEntitySet(Id)/to_TransitiveEntity -> multiple entities from A_TransitiveType + */ +@WireMockTest +class ServicePathTest +{ + private static final String SERVICE_A = "/path/to/serviceA"; + private static final String SERVICE_B = "/path/to/serviceB"; + + private HttpDestination destination; + + @BeforeEach + void setupDestination( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + stubFor(head(urlEqualTo(SERVICE_A)).willReturn(ok())); + stubFor(head(urlEqualTo(SERVICE_B)).willReturn(ok())); + } + + @Test + void testChildEntityFromGet() + { + final String responseEntityListWithRelated = + createPayloadMultiple(createTestEntity("72", createRelatedEntity("13857", null))); + stubFor(get(urlPathMatching(SERVICE_B + "/A_EntitySet")).willReturn(okJson(responseEntityListWithRelated))); + + final String responseEntityListTransitive = createPayloadMultiple(createTransitiveEntity("Bar")); + stubFor( + get(urlPathMatching(SERVICE_B + "/A_RelatedEntitySet\\('(.*)'\\)/to_TransitiveEntity")) + .willReturn(okJson(responseEntityListTransitive))); + + final List testEntitys = + FluentHelperFactory + .withServicePath(SERVICE_B) + .read(TestEntity.class, "A_EntitySet") + .select(TestEntity.TO_RELATED_ENTITY) + .executeRequest(destination); + + final RelatedEntity bpRelatedEntity = testEntitys.get(0).getRelatedEntity(); + assertThat(bpRelatedEntity).isNotNull(); + + final List bpTransitiveEntity = bpRelatedEntity.fetchTransitiveEntity(); + assertThat(bpTransitiveEntity).isNotNull(); + + verify(getRequestedFor(urlPathMatching(SERVICE_B + "/A_RelatedEntitySet\\('(.*)'\\)/to_TransitiveEntity"))); + } + + @Test + void testChildEntityFromGetByKey() + { + final String resonseEntityWithRelated = + createPayloadSingle(createTestEntity("72", createRelatedEntity("13857", null))); + stubFor( + get(urlPathMatching(SERVICE_B + "/A_EntitySet\\('(.*)'\\)")).willReturn(okJson(resonseEntityWithRelated))); + + final String responseEntityListTransitive = createPayloadMultiple(createTransitiveEntity("Bar")); + stubFor( + get(urlPathMatching(SERVICE_B + "/A_RelatedEntitySet\\('(.*)'\\)/to_TransitiveEntity")) + .willReturn(okJson(responseEntityListTransitive))); + + final TestEntity testEntity = + FluentHelperFactory + .withServicePath(SERVICE_B) + .readByKey(TestEntity.class, "A_EntitySet", Collections.singletonMap("EntityId", "72")) + .select(TestEntity.TO_RELATED_ENTITY) + .executeRequest(destination); + + final RelatedEntity bpRelatedEntity = testEntity.getRelatedEntity(); + assertThat(bpRelatedEntity).isNotNull(); + + final List bpTransitiveEntity = bpRelatedEntity.fetchTransitiveEntity(); + assertThat(bpTransitiveEntity).isNotNull(); + + verify(getRequestedFor(urlPathMatching(SERVICE_B + "/A_RelatedEntitySet\\('(.*)'\\)/to_TransitiveEntity"))); + } + + @Test + void testChildEntityFromCreate() + { + final String resonseEntityWithRelated = + createPayloadSingle(createTestEntity("72", createRelatedEntity("13857", null))); + stubFor(post(urlPathMatching(SERVICE_B + "/A_EntitySet")).willReturn(okJson(resonseEntityWithRelated))); + + final String responseEntityListTransitive = createPayloadMultiple(createTransitiveEntity("Bar")); + stubFor( + get(urlPathMatching(SERVICE_B + "/A_RelatedEntitySet\\('(.*)'\\)/to_TransitiveEntity")) + .willReturn(okJson(responseEntityListTransitive))); + + TestEntity testEntity = new TestEntity(); + + testEntity = + FluentHelperFactory + .withServicePath(SERVICE_B) + .create("A_EntitySet", testEntity) + .executeRequest(destination) + .getModifiedEntity(); + + final RelatedEntity bpRelatedEntity = testEntity.getRelatedEntity(); + assertThat(bpRelatedEntity).isNotNull(); + + final List bpTransitiveEntity = bpRelatedEntity.fetchTransitiveEntity(); + assertThat(bpTransitiveEntity).isNotNull(); + + verify(getRequestedFor(urlPathMatching(SERVICE_B + "/A_RelatedEntitySet\\('(.*)'\\)/to_TransitiveEntity"))); + } + + @Test + void testChildEntityFromUpdate() + { + final String responseEntity = createPayloadSingle(createTestEntity("72", null)); + stubFor(get(urlPathMatching(SERVICE_B + "/A_EntitySet\\('(.*)'\\)")).willReturn(okJson(responseEntity))); + + final String responseEntityWithRelated = + createPayloadSingle(createTestEntity("72", createRelatedEntity("13857", null))); + stubFor( + patch(urlPathMatching(SERVICE_B + "/A_EntitySet\\('(.*)'\\)")) + .willReturn(okJson(responseEntityWithRelated))); + + final String responseEntityListTransitive = createPayloadMultiple(createTransitiveEntity("Bar")); + stubFor( + get(urlPathMatching(SERVICE_B + "/A_RelatedEntitySet\\('(.*)'\\)/to_TransitiveEntity")) + .willReturn(okJson(responseEntityListTransitive))); + + TestEntity testEntity = + FluentHelperFactory + .withServicePath(SERVICE_B) + .readByKey(TestEntity.class, "A_EntitySet", Collections.singletonMap("EntityId", "72")) + .executeRequest(destination); + + testEntity.setRelatedEntity(new RelatedEntity()); + + testEntity = + FluentHelperFactory + .withServicePath(SERVICE_B) + .update("A_EntitySet", testEntity) + .modifyingEntity() + .executeRequest(destination) + .getModifiedEntity(); + + final RelatedEntity bpRelatedEntity = testEntity.getRelatedEntity(); + assertThat(bpRelatedEntity).isNotNull(); + + final List bpTransitiveEntity = bpRelatedEntity.fetchTransitiveEntity(); + assertThat(bpTransitiveEntity).isNotNull(); + + verify(getRequestedFor(urlPathMatching(SERVICE_B + "/A_RelatedEntitySet\\('(.*)'\\)/to_TransitiveEntity"))); + } + + @Test + void testChildEntityFromNavigationProperty() + { + final String responseEntity = createPayloadSingle(createTestEntity("72", null)); + stubFor(get(urlPathMatching(SERVICE_B + "/A_EntitySet\\('(.*)'\\)")).willReturn(okJson(responseEntity))); + + final String responseEntityWithRelated = createPayloadSingle(createRelatedEntity("13857", null)); + stubFor( + get(urlPathMatching(SERVICE_B + "/A_EntitySet\\('(.*)'\\)/to_RelatedEntity")) + .willReturn(okJson(responseEntityWithRelated))); + + final String responseEntityListTransitive = createPayloadMultiple(createTransitiveEntity("Bar")); + stubFor( + get(urlPathMatching(SERVICE_B + "/A_RelatedEntitySet\\('(.*)'\\)/to_TransitiveEntity")) + .willReturn(okJson(responseEntityListTransitive))); + + final TestEntity testEntity = + FluentHelperFactory + .withServicePath(SERVICE_B) + .readByKey(TestEntity.class, "A_EntitySet", Collections.singletonMap("EntityId", "72")) + .executeRequest(destination); + + final RelatedEntity bpRelatedEntity = testEntity.fetchRelatedEntity(); + assertThat(bpRelatedEntity).isNotNull(); + + final List bpTransitiveEntity = bpRelatedEntity.fetchTransitiveEntity(); + assertThat(bpTransitiveEntity).isNotNull(); + + verify(getRequestedFor(urlPathMatching(SERVICE_B + "/A_RelatedEntitySet\\('(.*)'\\)/to_TransitiveEntity"))); + } + + @Test + void testChildEntityFromFunctionImport() + throws IOException + { + final String responseFunctionImport = + createPayloadMultiple(createTransitiveEntity("100"), createTransitiveEntity("101")); + stubFor( + get(urlPathMatching(SERVICE_A + "/FunctionToMedia")) + .withQueryParam("Param1", equalTo("'MARA'")) + .withQueryParam("Param2", equalTo("'SAPTEST'")) + .willReturn(okJson(responseFunctionImport))); + + stubFor( + get(urlPathMatching(SERVICE_A + "/A_MediaEntitySet\\((.*)\\)/\\$value")) + .willReturn(okForContentType("text/plain", "Test file content"))); + + final Map parameters = new LinkedHashMap<>(); + parameters.put("Param1", "MARA"); + parameters.put("Param2", "SAPTEST"); + + final List attachments = + FluentHelperFactory + .withServicePath(SERVICE_A) + .functionMultipleGet(parameters, "FunctionToMedia", MediaEntity.class) + .executeRequest(destination); + + for( final MediaEntity attachmentContent : attachments ) { + try( final InputStream attachmentMedia = attachmentContent.fetchMediaStream() ) { + assertThat(attachmentMedia).isNotNull(); + assertThat(attachmentMedia.available()).isGreaterThan(0); + } + } + } + + private static String createPayloadSingle( final String payload ) + { + return format("{\"d\":%s}", payload); + } + + private static String createPayloadMultiple( final String... payloads ) + { + return createPayloadSingle(format("{\"results\":[%s]}", String.join(",", payloads))); + } + + private static String createTestEntity( final String id, final String rel ) + { + final String uri = format("https://127.0.0.1/path/to/serviceB/A_EntitySet('%s')", id); + final String m = format("{\"id\":\"%s\",\"uri\":\"%s\",\"type\":\"ServiceB.A_EntityType\"}", uri, uri); + final String r = rel != null ? rel : format("{\"__deferred\":{\"uri\":\"%s/to_RelatedEntity\"}}", uri); + return format("{\"__metadata\":%s,\"EntityId\":\"%s\",\"to_RelatedEntity\":%s}", m, id, r); + } + + private static String createRelatedEntity( final String id, final String trn ) + { + final String uri = format("https://127.0.0.1/path/to/serviceB/A_RelatedEntitySet('%s')", id); + final String m = format("{\"id\":\"%s\",\"uri\":\"%s\",\"type\":\"ServiceB.A_RelatedType\"}", uri, uri); + final String t = trn != null ? trn : format("{\"__deferred\":{\"uri\":\"%s/to_TransitiveEntity\"}}", uri); + return format("{\"__metadata\":%s,\"RelatedId\":\"%s\",\"to_TransitiveEntity\":%s}", m, id, t); + } + + private static String createTransitiveEntity( final String id ) + { + final String uri = format("https://127.0.0.1/path/to/serviceB/A_TransitiveSet('%s')", id); + final String format = + "{\"id\":\"%s\",\"uri\":\"%s\",\"type\":\"ServiceB.A_MediaType\",\"content_type\":\"text/plain\",\"media_src\":\"%s/$value\"}"; + return format("{\"__metadata\":%s,\"MediaId\":\"%s\"}", format(format, uri, uri, uri), id); + } + + @Data + @EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) + @JsonAdapter( ODataVdmEntityAdapterFactory.class ) + public static class TestEntity extends VdmEntity + { + private final String entityCollection = "A_EntitySet"; + private final Class type = TestEntity.class; + + @SerializedName( "EntityId" ) + @JsonProperty( "EntityId" ) + @ODataField( odataName = "EntityId" ) + private String entityId; + + @SerializedName( "to_RelatedEntity" ) + @JsonProperty( "to_RelatedEntity" ) + @ODataField( odataName = "to_RelatedEntity" ) + private RelatedEntity relatedEntity; + + public static final TestEntityLink TO_RELATED_ENTITY = new TestEntityLink<>("to_RelatedEntity"); + + @Nonnull + @Override + protected Map getKey() + { + return ImmutableMap.of("EntityId", getEntityId()); + } + + @Nonnull + @Override + protected Map toMapOfNavigationProperties() + { + return Collections.singletonMap("to_RelatedEntity", relatedEntity); + } + + public RelatedEntity fetchRelatedEntity() + { + return fetchFieldAsSingle("to_RelatedEntity", RelatedEntity.class); + } + + public void setRelatedEntity( final RelatedEntity relatedEntity ) + { + rememberChangedField("to_RelatedEntity", this.relatedEntity); + this.relatedEntity = relatedEntity; + } + } + + @Data + @EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) + @JsonAdapter( ODataVdmEntityAdapterFactory.class ) + public static class RelatedEntity extends VdmEntity + { + private final String entityCollection = "A_RelatedEntitySet"; + private final Class type = RelatedEntity.class; + + @SerializedName( "RelatedId" ) + @JsonProperty( "RelatedId" ) + @ODataField( odataName = "RelatedId" ) + private String relatedId; + + @SerializedName( "to_TransitiveEntity" ) + @JsonProperty( "to_TransitiveEntity" ) + @ODataField( odataName = "to_TransitiveEntity" ) + private List transitiveEntity; + + public static final RelatedEntityLink TO_RELATED_ENTITY = + new RelatedEntityLink<>("to_TransitiveEntity"); + + @Nonnull + @Override + protected Map getKey() + { + return ImmutableMap.of("RelatedId", getRelatedId()); + } + + @Nonnull + @Override + protected Map toMapOfNavigationProperties() + { + return Collections.singletonMap("to_TransitiveEntity", transitiveEntity); + } + + public List fetchTransitiveEntity() + { + return fetchFieldAsList("to_TransitiveEntity", TransitiveEntity.class); + } + } + + @Data + @EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) + @JsonAdapter( ODataVdmEntityAdapterFactory.class ) + public static class TransitiveEntity extends VdmEntity + { + private final String entityCollection = "A_TransitiveEntitySet"; + private final Class type = TransitiveEntity.class; + } + + @Data + @EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) + @JsonAdapter( ODataVdmEntityAdapterFactory.class ) + public static class MediaEntity extends VdmMediaEntity + { + private final String entityCollection = "A_MediaEntitySet"; + private final Class type = MediaEntity.class; + + @SerializedName( "MediaId" ) + @JsonProperty( "MediaId" ) + @ODataField( odataName = "MediaId" ) + private String mediaId; + + } + + public static class TestEntityLink> + extends + EntityLink, TestEntity, ObjectT> + { + public TestEntityLink( final String name ) + { + super(name); + } + } + + public static class RelatedEntityLink> + extends + EntityLink, TestEntity, ObjectT> + { + public RelatedEntityLink( final String name ) + { + super(name); + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/SpecialCharactersTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/SpecialCharactersTest.java new file mode 100644 index 0000000000..1d93574ada --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/SpecialCharactersTest.java @@ -0,0 +1,252 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +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.ok; +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.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static com.github.tomakehurst.wiremock.matching.RequestPatternBuilder.allRequests; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; + +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.google.common.collect.ImmutableMap; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@WireMockTest +class SpecialCharactersTest +{ + private static final String SERVICE_URL = "/service/path"; + + private DefaultHttpDestination destination; + + @BeforeEach + void before( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + } + + @Data + @EqualsAndHashCode( callSuper = true ) + public static class Ticket1 extends VdmEntity + { + private final String entityCollection = "Tickets"; + private final Class type = Ticket1.class; + private String keyVal1 = "Val1"; + @Getter( lazy = true ) + private final Map key = ImmutableMap.of("Key1", keyVal1); + } + + @Data + @EqualsAndHashCode( callSuper = true ) + public static class Ticket2 extends VdmEntity + { + private final String entityCollection = "Tickets"; + private final Class type = Ticket2.class; + private String keyVal1 = "Val1"; + private String keyVal2 = "Val2"; + @Getter( lazy = true ) + private final Map key = ImmutableMap.of("Key1", keyVal1, "Key2", keyVal2); + } + + @Test + void testGetByKey() + { + final String keyVal1 = "F?oo"; + final String keyVal2 = "A/ #"; + final String ticketsUrl = SERVICE_URL + "/Tickets(Key1='F%3Foo',Key2='A%2F%20%23')"; + stubFor(get(urlEqualTo(ticketsUrl)).willReturn(okJson("{\"d\":{\"Foo\":\"0\"}}"))); + + final Map key = ImmutableMap.of("Key1", keyVal1, "Key2", keyVal2); + final Ticket2 item = + FluentHelperFactory + .withServicePath(SERVICE_URL) + .readByKey(Ticket2.class, "Tickets", key) + .executeRequest(destination); + + assertThat(item).isNotNull(); + verify(1, allRequests()); + verify(1, getRequestedFor(urlEqualTo(ticketsUrl))); + } + + @Test + void testGetNavigationPropertyByKey() + { + final String ticketsUrl = SERVICE_URL + "/Tickets(Key1='s4L3s%2F%200rd3%3Fr',Key2='1t3%23m')/to_NextTicket"; + + stubFor(get(urlEqualTo(ticketsUrl)).willReturn(okJson("{\"d\":{\"\":\"\"}}"))); + + final Ticket2 item = new Ticket2(); + item.setKeyVal1("s4L3s/ 0rd3?r"); + item.setKeyVal2("1t3#m"); + + item.setDestinationForFetch(destination); + item.setServicePathForFetch(SERVICE_URL); + final Ticket2 nextTicket = item.fetchFieldAsSingle("to_NextTicket", Ticket2.class); + + assertThat(nextTicket).isNotNull(); + assertThat(nextTicket.getDestinationForFetch()).isEqualTo(destination); + verify(1, allRequests()); + verify(1, getRequestedFor(urlEqualTo(ticketsUrl))); + } + + @Test + void testFilterExpression() + { + final String field1Val = "&?:/"; + final String field2Val = "#/\\"; + + final String ticketsUrl = + SERVICE_URL + "/Tickets?$filter=(Field1%20eq%20'%26%3F:/')%20and%20(Field2%20eq%20'%23/%5C')"; + + stubFor(get(urlEqualTo(ticketsUrl)).willReturn(okJson("{\"d\":{\"results\":[{\"Foo\":\"0\"}]}}"))); + + final EntityField field1 = new EntityField<>("Field1"); + final EntityField field2 = new EntityField<>("Field2"); + final Ticket2 item = + FluentHelperFactory + .withServicePath(SERVICE_URL) + .read(Ticket2.class, "Tickets") + .filter(field1.eq(field1Val).and(field2.eq(field2Val))) + .executeRequest(destination) + .get(0); + + assertThat(item).isNotNull(); + verify(1, allRequests()); + verify(1, getRequestedFor(urlEqualTo(ticketsUrl))); + } + + @Test + void testEscapedNestedSingleQuote() + { + // test escaping for get by filter + { + final String getByFilter = "/Tickets?$filter=Field1%20eq%20'foo''bar'"; + stubFor( + get(urlEqualTo(SERVICE_URL + getByFilter)) + .willReturn(okJson("{\"d\":{\"results\":[{\"Foo\":\"foo'bar\"}]}}"))); + + final EntityField field1 = new EntityField<>("Field1"); + final Ticket1 item = + FluentHelperFactory + .withServicePath(SERVICE_URL) + .read(Ticket1.class, "Tickets") + .filter(field1.eq("foo'bar")) + .executeRequest(destination) + .get(0); + assertThat(item).isNotNull(); + verify(1, getRequestedFor(urlEqualTo(SERVICE_URL + getByFilter))); + } + + // test escaping for get by key + { + final String getByKey = "/Tickets('foo''bar')"; + stubFor(get(urlEqualTo(SERVICE_URL + getByKey)).willReturn(okJson("{\"d\":{\"Foo\":\"foo'bar\"}}"))); + + final Map key = ImmutableMap.of("Field1", "foo'bar"); + final Ticket1 item = + FluentHelperFactory + .withServicePath(SERVICE_URL) + .readByKey(Ticket1.class, "Tickets", key) + .executeRequest(destination); + assertThat(item).isNotNull(); + verify(1, getRequestedFor(urlEqualTo(SERVICE_URL + getByKey))); + } + + // test escaping for lazily fetching a navigation property + { + final String getByFilter = "/Tickets('foo''bar')/to_NextTicket"; + stubFor( + get(urlEqualTo(SERVICE_URL + getByFilter)) + .willReturn(okJson("{\"d\":{\"results\":[{\"Foo\":\"0\"}]}}"))); + final Ticket1 item = new Ticket1(); + item.setKeyVal1("foo'bar"); + + item.setDestinationForFetch(destination); + item.setServicePathForFetch(SERVICE_URL); + final Ticket1 nextTicket = item.fetchFieldAsSingle("to_NextTicket", Ticket1.class); + + assertThat(nextTicket).isNotNull(); + assertThat(nextTicket.getDestinationForFetch()).isEqualTo(destination); + verify(1, getRequestedFor(urlEqualTo(SERVICE_URL + getByFilter))); + } + + } + + @Test + void testEscapedOnlySingleQuote() + { + final String oderKey = "'"; + final String itemKey = "'"; + stubFor(head(urlEqualTo(SERVICE_URL)).willReturn(ok())); + + // test escaping for get by filter + { + final String getByFilter = "/Tickets?$filter=(Field1%20eq%20'''')%20and%20(Field2%20eq%20'''')"; + stubFor( + get(urlEqualTo(SERVICE_URL + getByFilter)) + .willReturn(okJson("{\"d\":{\"results\":[{\"Foo\":\"0\"}]}}"))); + + final EntityField field1 = new EntityField<>("Field1"); + final EntityField field2 = new EntityField<>("Field2"); + final Ticket2 item = + FluentHelperFactory + .withServicePath(SERVICE_URL) + .read(Ticket2.class, "Tickets") + .filter(field1.eq(oderKey).and(field2.eq(itemKey))) + .executeRequest(destination) + .get(0); + + assertThat(item).isNotNull(); + verify(1, getRequestedFor(urlEqualTo(SERVICE_URL + getByFilter))); + } + + // test escaping for lazily fetching a navigation property + { + final String getByFilter = "/Tickets(Key1='''',Key2='''')/to_NextTicket"; + stubFor( + get(urlEqualTo(SERVICE_URL + getByFilter)) + .willReturn(okJson("{\"d\":{\"results\":[{\"Foo\":\"0\"}]}}"))); + final Ticket2 item = new Ticket2(); + item.setKeyVal1(oderKey); + item.setKeyVal2(itemKey); + + item.setDestinationForFetch(destination); + item.setServicePathForFetch(SERVICE_URL); + final Ticket2 nextTicket = item.fetchFieldAsSingle("to_NextTicket", Ticket2.class); + + assertThat(nextTicket).isNotNull(); + assertThat(nextTicket.getDestinationForFetch()).isEqualTo(destination); + verify(1, getRequestedFor(urlEqualTo(SERVICE_URL + getByFilter))); + } + + // test escaping for get by key + { + final String getByKey = "/Tickets(Key1='''',Key2='''')"; + stubFor(get(urlEqualTo(SERVICE_URL + getByKey)).willReturn(okJson("{\"d\":{\"Foo\":\"0\"}}"))); + + final Map key = ImmutableMap.of("Key1", oderKey, "Key2", itemKey); + final Ticket2 item = + FluentHelperFactory + .withServicePath(SERVICE_URL) + .readByKey(Ticket2.class, "Tickets", key) + .executeRequest(destination); + assertThat(item).isNotNull(); + verify(getRequestedFor(urlEqualTo(SERVICE_URL + getByKey))); + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/TestVdmComplex.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/TestVdmComplex.java new file mode 100644 index 0000000000..1169888fc1 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/TestVdmComplex.java @@ -0,0 +1,79 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataField; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@ToString( doNotUseGetters = true, callSuper = true ) +@EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) +@JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataVdmEntityAdapterFactory.class ) +public class TestVdmComplex extends VdmComplex +{ + @SerializedName( "SomeValue" ) + @JsonProperty( "SomeValue" ) + @Nullable + @ODataField( odataName = "SomeValue" ) + private String someValue; + + @SerializedName( "OtherValue" ) + @JsonProperty( "OtherValue" ) + @Nullable + @ODataField( odataName = "OtherValue" ) + private String otherValue; + + @SerializedName( "ComplexValue" ) + @JsonProperty( "ComplexValue" ) + @Nullable + @ODataField( odataName = "ComplexValue" ) + private TestVdmComplex complexValue; + + @Getter + private final Class type = TestVdmComplex.class; + + @Nonnull + @Override + protected Map toMapOfFields() + { + final Map values = super.toMapOfFields(); + values.put("SomeValue", getSomeValue()); + values.put("OtherValue", getOtherValue()); + values.put("ComplexValue", getComplexValue()); + return values; + } + + public void setSomeValue( String value ) + { + rememberChangedField("SomeValue", someValue); + someValue = value; + } + + public void setOtherValue( String value ) + { + rememberChangedField("OtherValue", otherValue); + otherValue = value; + } + + public void setComplexValue( TestVdmComplex value ) + { + rememberChangedField("ComplexValue", complexValue); + complexValue = value; + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/TestVdmEntity.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/TestVdmEntity.java new file mode 100644 index 0000000000..fc79b7fa18 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/TestVdmEntity.java @@ -0,0 +1,298 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZonedDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.common.collect.Lists; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataField; +import com.sap.cloud.sdk.s4hana.datamodel.odata.annotation.Key; + +import io.vavr.control.Option; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@ToString( doNotUseGetters = true, callSuper = true ) +@EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) +@JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataVdmEntityAdapterFactory.class ) +public class TestVdmEntity extends VdmEntity +{ + @Getter + final String entityCollection = "Entities"; + + @Getter + private final String defaultServicePath = "/service"; + + @Getter + private final Class type = TestVdmEntity.class; + + @Key + @SerializedName( "IntegerValue" ) + @JsonProperty( "IntegerValue" ) + @Nullable + @ODataField( odataName = "IntegerValue" ) + private Integer integerValue; + + @SerializedName( "GuidValue" ) + @JsonProperty( "GuidValue" ) + @Nullable + @ODataField( odataName = "GuidValue" ) + private UUID guidValue; + + @SerializedName( "StringValue" ) + @JsonProperty( "StringValue" ) + @Nullable + @ODataField( odataName = "StringValue" ) + private String stringValue; + + @SerializedName( "OffsetDateTimeValue" ) + @JsonProperty( "OffsetDateTimeValue" ) + @Nullable + @JsonSerialize( using = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.JacksonZonedDateTimeSerializer.class ) + @JsonDeserialize( using = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.JacksonZonedDateTimeDeserializer.class ) + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ZonedDateTimeAdapter.class ) + @ODataField( + odataName = "OffsetDateTimeValue", + converter = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ZonedDateTimeCalendarConverter.class ) + private ZonedDateTime offsetDateTimeValue; + + @SerializedName( "to_Parent" ) + @JsonProperty( "to_Parent" ) + @ODataField( odataName = "to_Parent" ) + @Nullable + @Getter( AccessLevel.NONE ) + @Setter( AccessLevel.NONE ) + private TestVdmEntity toParent; + + @SerializedName( "to_Children" ) + @JsonProperty( "to_Children" ) + @ODataField( odataName = "to_Children" ) + @Getter( AccessLevel.NONE ) + @Setter( AccessLevel.NONE ) + private List toChildren; + + @SerializedName( "DecimalValue" ) + @JsonProperty( "DecimalValue" ) + @Nullable + @ODataField( odataName = "DecimalValue" ) + private BigDecimal decimalValue; + + @SerializedName( "DoubleValue" ) + @JsonProperty( "DoubleValue" ) + @Nullable + @ODataField( odataName = "DoubleValue" ) + private Double doubleValue; + + @SerializedName( "LocalTimeValue" ) + @JsonProperty( "LocalTimeValue" ) + @Nullable + @JsonSerialize( using = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.JacksonLocalTimeSerializer.class ) + @JsonDeserialize( using = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.JacksonLocalTimeDeserializer.class ) + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.LocalTimeAdapter.class ) + @ODataField( + odataName = "LocalTimeValue", + converter = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.LocalTimeCalendarConverter.class ) + private LocalTime localTimeValue; + + @SerializedName( "LocalDateTimeValue" ) + @JsonProperty( "LocalDateTimeValue" ) + @Nullable + @JsonSerialize( using = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.JacksonLocalDateTimeSerializer.class ) + @JsonDeserialize( using = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.JacksonLocalDateTimeDeserializer.class ) + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.LocalDateTimeAdapter.class ) + @ODataField( + odataName = "LocalDateTimeValue", + converter = com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.LocalDateTimeCalendarConverter.class ) + private LocalDateTime localDateTimeValue; + + @SerializedName( "BooleanValue" ) + @JsonProperty( "BooleanValue" ) + @Nullable + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataBooleanAdapter.class ) + @ODataField( odataName = "BooleanValue" ) + private Boolean booleanValue; + + @SerializedName( "ComplexValue" ) + @JsonProperty( "ComplexValue" ) + @Nullable + @ODataField( odataName = "ComplexValue" ) + private TestVdmComplex complexValue; + + public void setIntegerValue( @Nullable final Integer integerValue ) + { + rememberChangedField("IntegerValue", this.integerValue); + this.integerValue = integerValue; + } + + public void setBooleanValue( @Nullable final Boolean booleanValue ) + { + rememberChangedField("BooleanValue", this.booleanValue); + this.booleanValue = booleanValue; + } + + public void setGuidValue( @Nullable final UUID guidValue ) + { + rememberChangedField("GuidValue", this.guidValue); + this.guidValue = guidValue; + } + + public void setStringValue( @Nullable final String stringValue ) + { + rememberChangedField("StringValue", this.stringValue); + this.stringValue = stringValue; + } + + public void setDecimalValue( @Nullable final BigDecimal decimalValue ) + { + rememberChangedField("DecimalValue", this.decimalValue); + this.decimalValue = decimalValue; + } + + public void setDoubleValue( @Nullable final Double doubleValue ) + { + rememberChangedField("DoubleValue", this.doubleValue); + this.doubleValue = doubleValue; + } + + public void setOffsetDateTimeValue( @Nullable final ZonedDateTime offsetDateTimeValue ) + { + rememberChangedField("OffsetDateTimeValue", this.offsetDateTimeValue); + this.offsetDateTimeValue = offsetDateTimeValue; + } + + public void setLocalTimeValue( @Nullable final LocalTime localTimeValue ) + { + rememberChangedField("LocalTimeValue", this.localTimeValue); + this.localTimeValue = localTimeValue; + } + + public void setLocalDateTimeValue( @Nullable final LocalDateTime localDateTimeValue ) + { + rememberChangedField("LocalDateTimeValue", this.localDateTimeValue); + this.localDateTimeValue = localDateTimeValue; + } + + public void setComplexValue( @Nullable final TestVdmComplex complexValue ) + { + rememberChangedField("ComplexValue", this.complexValue); + this.complexValue = complexValue; + } + + @Nonnull + @Override + protected Map toMapOfFields() + { + final Map values = super.toMapOfFields(); + values.put("IntegerValue", getIntegerValue()); + values.put("BooleanValue", getBooleanValue()); + values.put("StringValue", getStringValue()); + values.put("DecimalValue", getDecimalValue()); + values.put("DoubleValue", getDoubleValue()); + values.put("GuidValue", getGuidValue()); + values.put("OffsetDateTimeValue", getOffsetDateTimeValue()); + values.put("LocalDateTimeValue", getLocalDateTimeValue()); + values.put("LocalTimeValue", getLocalTimeValue()); + values.put("ComplexValue", getComplexValue()); + return values; + } + + @Nullable + public List fetchToChildren() + { + // not implemented here + return null; + } + + @Nonnull + public List getToChildrenOrFetch() + { + if( toChildren == null ) { + toChildren = fetchToChildren(); + } + return toChildren; + } + + @Nonnull + public Option> getToChildrenIfPresent() + { + return Option.of(toChildren); + } + + public void setToChildren( @Nonnull final List value ) + { + // rememberChangedField("to_Children", toChildren); + if( toChildren == null ) { + toChildren = Lists.newArrayList(); + } + toChildren.clear(); + toChildren.addAll(value); + } + + public void addToChildren( TestVdmEntity... entity ) + { + // rememberChangedField("to_Children", toChildren); + if( toChildren == null ) { + toChildren = Lists.newArrayList(); + } + toChildren.addAll(Lists.newArrayList(entity)); + } + + @Nullable + public TestVdmEntity fetchToParent() + { + // not implemented here + return null; + } + + @Nonnull + public TestVdmEntity getToParentOrFetch() + { + if( toParent == null ) { + toParent = fetchToParent(); + } + return toParent; + } + + @Nonnull + public Option getToParentIfPresent() + { + return Option.of(toParent); + } + + public void setToParent( @Nonnull final TestVdmEntity value ) + { + // rememberChangedField("to_Children", toChildren); + toParent = value; + } + + @Nonnull + @Override + protected Map getKey() + { + return Collections.singletonMap("IntegerValue", integerValue); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/UncheckedFilterExpressionTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/UncheckedFilterExpressionTest.java new file mode 100644 index 0000000000..6b8d4db145 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/UncheckedFilterExpressionTest.java @@ -0,0 +1,86 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static org.assertj.core.api.Assertions.assertThat; + +import javax.annotation.Nonnull; + +import org.junit.jupiter.api.Test; + +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.ValueBoolean; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead; + +import lombok.Getter; + +class UncheckedFilterExpressionTest +{ + @Test + void testClientFilterExpressionEmpty() + { + final ValueBoolean filterExpression = ValueBoolean.literal(true); + final ExpressionFluentHelper customFilterExpression = new ExpressionFluentHelper<>(filterExpression); + + assertThat( + customFilterExpression.getDelegateExpressionWithoutOuterParentheses().getExpression(ODataProtocol.V2)) + .isEqualTo("true"); + } + + @Test + void testClientFilterExpressionInteger() + { + final ValueBoolean filterExpression = FieldReference.of("ShoeSize").equalTo(42); + final ExpressionFluentHelper customFilterExpression = new ExpressionFluentHelper<>(filterExpression); + + assertThat( + customFilterExpression.getDelegateExpressionWithoutOuterParentheses().getExpression(ODataProtocol.V2)) + .isEqualTo("ShoeSize eq 42"); + + final ODataRequestRead read = newFluentHelperRead().filter(customFilterExpression).toRequest(); + assertThat(read.getRelativeUri()).hasQuery("$filter=ShoeSize eq 42"); + } + + @Test + void testClientFilterExpressionString() + { + final ValueBoolean filterExpression = FieldReference.of("FirstName").equalTo("Alice"); + final ExpressionFluentHelper customFilterExpression = new ExpressionFluentHelper<>(filterExpression); + + assertThat( + customFilterExpression.getDelegateExpressionWithoutOuterParentheses().getExpression(ODataProtocol.V2)) + .isEqualTo("FirstName eq 'Alice'"); + + final ODataRequestRead read = newFluentHelperRead().filter(customFilterExpression).toRequest(); + assertThat(read.getRelativeUri()).hasQuery("$filter=FirstName eq 'Alice'"); + } + + // test helper classes + + private static < + T extends FluentHelperRead> + FluentHelperRead + newFluentHelperRead() + { + return FluentHelperFactory.withServicePath("some/path").read(MyEntity.class, "MyEntityCollection"); + } + + static class MyEntity extends VdmEntity + { + @Getter + private final String entityCollection = "MyEntityCollection"; + + @Getter + private final String defaultServicePath = "API_MY_ENTITY"; + + @Nonnull + @Override + public Class getType() + { + return MyEntity.class; + } + } + + static class MySelectable + { + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmComplexTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmComplexTest.java new file mode 100644 index 0000000000..0872b98ede --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmComplexTest.java @@ -0,0 +1,96 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +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.equalToJson; +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.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +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.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; + +@WireMockTest +class VdmComplexTest +{ + private static final String RESPONSE_CREATE_ENTITY = """ + {"d":{ + "__metadata": { + "id": "https://127.0.0.1/path/to/service(100)", + "uri": "https://127.0.0.1/path/to/service(100)", + "type": "SERVICE.SomeEntity" + }, + "IntegerValue":100, + "StringValue":"Foo", + "ComplexValue":{ + "__metadata": { + "type": "SERVICE.SomeComplex" + }, + "SomeValue":"Some", + "OtherValue":"Another" + } + }} + """; + + private static final String REQUEST_CREATE_ENTITY = """ + { + "StringValue" : "Foo", + "ComplexValue" : { + "SomeValue" : "Some", + "OtherValue" : "Another" + } + } + """; + + private HttpDestination destination; + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + } + + @Test + void testCreateEntityWithComplexProperty() + { + stubFor(head(anyUrl()).willReturn(noContent())); + stubFor(post(anyUrl()).willReturn(okJson(RESPONSE_CREATE_ENTITY))); + + final TestVdmComplex complex = new TestVdmComplex(); + complex.setSomeValue("Some"); + complex.setOtherValue("Another"); + + final TestVdmEntity entity = new TestVdmEntity(); + entity.setStringValue("Foo"); + entity.setComplexValue(complex); + + final TestVdmEntity resultEntity = + FluentHelperFactory + .withServicePath("/path/to/service") + .create(entity.getEntityCollection(), entity) + .executeRequest(destination) + .getModifiedEntity(); + + assertThat(resultEntity).isNotNull().isNotEqualTo(entity); + assertThat(resultEntity.getComplexValue()).isEqualTo(complex); + + verify(headRequestedFor(urlPathEqualTo("/path/to/service/")).withHeader("x-csrf-token", equalTo("fetch"))); + verify( + postRequestedFor(urlPathEqualTo("/path/to/service/Entities")) + .withRequestBody(equalToJson(REQUEST_CREATE_ENTITY))); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntityTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntityTest.java new file mode 100644 index 0000000000..9ad5073129 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntityTest.java @@ -0,0 +1,162 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static org.assertj.core.api.Assertions.assertThat; + +import javax.annotation.Nonnull; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.DestinationProperty; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestinationProperties; + +class VdmEntityTest +{ + private static final String TEST_DEFAULT_SERVICE_PATH = "/odata/default"; + private static final String TEST_DESTINATION_NAME = "UnitTestDestination"; + + private static HttpDestinationProperties TEST_DESTINATION; + + private static class TestEntity extends VdmEntity + { + @Override + protected String getEntityCollection() + { + return null; + } + + @Nonnull + @Override + public Class getType() + { + return TestEntity.class; + } + + @Override + protected String getDefaultServicePath() + { + return TEST_DEFAULT_SERVICE_PATH; + } + } + + @BeforeAll + static void setUp() + { + TEST_DESTINATION = DefaultHttpDestination.builder("").name(TEST_DESTINATION_NAME).build(); + } + + @Test + void testAttachToErpWithDefaults() + { + final TestEntity sut = new TestEntity(); + sut.attachToService(null, (HttpDestinationProperties) null); + + assertThat(sut.getServicePathForFetch()).isNotEmpty(); + assertThat(sut.getServicePathForFetch()).isEqualTo(TEST_DEFAULT_SERVICE_PATH); + + assertThat(sut.getDestinationForFetch()).isNull(); + } + + @Test + void testAttachToErpWithCustomErpConfigContext() + { + final TestEntity sut = new TestEntity(); + sut.attachToService(null, TEST_DESTINATION); + + assertThat(sut.getServicePathForFetch()).isNotEmpty(); + assertThat(sut.getServicePathForFetch()).isEqualTo(TEST_DEFAULT_SERVICE_PATH); + + assertThat(sut.getDestinationForFetch()).isNotNull(); + assertThat(sut.getDestinationForFetch().get(DestinationProperty.NAME)).contains(TEST_DESTINATION_NAME); + } + + @Test + void testAttachToErpWithCustomServicePath() + { + final TestEntity sut = new TestEntity(); + sut.attachToService("/sap/opu/odata", (HttpDestinationProperties) null); + + assertThat(sut.getServicePathForFetch()).isNotEmpty(); + assertThat(sut.getServicePathForFetch()).isEqualTo("/sap/opu/odata"); + + assertThat(sut.getDestinationForFetch()).isNull(); + } + + @Test + void testAttachToErpWithAllCustomParameters() + { + final TestEntity sut = new TestEntity(); + sut.attachToService("/sap/opu/odata", TEST_DESTINATION); + + assertThat(sut.getServicePathForFetch()).isNotEmpty(); + assertThat(sut.getServicePathForFetch()).isEqualTo("/sap/opu/odata"); + + assertThat(sut.getDestinationForFetch()).isNotNull(); + assertThat(sut.getDestinationForFetch().get(DestinationProperty.NAME)).contains(TEST_DESTINATION_NAME); + } + + @Test + void testEntityComparison() + { + final TestVdmEntity foo1 = TestVdmEntity.builder().stringValue("foo").build(); + final TestVdmEntity foo2 = TestVdmEntity.builder().stringValue("foo").build(); + assertThat(foo1) + .withFailMessage( + "Entities wit equal properties should be equal. Expected:\n %s\n to be equal to:\n %s\nbut was not.", + foo1, + foo2) + .isEqualTo(foo2); + + foo1.setVersionIdentifier("1"); + foo2.setVersionIdentifier("1"); + assertThat(foo1).withFailMessage("Equal entities with equal ETags should be equal.").isEqualTo(foo2); + + foo1.setServicePathForFetch("bar"); + foo2.setServicePathForFetch("baz"); + assertThat(foo1) + .withFailMessage("Equal entities with different service paths should be equal.") + .isEqualTo(foo2); + + foo1.setDestinationForFetch(DefaultHttpDestination.builder("bar").build()); + foo2.setDestinationForFetch(DefaultHttpDestination.builder("baz").build()); + assertThat(foo1) + .withFailMessage("Equal entities with different service paths should be equal.") + .isEqualTo(foo2); + + foo2.setVersionIdentifier("2"); + assertThat(foo1).withFailMessage("Equal entities with different ETags should not be equal.").isNotEqualTo(foo2); + } + + @Test + void testChangedNonCustomFields() + { + final TestVdmEntity entity = TestVdmEntity.builder().stringValue("old").build(); + + assertThat(entity.getChangedFields()).isEmpty(); + + entity.setStringValue("old"); + assertThat(entity.getChangedFields()).isEmpty(); + + entity.setStringValue("new"); + assertThat(entity.getChangedFields()).containsOnlyKeys("StringValue"); + } + + @Test + void testChangedCustomFields() + { + final TestVdmEntity entity = TestVdmEntity.builder().build(); + + assertThat(entity.getChangedFields()).isEmpty(); + + entity.setCustomField("foo", "bar"); + assertThat(entity.getChangedFields()).containsOnlyKeys("foo"); + + entity.resetChangedFields(); + entity.setCustomField("foo", "bar"); + assertThat(entity.getChangedFields()).isEmpty(); + + entity.setCustomField("foo", "baz"); + assertThat(entity.getChangedFields()).containsOnlyKeys("foo"); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntityVersionIdentifierTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntityVersionIdentifierTest.java new file mode 100644 index 0000000000..d86d22ed62 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/VdmEntityVersionIdentifierTest.java @@ -0,0 +1,354 @@ +package com.sap.cloud.sdk.datamodel.odata.helper; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +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.head; +import static com.github.tomakehurst.wiremock.client.WireMock.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.patch; +import static com.github.tomakehurst.wiremock.client.WireMock.patchRequestedFor; +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.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import com.google.common.collect.ImmutableMap; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataResponseException; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataField; +import com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataVdmEntityAdapterFactory; + +import io.vavr.control.Option; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.With; + +class VdmEntityVersionIdentifierTest +{ + @RegisterExtension + static final WireMockExtension ERP_SERVER = + WireMockExtension.newInstance().options(wireMockConfig().dynamicPort().gzipDisabled(true)).build(); + + private static final String key1val = "2015"; + private static final String key2val = "100000000"; + private static final String key3val = "1"; + private static final Map KEY_MAP = + ImmutableMap.of("Key1", key1val, "Key2", key2val, "Key3", key3val); + + private static final String versionIdentifierInResponseBody = + "W/\"datetimeoffset'2018-01-09T08%3A33%3A53.8828600Z'\""; + private static final String versionIdentifierInHeader = "W/\"datetimeoffset'2015-01-09T08%3A33%3A53.8828600Z'\""; + private static final String updatedVersionIdentifier = "W/\"datetimeoffset'2015-01-09T08%3A33%3A53.8828600Z'\""; + + private static final String ODATA_ENDPOINT_URL = "/path/to/service"; + private static final String ODATA_COLLECTION = "A_TestEntity"; + private static final String ODATA_DOCUMENT_ITEM_URL = + String + .format( + "%s/%s(Key1='%s',Key2='%s',Key3='%s')", + ODATA_ENDPOINT_URL, + ODATA_COLLECTION, + key1val, + key2val, + key3val); + + private static final String getDocumentItemResponseBody = """ + { + "d": { + "__metadata": { + "id": "https://127.0.0.1/path/to/service/A_TestEntity(Key1='2018',Key2='100010641',Key3='1')", + "uri": "https://127.0.0.1/path/to/service/A_TestEntity(Key1='2018',Key2='100010641',Key3='1')", + "type": "SERVICE.A_TestEntityType", + "etag": "W/\\"datetimeoffset'2018-01-09T08%3A33%3A53.8828600Z'\\"" + }, + "Key1": "2015", + "Key2": "100000000", + "Key3": "1", + "SomeField": "Foo" + } + } + """; + + @Data + @NoArgsConstructor + @AllArgsConstructor( access = AccessLevel.PRIVATE ) + @EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) + @JsonAdapter( ODataVdmEntityAdapterFactory.class ) + public static class TestEntity extends VdmEntity + { + private final String entityCollection = ODATA_COLLECTION; + private final Class type = TestEntity.class; + + @With + @SerializedName( "Key1" ) + @JsonProperty( "Key1" ) + @ODataField( odataName = "Key1" ) + private String key1; + + @With + @SerializedName( "Key2" ) + @JsonProperty( "Key2" ) + @ODataField( odataName = "Key2" ) + private String key2; + + @With + @SerializedName( "Key3" ) + @JsonProperty( "Key3" ) + @ODataField( odataName = "Key3" ) + private String key3; + + @With + @SerializedName( "SomeField" ) + @JsonProperty( "SomeField" ) + @ODataField( odataName = "SomeField" ) + private String someField; + + @Nonnull + @Override + protected Map getKey() + { + return ImmutableMap.of("Key1", getKey1(), "Key2", getKey2(), "Key3", getKey3()); + } + + public void setSomeField( final String someField ) + { + rememberChangedField("SomeField", this.someField); + this.someField = someField; + } + } + + private DefaultHttpDestination destination; + + @BeforeEach + void before() + { + destination = DefaultHttpDestination.builder(ERP_SERVER.baseUrl()).build(); + ERP_SERVER.stubFor(head(urlEqualTo(ODATA_ENDPOINT_URL)).willReturn(WireMock.ok())); + ERP_SERVER + .stubFor( + get(urlEqualTo(ODATA_DOCUMENT_ITEM_URL)).willReturn(aResponse().withBody(getDocumentItemResponseBody))); + } + + @Test + void testVersionIdentifier() + { + ERP_SERVER.stubFor(patch(urlEqualTo(ODATA_DOCUMENT_ITEM_URL)).willReturn(ok())); + + final TestEntity item = + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .readByKey(TestEntity.class, ODATA_COLLECTION, KEY_MAP) + .executeRequest(destination); + + item.setKey1("2015"); + item.setSomeField("Bar"); + + assertThat(item.getVersionIdentifier()).isEqualTo(Option.of(versionIdentifierInResponseBody)); + + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .update(ODATA_COLLECTION, item) + .executeRequest(destination); + + ERP_SERVER + .verify( + patchRequestedFor(urlEqualTo(ODATA_DOCUMENT_ITEM_URL)) + .withHeader("If-Match", equalTo(versionIdentifierInResponseBody))); + } + + @Test + void testVersionIdentifierSetInHeader() + { + ERP_SERVER + .stubFor( + get(urlEqualTo(ODATA_DOCUMENT_ITEM_URL)) + .willReturn( + aResponse() + .withBody(getDocumentItemResponseBody) + .withHeader("ETag", versionIdentifierInHeader))); + ERP_SERVER.stubFor(patch(urlEqualTo(ODATA_DOCUMENT_ITEM_URL)).willReturn(ok())); + + final TestEntity item = + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .readByKey(TestEntity.class, ODATA_COLLECTION, KEY_MAP) + .executeRequest(destination); + + item.setKey1("2015"); + item.setSomeField("Bar"); + + assertThat(item.getVersionIdentifier()).isEqualTo(Option.of(versionIdentifierInHeader)); + + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .update(ODATA_COLLECTION, item) + .executeRequest(destination); + + ERP_SERVER + .verify( + patchRequestedFor(urlEqualTo(ODATA_DOCUMENT_ITEM_URL)) + .withHeader("If-Match", equalTo(versionIdentifierInHeader))); + } + + @Test + void testIgnoreVersionIdentifier() + { + ERP_SERVER.stubFor(patch(urlEqualTo(ODATA_DOCUMENT_ITEM_URL)).willReturn(ok())); + + final TestEntity item = + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .readByKey(TestEntity.class, ODATA_COLLECTION, KEY_MAP) + .executeRequest(destination); + + item.setKey1("2015"); + item.setSomeField("Bar"); + + assertThat(item.getVersionIdentifier()).isEqualTo(Option.of(versionIdentifierInResponseBody)); + + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .update(ODATA_COLLECTION, item) + .matchAnyVersionIdentifier() + .executeRequest(destination); + + ERP_SERVER.verify(patchRequestedFor(urlEqualTo(ODATA_DOCUMENT_ITEM_URL)).withHeader("If-Match", equalTo("*"))); + } + + @Test + void testIgnoreVersionIdentifierEvenIfNoVersionIdentifierPresent() + { + ERP_SERVER.stubFor(patch(urlEqualTo(ODATA_DOCUMENT_ITEM_URL)).willReturn(ok())); + + final TestEntity item = new TestEntity().withKey1(key1val).withKey2(key2val).withKey3(key3val); + + item.setKey1("2015"); + item.setSomeField("Bar"); + + assertThat(item.getVersionIdentifier().isDefined()).isFalse(); + + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .update(ODATA_COLLECTION, item) + .matchAnyVersionIdentifier() + .executeRequest(destination); + + ERP_SERVER.verify(patchRequestedFor(urlEqualTo(ODATA_DOCUMENT_ITEM_URL)).withHeader("If-Match", equalTo("*"))); + } + + @Test + void testExceptionWhenExpiredVersionIdentifierIsSent() + { + ERP_SERVER.stubFor(patch(urlEqualTo(ODATA_DOCUMENT_ITEM_URL)).willReturn(aResponse().withStatus(412))); + + final TestEntity item = + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .readByKey(TestEntity.class, ODATA_COLLECTION, KEY_MAP) + .executeRequest(destination); + + item.setKey1("2015"); + item.setSomeField("Bar"); + + assertThat(item.getVersionIdentifier()).isEqualTo(Option.of(versionIdentifierInResponseBody)); + + item.setVersionIdentifier(updatedVersionIdentifier); + + assertThat(item.getVersionIdentifier()).isEqualTo(Option.of(updatedVersionIdentifier)); + + assertThatThrownBy( + () -> FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .update(ODATA_COLLECTION, item) + .executeRequest(destination)) + .isInstanceOf(ODataResponseException.class) + .extracting("httpCode") + .isEqualTo(412); + } + + @Test + void testExceptionWhenVersionIdentifierMissing() + { + ERP_SERVER.stubFor(patch(urlEqualTo(ODATA_DOCUMENT_ITEM_URL)).willReturn(aResponse().withStatus(428))); + + final TestEntity item = + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .readByKey(TestEntity.class, ODATA_COLLECTION, KEY_MAP) + .executeRequest(destination); + + item.setKey1("2015"); + item.setSomeField("Bar"); + + assertThat(item.getVersionIdentifier()).isEqualTo(Option.of(versionIdentifierInResponseBody)); + + item.setVersionIdentifier(updatedVersionIdentifier); + + assertThat(item.getVersionIdentifier()).isEqualTo(Option.of(updatedVersionIdentifier)); + + assertThatThrownBy( + () -> FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .update(ODATA_COLLECTION, item) + .executeRequest(destination)) + .isInstanceOf(ODataResponseException.class) + .extracting("httpCode") + .isEqualTo(428); + } + + @Test + void testVersionIdentifierSentAndFetchedEvenAfterUpdate() + { + ERP_SERVER + .stubFor( + get(urlEqualTo(ODATA_DOCUMENT_ITEM_URL)).willReturn(aResponse().withBody(getDocumentItemResponseBody))); + ERP_SERVER + .stubFor( + patch(urlEqualTo(ODATA_DOCUMENT_ITEM_URL)) + .willReturn(ok().withHeader("ETag", versionIdentifierInHeader))); + + final TestEntity item = + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .readByKey(TestEntity.class, ODATA_COLLECTION, KEY_MAP) + .executeRequest(destination); + + item.setKey1("2015"); + item.setSomeField("Bar"); + + assertThat(item.getVersionIdentifier()).isEqualTo(Option.of(versionIdentifierInResponseBody)); + + final ModificationResponse updateResponse = + FluentHelperFactory + .withServicePath(ODATA_ENDPOINT_URL) + .update(ODATA_COLLECTION, item) + .executeRequest(destination); + + ERP_SERVER + .verify( + patchRequestedFor(urlEqualTo(ODATA_DOCUMENT_ITEM_URL)) + .withHeader("If-Match", equalTo(versionIdentifierInResponseBody))); + + assertThat(updateResponse.getRequestEntity().getVersionIdentifier()) + .isEqualTo(Option.of(versionIdentifierInResponseBody)); + assertThat(updateResponse.getModifiedEntity().getVersionIdentifier()) + .isEqualTo(Option.of(versionIdentifierInHeader)); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/ODataV2BatchFunctionImportTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/ODataV2BatchFunctionImportTest.java new file mode 100644 index 0000000000..29a9e15a47 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/ODataV2BatchFunctionImportTest.java @@ -0,0 +1,251 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToIgnoreCase; +import static com.github.tomakehurst.wiremock.client.WireMock.head; +import static com.github.tomakehurst.wiremock.client.WireMock.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.okForContentType; +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.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +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.google.common.collect.ImmutableMap; +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.request.ODataRequestBatch; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultMultipartGeneric; +import com.sap.cloud.sdk.datamodel.odata.helper.TestVdmEntity; + +import lombok.SneakyThrows; + +@WireMockTest +class ODataV2BatchFunctionImportTest +{ + private static final String X_CSRF_TOKEN_HEADER_KEY = "x-csrf-token"; + private static final String X_CSRF_TOKEN_HEADER_FETCH_VALUE = "fetch"; + private static final String X_CSRF_TOKEN_HEADER_VALUE = "awesome-csrf-token"; + private static final String REQUEST_URL_BATCH = "/$batch"; + private static final String RESPONSE_BODY = readResourceFileCrlf("BatchResponse.txt"); + private static final String REQUEST_BODY_POST = readResourceFileCrlf("BatchRequestFunctionImportWithPost.txt"); + private static final String REQUEST_BODY_GET = readResourceFileCrlf("BatchRequestFunctionImportWithGet.txt"); + private static final String REQUEST_BODY_POST_WITH_CUSTOM_HEADER = + readResourceFileCrlf("BatchRequestFunctionImportWithPostWithCustomHeader.txt"); + + @BeforeEach + void before() + { + stubFor( + head(urlEqualTo("/")) + .withHeader(X_CSRF_TOKEN_HEADER_KEY, equalToIgnoreCase(X_CSRF_TOKEN_HEADER_FETCH_VALUE)) + .willReturn(ok().withHeader(X_CSRF_TOKEN_HEADER_KEY, X_CSRF_TOKEN_HEADER_VALUE))); + + final String contentType = "multipart/mixed; boundary=batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef"; + stubFor(post(urlEqualTo(REQUEST_URL_BATCH)).willReturn(okForContentType(contentType, RESPONSE_BODY))); + } + + @Test + void testIllegalStateExceptionWhenFunctionImportUsingHttpGetInChangeSet( @Nonnull final WireMockRuntimeInfo wm ) + { + final DefaultHttpDestination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + final TestVdmEntityBatch.TestFunctionImportSingleResultHttpGet functionImport = + new TestVdmEntityBatch.TestFunctionImportSingleResultHttpGet("John", "Doe"); + + assertThatIllegalStateException().isThrownBy(() -> { + new TestVdmEntityBatch("") + .beginChangeSet() + .addFunctionImport(functionImport) + .endChangeSet() + .executeRequest(destination); + }); + } + + @Test + void testFunctionImportUsingHttpPostInChangeSet( @Nonnull final WireMockRuntimeInfo wm ) + { + final DefaultHttpDestination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + final TestVdmEntityBatch.TestFunctionImportHttpPost functionImport = + new TestVdmEntityBatch.TestFunctionImportHttpPost("John", "Doe"); + + new TestVdmEntityBatch("") + .beginChangeSet() + .addFunctionImport(functionImport) + .endChangeSet() + .executeRequest(destination); + + verify(postRequestedFor(urlPathEqualTo(REQUEST_URL_BATCH)).withRequestBody(equalTo(REQUEST_BODY_POST))); + } + + @Test + void testFunctionImportUsingHttpGetInBatchRequest( @Nonnull final WireMockRuntimeInfo wm ) + { + final DefaultHttpDestination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + final TestVdmEntityBatch.TestFunctionImportSingleResultHttpGet functionImport = + new TestVdmEntityBatch.TestFunctionImportSingleResultHttpGet("John", "Doe"); + + new TestVdmEntityBatch("").addReadOperations(functionImport).executeRequest(destination); + + verify(postRequestedFor(urlPathEqualTo(REQUEST_URL_BATCH)).withRequestBody(equalTo(REQUEST_BODY_GET))); + } + + @Test + void + testIllegalStateExceptionWhenFunctionImportUsingHttpPostInReadOperation( @Nonnull final WireMockRuntimeInfo wm ) + { + final DefaultHttpDestination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + final TestVdmEntityBatch.TestFunctionImportHttpPost functionImport = + new TestVdmEntityBatch.TestFunctionImportHttpPost("John", "Doe"); + + assertThatIllegalStateException().isThrownBy(() -> { + new TestVdmEntityBatch("").addReadOperations(functionImport).executeRequest(destination); + }); + } + + @Test + void testFunctionImportUsingHttpPostInChangeSetWithCustomHeader( @Nonnull final WireMockRuntimeInfo wm ) + { + final DefaultHttpDestination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + final TestVdmEntityBatch.TestFunctionImportHttpPost functionImport = + new TestVdmEntityBatch.TestFunctionImportHttpPost("John", "Doe").withHeader("foo", "bar"); + + new TestVdmEntityBatch("") + .beginChangeSet() + .addFunctionImport(functionImport) + .endChangeSet() + .executeRequest(destination); + + verify( + postRequestedFor(urlPathEqualTo(REQUEST_URL_BATCH)) + .withRequestBody(equalTo(REQUEST_BODY_POST_WITH_CUSTOM_HEADER))); + } + + @SneakyThrows + private static String readResourceFileCrlf( final String resourceFileName ) + { + final URL resourceUrl = + ODataV2BatchRequestUnitTest.class + .getClassLoader() + .getResource(ODataV2BatchFunctionImportTest.class.getSimpleName() + "/" + resourceFileName); + final String result = Resources.toString(resourceUrl, StandardCharsets.UTF_8); + return result.replaceAll("(? "StringValue", () -> "IntegerValue") + .top(10) + .withHeader("header-read_all", "read_all"); + + final TestVdmEntityBatch.TestFunctionImportSingleResultHttpGet testFunctionImportSingleResultHttpGet = + new TestVdmEntityBatch.TestFunctionImportSingleResultHttpGet("Alice", "Bob"); + + final TestVdmEntityBatch.TestFunctionImportCollectionResultHttpGet testFunctionImportCollectionResultHttpGet = + new TestVdmEntityBatch.TestFunctionImportCollectionResultHttpGet(); + + final TestVdmEntityBatch.TestFunctionImportHttpPost functionImport = + new TestVdmEntityBatch.TestFunctionImportHttpPost("John", "Doe"); + + final TestVdmEntityBatch.TestFunctionImportSingleEntityResultHttpGet testFunctionImportSingleEntityResultHttpGet = + new TestVdmEntityBatch.TestFunctionImportSingleEntityResultHttpGet(33, "Alice"); + + final TestVdmEntityBatch batchBuilder = + testVdmEntityBatch + .addReadOperations(readAll) + .addChangeSet(create) + .addReadOperations(entityByKey) + .addChangeSet(update, delete) + .beginChangeSet() + .create(testVdmEntity) + .addFunctionImport(functionImport) + .endChangeSet() + .addReadOperations(testFunctionImportSingleResultHttpGet, testFunctionImportCollectionResultHttpGet) + .addReadOperations(testFunctionImportSingleEntityResultHttpGet); + + final ODataRequestBatch lowLevelRequest = batchBuilder.toRequest(); + + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + + final ODataRequestResultMultipartGeneric lowLevelResult = lowLevelRequest.execute(httpClient); + + //Customer transition example from low-level result object to typed result + + final DefaultBatchResponseResult convertedHighLevelResult = + DefaultBatchResponseResult.of(lowLevelResult, batchBuilder); + + final BatchResponse expectedHighLevelResult = batchBuilder.executeRequest(destination); + + assertThat(expectedHighLevelResult.getReadResult(readAll)) + .isEqualTo(convertedHighLevelResult.getReadResult(readAll)); + + assertThat(expectedHighLevelResult.getReadResult(entityByKey)) + .isEqualTo(convertedHighLevelResult.getReadResult(entityByKey)); + assertThat(expectedHighLevelResult.getReadResult(entityByKey)) + .extracting(TestVdmEntity::getIntegerValue) + .isEqualTo(9000); + + assertThat(expectedHighLevelResult.getReadResult(testFunctionImportSingleResultHttpGet)) + .isEqualTo(convertedHighLevelResult.getReadResult(testFunctionImportSingleResultHttpGet)); + assertThat(expectedHighLevelResult.getReadResult(testFunctionImportSingleResultHttpGet)) + .isEqualTo("awesomeStuff"); + + assertThat(expectedHighLevelResult.getReadResult(testFunctionImportCollectionResultHttpGet)) + .isEqualTo(convertedHighLevelResult.getReadResult(testFunctionImportCollectionResultHttpGet)); + assertThat(expectedHighLevelResult.getReadResult(testFunctionImportCollectionResultHttpGet)) + .isEqualTo(Arrays.asList("foo", "bar")); + + assertThat(expectedHighLevelResult.getReadResult(testFunctionImportSingleEntityResultHttpGet)) + .isEqualTo(convertedHighLevelResult.getReadResult(testFunctionImportSingleEntityResultHttpGet)); + assertThat(expectedHighLevelResult.getReadResult(testFunctionImportSingleEntityResultHttpGet)) + .extracting(TestVdmEntity::getIntegerValue) + .isEqualTo(33); + assertThat(expectedHighLevelResult.getReadResult(testFunctionImportSingleEntityResultHttpGet)) + .extracting(TestVdmEntity::getStringValue) + .isEqualTo("Alice"); + + assertThat(expectedHighLevelResult.get(0).get().getCreatedEntities()) + .isEqualTo(convertedHighLevelResult.get(0).get().getCreatedEntities()); + assertThat(expectedHighLevelResult.get(1).get().getCreatedEntities()) + .isEqualTo(convertedHighLevelResult.get(1).get().getCreatedEntities()); + assertThat(expectedHighLevelResult.get(2).isFailure()).isEqualTo(convertedHighLevelResult.get(2).isFailure()); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/ODataV2BatchRequestUnitTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/ODataV2BatchRequestUnitTest.java new file mode 100644 index 0000000000..e7fa59de1c --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/ODataV2BatchRequestUnitTest.java @@ -0,0 +1,371 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import static com.github.tomakehurst.wiremock.client.WireMock.equalToIgnoreCase; +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.matching; +import static com.github.tomakehurst.wiremock.client.WireMock.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.okForContentType; +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.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.InputStreamEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.api.AfterEach; +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 org.mockito.Mockito; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.Resources; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5FactoryBuilder; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestinationProperties; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataServiceErrorException; +import com.sap.cloud.sdk.datamodel.odata.helper.TestVdmEntity; + +import lombok.SneakyThrows; +import lombok.Value; + +class ODataV2BatchRequestUnitTest +{ + private static final WireMockConfiguration WIREMOCK_CONFIGURATION = wireMockConfig().dynamicPort(); + private static final String X_CSRF_TOKEN_HEADER_KEY = "x-csrf-token"; + private static final String X_CSRF_TOKEN_HEADER_FETCH_VALUE = "fetch"; + private static final String X_CSRF_TOKEN_HEADER_VALUE = "awesome-csrf-token"; + private static final String REQUEST_URL_BATCH = "/$batch"; + + private static final String REQUEST_BODY = readResourceFileCrlf("BatchRequest.txt"); + private static final String REQUEST_BODY_WITH_CUSTOM_HEADERS = + readResourceFileCrlf("BatchRequestWithCustomHeaders.txt"); + private static final String RESPONSE_BODY = readResourceFileCrlf("BatchResponse.txt"); + private static final int MAX_PARALLEL_CONNECTIONS = 10; + + private final WireMockServer server = new WireMockServer(WIREMOCK_CONFIGURATION); + private DefaultHttpDestination destination; + + private static Stream getTestParameters() + { + return Stream.of(new TestParameter("#executeRequest", BatchFluentHelperBasic::executeRequest, REQUEST_BODY)); + } + + private static Stream getTestParametersWithCustomHeaders() + { + return Stream + .of( + new TestParameter( + "#executeRequest", + BatchFluentHelperBasic::executeRequest, + REQUEST_BODY_WITH_CUSTOM_HEADERS)); + } + + @Value + static class TestParameter + { + String label; + Executor executor; + String requestBody; + + public String toString() + { + return label; + } + + interface Executor + { + BatchResponse execute( BatchFluentHelperBasic fluentHelper, HttpDestinationProperties destination ) + throws Exception; + } + } + + @BeforeEach + void setup() + { + server.start(); + destination = DefaultHttpDestination.builder(server.baseUrl()).build(); + + // Mock CSRF token handling + server + .stubFor( + head(urlEqualTo("/")) + .withHeader(X_CSRF_TOKEN_HEADER_KEY, equalToIgnoreCase(X_CSRF_TOKEN_HEADER_FETCH_VALUE)) + .willReturn(ok().withHeader(X_CSRF_TOKEN_HEADER_KEY, X_CSRF_TOKEN_HEADER_VALUE))); + + // Mock OData Batch response + final String contentType = "multipart/mixed; boundary=batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef"; + server.stubFor(post(urlEqualTo(REQUEST_URL_BATCH)).willReturn(okForContentType(contentType, RESPONSE_BODY))); + + ApacheHttpClient5Accessor + .setHttpClientFactory( + new ApacheHttpClient5FactoryBuilder() + .maxConnectionsTotal(MAX_PARALLEL_CONNECTIONS) + .maxConnectionsPerRoute(MAX_PARALLEL_CONNECTIONS) + .build()); + } + + @AfterEach + void shutdown() + { + server.shutdown(); + ApacheHttpClient5Accessor.setHttpClientFactory(null); + } + + @SneakyThrows + @ParameterizedTest( name = "{0}" ) + @MethodSource( "getTestParameters" ) + void testAllOperations( @Nonnull final TestParameter parameter ) + { + final TestVdmEntity entity12 = TestVdmEntity.builder().integerValue(12).build(); + final TestVdmEntity entity13 = TestVdmEntity.builder().integerValue(13).build(); + final TestVdmEntity entity14 = TestVdmEntity.builder().integerValue(14).build(); + final TestVdmEntity entity15 = TestVdmEntity.builder().integerValue(15).build(); + final TestVdmEntity entity16 = TestVdmEntity.builder().integerValue(16).build(); + + final TestVdmEntityBatch.TestEntityRead readAll = + new TestVdmEntityBatch.TestEntityRead().select(() -> "StringValue", () -> "IntegerValue").top(10); + final TestVdmEntityBatch.TestEntityByKey readByKey = + new TestVdmEntityBatch.TestEntityByKey(ImmutableMap.of("IntegerValue", 9000)); + + // prepare batched requests + final TestVdmEntityBatch request = + new TestVdmEntityBatch("") + + // read operation + .addReadOperations(readAll) + + // changeset:0 + .beginChangeSet() + .create(entity12) + .endChangeSet() + + // read operation + .addReadOperations(readByKey) + + // changeset:1 + .beginChangeSet() + .update(entity13) + .delete(entity14) + .endChangeSet() + + // changeset:2 + .beginChangeSet() + .delete(entity15) + .delete(entity16) + .endChangeSet(); + + // execute batch request with try-with-resources to ensure no connection leaks + for( int i = 0; i <= MAX_PARALLEL_CONNECTIONS * 2; i++ ) { + try( final BatchResponse response = request.executeRequest(destination) ) { + verifyBatchChangeSets(response); + verifyBatchReadAll(response, readAll); + verifyBatchReadByKey(response, readByKey); + verifyBatchRequestBody(parameter); + } + } + } + + @SneakyThrows + @ParameterizedTest( name = "{0}" ) + @MethodSource( "getTestParametersWithCustomHeaders" ) + void testAllOperationsWithCustomHeaders( @Nonnull final TestParameter parameter ) + { + final TestVdmEntity entity12 = TestVdmEntity.builder().integerValue(12).build(); + final TestVdmEntity entity13 = TestVdmEntity.builder().integerValue(13).build(); + final TestVdmEntity entity14 = TestVdmEntity.builder().integerValue(14).build(); + final TestVdmEntity entity15 = TestVdmEntity.builder().integerValue(15).build(); + final TestVdmEntity entity16 = TestVdmEntity.builder().integerValue(16).build(); + + final TestVdmEntityBatch.TestEntityRead readAll = + new TestVdmEntityBatch.TestEntityRead() + .select(() -> "StringValue", () -> "IntegerValue") + .top(10) + .withHeader("header-read_all", "read_all"); + final TestVdmEntityBatch.TestEntityByKey readByKey = + new TestVdmEntityBatch.TestEntityByKey(ImmutableMap.of("IntegerValue", 9000)) + .withHeader("header-read_by_key", "read_by_key"); + final TestVdmEntityBatch.TestEntityCreate create = + new TestVdmEntityBatch.TestEntityCreate(entity12).withHeader("header-create", "create"); + final TestVdmEntityBatch.TestEntityUpdate update = + new TestVdmEntityBatch.TestEntityUpdate(entity13).withHeader("header-update", "update"); + final TestVdmEntityBatch.TestEntityDelete deleteEntity14 = + new TestVdmEntityBatch.TestEntityDelete(entity14).withHeader("header-delete", "delete-entity14"); + final TestVdmEntityBatch.TestEntityDelete deleteEntity15 = + new TestVdmEntityBatch.TestEntityDelete(entity15).withHeader("header-delete", "delete-entity15"); + final TestVdmEntityBatch.TestEntityDelete deleteEntity16 = + new TestVdmEntityBatch.TestEntityDelete(entity16).withHeader("header-delete", "delete-entity16"); + + // prepare batched requests + final TestVdmEntityBatch request = + new TestVdmEntityBatch("") + + // read operation + .addReadOperations(readAll) + + // changeset:0 + .addChangeSet(create) + + // read operation + .addReadOperations(readByKey) + + // changeset:1 + .addChangeSet(update, deleteEntity14) + + // changeset:2 + .addChangeSet(deleteEntity15, deleteEntity16); + + for( int i = 0; i < MAX_PARALLEL_CONNECTIONS * 2; i++ ) { + // execute batch request + final BatchResponse response = parameter.getExecutor().execute(request, destination); + + verifyBatchChangeSets(response); + verifyBatchReadAll(response, readAll); + verifyBatchReadByKey(response, readByKey); + verifyBatchRequestBody(parameter); + } + } + + private void verifyBatchChangeSets( @Nonnull final BatchResponse response ) + { + // assertion on response parsing + assertThat(response.get(0).isSuccess()).isTrue(); + assertThat(response.get(0).get().getCreatedEntities()).hasSize(1); + assertThat(response.get(1).isSuccess()).isTrue(); + assertThat(response.get(1).get().getCreatedEntities()).hasSize(0); + + assertThat(response.get(2).isSuccess()).isFalse(); // legitimately no success: unhealthy response + assertThat(response.get(2).getCause()).isInstanceOfAny(ODataServiceErrorException.class); + assertThat(response.get(3).isSuccess()).isFalse(); // index out of bounds + assertThat(response.get(3).getCause()).isInstanceOf(IllegalArgumentException.class); + } + + private void verifyBatchReadAll( + @Nonnull final BatchResponse response, + @Nonnull final TestVdmEntityBatch.TestEntityRead readAll ) + { + assertThat(response.getReadResult(readAll)) + .satisfiesExactly(item -> assertThat(item.getStringValue()).isEqualTo("Foo")); + } + + private void verifyBatchReadByKey( + @Nonnull final BatchResponse response, + @Nonnull final TestVdmEntityBatch.TestEntityByKey readByKey ) + { + assertThat(response.getReadResult(readByKey)).extracting(TestVdmEntity::getIntegerValue).isEqualTo(9000); + } + + private void verifyBatchRequestBody( @Nonnull final TestParameter parameter ) + { + server + .verify( + postRequestedFor(urlEqualTo(REQUEST_URL_BATCH)) + .withHeader("Content-Type", matching("multipart/mixed;boundary=batch_[a-z0-9-]+")) + .withHeader(X_CSRF_TOKEN_HEADER_KEY, equalToIgnoreCase(X_CSRF_TOKEN_HEADER_VALUE)) + .withRequestBody(matching("\\Q" + parameter.getRequestBody() + "\\E"))); + } + + @SneakyThrows + private static String readResourceFileCrlf( final String resourceFileName ) + { + final URL resourceUrl = + ODataV2BatchRequestUnitTest.class + .getClassLoader() + .getResource(ODataV2BatchRequestUnitTest.class.getSimpleName() + "/" + resourceFileName); + final String result = Resources.toString(resourceUrl, StandardCharsets.UTF_8); + return result.replaceAll("(? inputStreams = new ArrayList<>(); + + final HttpDestination dest = DefaultHttpDestination.builder("").build(); + final HttpClient httpClient = mock(HttpClient.class); + when(httpClient.executeOpen(isNull(), argThat(req -> req instanceof HttpPost), isNull())).thenAnswer(args -> { + final BasicClassicHttpResponse response = new BasicClassicHttpResponse(200, "ok"); + final InputStream inStream = mock(InputStream.class); + inputStreams.add(inStream); + response.setEntity(new InputStreamEntity(inStream, ContentType.APPLICATION_JSON)); + return response; + }); + + // configure test setup + ApacheHttpClient5Accessor.setHttpClientFactory(( anyDestination ) -> httpClient); + + // TEST: invoke many batch request each spawning an InputStream + for( int i = 0; i < N; i++ ) { + new TestVdmEntityBatch("").addReadOperations(new TestVdmEntityBatch.TestEntityRead()).executeRequest(dest); + } + + // ASSERTION: input streams are loaded but never fully consumed + assertThat(inputStreams).hasSize(N); + for( final InputStream inStream : inputStreams ) { + Mockito.verify(inStream, never()).close(); + } + + // TEST: invoke one batch request using try-with-resource + try( + BatchResponse result = + new TestVdmEntityBatch("") + .addReadOperations(new TestVdmEntityBatch.TestEntityRead()) + .executeRequest(dest) ) { + + assertThat(result).isNotNull(); + } + + // ASSERTION: input stream is fully consumed + Mockito.verify(inputStreams.get(N), times(1)).close(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/ODatav2BatchConnectionTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/ODatav2BatchConnectionTest.java new file mode 100644 index 0000000000..7c325d032d --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/ODatav2BatchConnectionTest.java @@ -0,0 +1,194 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +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.okForContentType; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.sap.cloud.sdk.datamodel.odata.helper.batch.TestVdmEntityBatch.TestEntityByKey; +import static com.sap.cloud.sdk.datamodel.odata.helper.batch.TestVdmEntityBatch.TestEntityCreate; +import static com.sap.cloud.sdk.datamodel.odata.helper.batch.TestVdmEntityBatch.TestEntityDelete; +import static com.sap.cloud.sdk.datamodel.odata.helper.batch.TestVdmEntityBatch.TestEntityRead; +import static com.sap.cloud.sdk.datamodel.odata.helper.batch.TestVdmEntityBatch.TestEntityUpdate; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import javax.annotation.Nonnull; + +import org.apache.hc.core5.http.ConnectionRequestTimeoutException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.github.tomakehurst.wiremock.matching.UrlPattern; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.Resources; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5FactoryBuilder; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataConnectionException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataServiceErrorException; +import com.sap.cloud.sdk.datamodel.odata.helper.TestVdmEntity; + +import lombok.SneakyThrows; + +@WireMockTest +class ODatav2BatchConnectionTest +{ + private static final String RESPONSE_CONTENT_TYPE = + "multipart/mixed; boundary=batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef"; + private static final String RESPONSE_WITH_CHANGESET = readResourceFileCrlf("BatchResponseWithChangeset.txt"); + private static final String RESPONSE_WITHOUT_CHANGESET = readResourceFileCrlf("BatchResponseWithoutChangeset.txt"); + private static final String RESPONSE_WITH_ERROR = readResourceFileCrlf("BatchResponseWithError.txt"); + private static final int MAX_PARALLEL_CONNECTIONS = 10; + private DefaultHttpDestination destination; + + @SneakyThrows + private static String readResourceFileCrlf( final String resourceFileName ) + { + final URL resourceUrl = + ODatav2BatchConnectionTest.class + .getClassLoader() + .getResource(ODatav2BatchConnectionTest.class.getSimpleName() + "/" + resourceFileName); + final String result = Resources.toString(resourceUrl, StandardCharsets.UTF_8); + return result.replaceAll("(? result = batchResponse.getReadResult(read); + assertThat(result).isNotEmpty(); + final TestVdmEntity readByKeyResult = batchResponse.getReadResult(readByKey); + assertThat(readByKeyResult).extracting(TestVdmEntity::getIntegerValue).isEqualTo(9000); + } + } + + @Test + void testNoConnectionTimeoutWhenBatchResponseContainsChangeset() + { + stubFor(post(UrlPattern.ANY).willReturn(okForContentType(RESPONSE_CONTENT_TYPE, RESPONSE_WITH_CHANGESET))); + + final TestEntityRead read = new TestEntityRead(); + final TestEntityByKey readByKey = new TestEntityByKey(ImmutableMap.of("IntegerValue", 9000)); + final TestEntityCreate create = new TestEntityCreate(TestVdmEntity.builder().integerValue(1337).build()); + final TestVdmEntity updateEntity = TestVdmEntity.builder().integerValue(13).build(); + final TestEntityUpdate update = new TestEntityUpdate(updateEntity); + final TestVdmEntity deleteEntity = TestVdmEntity.builder().integerValue(14).build(); + final TestEntityDelete delete = new TestEntityDelete(deleteEntity); + final TestEntityCreate createAnother = new TestEntityCreate(TestVdmEntity.builder().integerValue(1447).build()); + + for( int i = 0; i < MAX_PARALLEL_CONNECTIONS * 2; i++ ) { + final TestVdmEntityBatch request = + new TestVdmEntityBatch("") + .addReadOperations(read) + .addChangeSet(create) + .addReadOperations(readByKey) + .addChangeSet(update, delete, createAnother); + final BatchResponse batchResponse = request.executeRequest(destination); + final List result = batchResponse.getReadResult(read); + assertThat(result).isNotEmpty(); + + final TestVdmEntity readByKeyResult = batchResponse.getReadResult(readByKey); + assertThat(readByKeyResult).extracting(TestVdmEntity::getIntegerValue).isEqualTo(9000); + + assertThat(batchResponse.get(0).isSuccess()).isTrue(); + assertThat(batchResponse.get(0).get().getCreatedEntities()).hasSize(1); + assertThat(batchResponse.get(1).isSuccess()).isTrue(); + assertThat(batchResponse.get(1).get().getCreatedEntities()).hasSize(1); + } + } + + @Test + void testNoConnectionTimeoutWhenBatchResponseContainsError() + { + stubFor(post(UrlPattern.ANY).willReturn(okForContentType(RESPONSE_CONTENT_TYPE, RESPONSE_WITH_ERROR))); + + final TestEntityRead read = new TestEntityRead(); + final TestEntityByKey readByKey = new TestEntityByKey(ImmutableMap.of("IntegerValue", 9000)); + final TestVdmEntity deleteEntity = TestVdmEntity.builder().integerValue(14).build(); + final TestEntityDelete delete = new TestEntityDelete(deleteEntity); + + for( int i = 0; i < MAX_PARALLEL_CONNECTIONS * 2; i++ ) { + final TestVdmEntityBatch request = + new TestVdmEntityBatch("").addReadOperations(read).addReadOperations(readByKey).addChangeSet(delete); + final BatchResponse batchResponse = request.executeRequest(destination); + final List result = batchResponse.getReadResult(read); + assertThat(result).isNotEmpty(); + final TestVdmEntity readByKeyResult = batchResponse.getReadResult(readByKey); + assertThat(readByKeyResult).extracting(TestVdmEntity::getIntegerValue).isEqualTo(9000); + assertThat(batchResponse.get(0).isSuccess()).isFalse(); + assertThat(batchResponse.get(0).getCause()).isInstanceOfAny(ODataServiceErrorException.class); + } + } + + @Test + @Timeout( value = 300_000L, unit = TimeUnit.MILLISECONDS ) + @Disabled( "Test triggers a ConnectionRequestTimeoutException. Use it only to manually verify behaviour." ) + void testConnectionTimeoutWhenBatchResponseIsNotConsumedFully() + { + stubFor(post(UrlPattern.ANY).willReturn(okForContentType(RESPONSE_CONTENT_TYPE, RESPONSE_WITH_CHANGESET))); + + final TestEntityRead read = new TestEntityRead(); + final TestEntityByKey readByKey = new TestEntityByKey(ImmutableMap.of("IntegerValue", 9000)); + final TestEntityCreate create = new TestEntityCreate(TestVdmEntity.builder().integerValue(1337).build()); + final TestVdmEntity updateEntity = TestVdmEntity.builder().integerValue(13).build(); + final TestEntityUpdate update = new TestEntityUpdate(updateEntity); + final TestVdmEntity deleteEntity = TestVdmEntity.builder().integerValue(14).build(); + final TestEntityDelete delete = new TestEntityDelete(deleteEntity); + final TestEntityCreate createAnother = new TestEntityCreate(TestVdmEntity.builder().integerValue(1447).build()); + final TestVdmEntityBatch request = + new TestVdmEntityBatch("") + .addReadOperations(read) + .addChangeSet(create) + .addReadOperations(readByKey) + .addChangeSet(update, delete, createAnother); + + for( int i = 0; i < MAX_PARALLEL_CONNECTIONS; i++ ) { + assertThatNoException().isThrownBy(() -> request.executeRequest(destination)); + } + + assertThatThrownBy(() -> request.executeRequest(destination)) + .isInstanceOf(ODataConnectionException.class) + .hasRootCauseExactlyInstanceOf(ConnectionRequestTimeoutException.class) + .hasMessageContaining( + "Please execute your request with try-with-resources to ensure resources are properly closed."); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/TestVdmEntityBatch.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/TestVdmEntityBatch.java new file mode 100644 index 0000000000..a001031e9b --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odata/helper/batch/TestVdmEntityBatch.java @@ -0,0 +1,371 @@ +package com.sap.cloud.sdk.datamodel.odata.helper.batch; + +import java.net.URI; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.classic.methods.HttpUriRequest; + +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.helper.CollectionValuedFluentHelperFunction; +import com.sap.cloud.sdk.datamodel.odata.helper.EntitySelectable; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperByKey; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperCreate; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperDelete; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperFunction; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperRead; +import com.sap.cloud.sdk.datamodel.odata.helper.FluentHelperUpdate; +import com.sap.cloud.sdk.datamodel.odata.helper.SingleValuedFluentHelperFunction; +import com.sap.cloud.sdk.datamodel.odata.helper.TestVdmEntity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +class TestVdmEntityBatch extends BatchFluentHelperBasic +{ + @Getter + private final String servicePathForBatchRequest; + + { + final AtomicInteger uuidCounter = new AtomicInteger(); + uuidProvider = () -> new UUID(0, uuidCounter.incrementAndGet()); + } + + @Nonnull + @Override + protected TestVdmEntityBatch getThis() + { + return this; + } + + @Nonnull + @Override + public TestVdmEntityChangeset beginChangeSet() + { + return new TestVdmEntityChangeset(this); + } + + static class TestVdmEntityChangeset + extends + BatchChangeSetFluentHelperBasic + { + public TestVdmEntityChangeset( final TestVdmEntityBatch batch ) + { + super(batch, batch); + } + + @Nonnull + @Override + protected TestVdmEntityChangeset getThis() + { + return this; + } + + TestVdmEntityChangeset create( final TestVdmEntity obj ) + { + return addRequestCreate(TestEntityCreate::new, obj); + } + + TestVdmEntityChangeset update( final TestVdmEntity obj ) + { + return addRequestUpdate(TestEntityUpdate::new, obj); + } + + TestVdmEntityChangeset delete( final TestVdmEntity obj ) + { + return addRequestDelete(TestEntityDelete::new, obj); + } + } + + static class TestEntityByKey + extends + FluentHelperByKey> + { + @Getter + private final Map key; + + @SuppressWarnings( "deprecation" ) + public TestEntityByKey( final Map key ) + { + super("", TestVdmEntity.builder().build().getEntityCollection()); + this.key = key; + } + + @Nonnull + @Override + protected Class getEntityClass() + { + return TestVdmEntity.class; + } + } + + static class TestEntityRead extends FluentHelperRead + { + @SuppressWarnings( "deprecation" ) + public TestEntityRead() + { + super("", TestVdmEntity.builder().build().getEntityCollection()); + } + + @Nonnull + @Override + protected Class getEntityClass() + { + return TestVdmEntity.class; + } + } + + static class TestEntityDelete extends FluentHelperDelete + { + @Getter + private final TestVdmEntity entity; + + @SuppressWarnings( "deprecation" ) + TestEntityDelete( final TestVdmEntity entity ) + { + super("", entity.getEntityCollection()); + this.entity = entity; + } + } + + static class TestEntityUpdate extends FluentHelperUpdate + { + @Getter + private final TestVdmEntity entity; + + @SuppressWarnings( "deprecation" ) + TestEntityUpdate( final TestVdmEntity entity ) + { + super("", entity.getEntityCollection()); + this.entity = entity; + } + } + + static class TestEntityCreate extends FluentHelperCreate + { + @Getter + private final TestVdmEntity entity; + + @SuppressWarnings( "deprecation" ) + TestEntityCreate( final TestVdmEntity entity ) + { + super("", entity.getEntityCollection()); + this.entity = entity; + } + } + + static class TestFunctionImportSingleResultHttpGet + extends + SingleValuedFluentHelperFunction + { + private final Map values = new HashMap<>(); + + @Nonnull + @Override + protected Class getEntityClass() + { + return String.class; + } + + TestFunctionImportSingleResultHttpGet( @Nonnull final String firstName, @Nonnull final String lastName ) + { + super(""); + values.put("FirstName", firstName); + values.put("LastName", lastName); + } + + @Nullable + @Override + public String executeRequest( @Nonnull final Destination destination ) + { + return "awesomeStuff"; + } + + @Nonnull + @Override + protected Map getParameters() + { + return values; + } + + @Nonnull + @Override + protected String getFunctionName() + { + return "awesomeFunction"; + } + + @Nonnull + @Override + protected HttpUriRequest createRequest( @Nonnull final URI uri ) + { + return new HttpGet(uri); + } + } + + static class TestFunctionImportCollectionResultHttpGet + extends + CollectionValuedFluentHelperFunction> + { + private final Map values = new HashMap<>(); + + @Nonnull + @Override + protected Class getEntityClass() + { + return String.class; + } + + TestFunctionImportCollectionResultHttpGet() + { + super(""); + } + + @Nullable + @Override + public List executeRequest( @Nonnull final Destination destination ) + { + return Arrays.asList("foo", "bar"); + } + + @Nonnull + @Override + protected Map getParameters() + { + return values; + } + + @Nonnull + @Override + protected String getFunctionName() + { + return "awesomeFunction"; + } + + @Nonnull + @Override + protected HttpUriRequest createRequest( @Nonnull final URI uri ) + { + return new HttpGet(uri); + } + } + + static class TestFunctionImportSingleEntityResultHttpGet + extends + SingleValuedFluentHelperFunction + { + private final Map values = new HashMap<>(); + + @Nonnull + @Override + protected Class getEntityClass() + { + return TestVdmEntity.class; + } + + TestFunctionImportSingleEntityResultHttpGet( + @Nonnull final int integerValue, + @Nonnull final String stringValue ) + { + super(""); + values.put("IntegerValue", integerValue); + values.put("StringValue", stringValue); + } + + @Nullable + @Override + public TestVdmEntity executeRequest( @Nonnull final Destination destination ) + { + return TestVdmEntity + .builder() + .integerValue((int) values.get("IntegerValue")) + .stringValue((String) values.get("StringValue")) + .build(); + } + + @Nonnull + @Override + protected Map getParameters() + { + return values; + } + + @Nonnull + @Override + protected String getFunctionName() + { + return "awesomeFunction"; + } + + @Nonnull + @Override + protected HttpUriRequest createRequest( @Nonnull final URI uri ) + { + return new HttpGet(uri); + } + } + + static class TestFunctionImportHttpPost + extends + FluentHelperFunction + { + private final Map values = new HashMap<>(); + + TestFunctionImportHttpPost( @Nonnull final String firstName, @Nonnull final String lastName ) + { + super(""); + values.put("FirstName", firstName); + values.put("LastName", lastName); + } + + @Nonnull + @Override + protected Class getEntityClass() + { + return TestVdmEntity.class; + } + + @Nullable + @Override + public String executeRequest( @Nonnull final Destination destination ) + { + return "awesomeStuff"; + } + + @Nonnull + @Override + protected Map getParameters() + { + return values; + } + + @Nonnull + @Override + protected String getFunctionName() + { + return "awesomeFunction"; + } + + @Nonnull + @Override + protected HttpUriRequest createRequest( @Nonnull final URI uri ) + { + return new HttpPost(uri); + } + } + + interface TestVdmEntitySelectable extends EntitySelectable + { + + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalDateTimeAdapterTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalDateTimeAdapterTest.java new file mode 100644 index 0000000000..ba0d9447ee --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalDateTimeAdapterTest.java @@ -0,0 +1,60 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.time.Month; + +import org.junit.jupiter.api.Test; + +import com.google.gson.TypeAdapter; + +import lombok.SneakyThrows; + +class LocalDateTimeAdapterTest +{ + @Test + @SneakyThrows + void standardFormatCases() + { + final TypeAdapter adapter = new LocalDateTimeAdapter(); + + assertThat(adapter.fromJson("\"/Date(1649669575192)/\"")).isEqualTo("2022-04-11T09:32:55.192"); + } + + @Test + @SneakyThrows + void wrongFormatReturnsNull() + { + final TypeAdapter adapter = new LocalDateTimeAdapter(); + + assertThat(adapter.fromJson("\"Something that is not a date\"")).isNull(); + } + + @Test + @SneakyThrows + void excessivelyLongNumberReturnsNull() + { + final TypeAdapter adapter = new LocalDateTimeAdapter(); + + assertThat(adapter.fromJson("\"/Date(100000000000000000000000)/\"")).isNull(); + } + + @Test + @SneakyThrows + void nonStringValueReturnsNull() + { + final TypeAdapter adapter = new LocalDateTimeAdapter(); + + assertThat(adapter.fromJson("1234")).isNull(); + } + + @Test + void writeLocalDateTime() + { + final TypeAdapter adapter = new LocalDateTimeAdapter(); + + assertThat(adapter.toJson(LocalDateTime.of(2022, Month.APRIL, 11, 11, 36, 55, 123000000))) + .isEqualTo("\"/Date(1649677015123)/\""); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalTimeAdapterTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalTimeAdapterTest.java new file mode 100644 index 0000000000..447c5072fe --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/LocalTimeAdapterTest.java @@ -0,0 +1,64 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalTime; + +import org.junit.jupiter.api.Test; + +import com.google.gson.TypeAdapter; + +import lombok.SneakyThrows; + +class LocalTimeAdapterTest +{ + @Test + @SneakyThrows + void standardFormatCases() + { + final TypeAdapter adapter = new LocalTimeAdapter(); + + assertThat(adapter.fromJson("\"PT13H20M\"")).isEqualTo("13:20"); + assertThat(adapter.fromJson("\"PT22M\"")).isEqualTo("00:22"); + assertThat(adapter.fromJson("\"PT15H\"")).isEqualTo("15:00"); + assertThat(adapter.fromJson("\"PT54S\"")).isEqualTo("00:00:54"); + assertThat(adapter.fromJson("\"PT54.123S\"")).isEqualTo("00:00:54.123"); + } + + @Test + @SneakyThrows + void adapterIgnoresDateFields() + { + final TypeAdapter adapter = new LocalTimeAdapter(); + + assertThat(adapter.fromJson("\"P11Y22M33DT13H22M12.345S\"")).isEqualTo("13:22:12.345"); + assertThat(adapter.fromJson("\"P11Y22M33DT\"")).isEqualTo("00:00"); + } + + @Test + @SneakyThrows + void adapterReturnsNullOnEmptyData() + { + final TypeAdapter adapter = new LocalTimeAdapter(); + + assertThat(adapter.fromJson("\"PT\"")).isNull(); + } + + @Test + @SneakyThrows + void nonStringValueReturnsNull() + { + final TypeAdapter adapter = new LocalTimeAdapter(); + + assertThat(adapter.fromJson("1234")).isNull(); + } + + @Test + void writeLocalTime() + { + final TypeAdapter adapter = new LocalTimeAdapter(); + + assertThat(adapter.toJson(LocalTime.of(1, 2, 3))).isEqualTo("\"PT1H2M3S\""); + assertThat(adapter.toJson(LocalTime.of(14, 54, 32, 123000000))).isEqualTo("\"PT14H54M32.123S\""); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataBinaryAdapterTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataBinaryAdapterTest.java new file mode 100644 index 0000000000..7982c1feb2 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataBinaryAdapterTest.java @@ -0,0 +1,63 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.junit.jupiter.api.Test; + +import com.google.gson.TypeAdapter; + +import lombok.SneakyThrows; + +class ODataBinaryAdapterTest +{ + @Test + @SneakyThrows + void readIntoByteArray() + { + final String testText = "Hello World"; + final String base64encodedString = + Base64.getEncoder().encodeToString(testText.getBytes(StandardCharsets.UTF_8)); + + final TypeAdapter binaryAdapter = new ODataBinaryAdapter(); + + final byte[] parsedResult = binaryAdapter.fromJson("\"" + base64encodedString + "\""); + + assertThat(parsedResult).isEqualTo(testText.getBytes(StandardCharsets.UTF_8)); + } + + @Test + @SneakyThrows + void readInvalidString() + { + final TypeAdapter binaryAdapter = new ODataBinaryAdapter(); + + assertThat(binaryAdapter.fromJson("\"_-/!§$&/()\"")).isNull(); + } + + @Test + void writeFromByteArray() + { + final String testText = "Hello World"; + final String base64encodedString = + Base64.getEncoder().encodeToString(testText.getBytes(StandardCharsets.UTF_8)); + + final TypeAdapter binaryAdapter = new ODataBinaryAdapter(); + + final String writtenResult = binaryAdapter.toJson(testText.getBytes(StandardCharsets.UTF_8)); + + assertThat(writtenResult).isEqualTo("\"" + base64encodedString + "\""); + } + + @Test + void nonStringValueGetsIgnored() + throws IOException + { + final TypeAdapter binaryAdapter = new ODataBinaryAdapter(); + + assertThat(binaryAdapter.fromJson("123")).isNull(); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataCustomFieldAdapterTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataCustomFieldAdapterTest.java new file mode 100644 index 0000000000..e8b8476b51 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataCustomFieldAdapterTest.java @@ -0,0 +1,216 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.io.StringReader; +import java.time.Instant; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.TimeZone; + +import org.junit.jupiter.api.Test; + +import com.google.gson.Gson; +import com.google.gson.stream.JsonReader; + +class ODataCustomFieldAdapterTest +{ + private static final Gson GSON = new Gson(); + + @Test + void testReadInteger() + throws IOException + { + final JsonReader jsonReader = new JsonReader(new StringReader(String.valueOf(Integer.MAX_VALUE))); + final ODataCustomFieldAdapter sut = new ODataCustomFieldAdapter(GSON); + + assertThat(sut.read(jsonReader)).isEqualTo(Integer.MAX_VALUE); + } + + @Test + void testReadLong() + throws IOException + { + final JsonReader jsonReader = new JsonReader(new StringReader(String.valueOf(Long.MAX_VALUE))); + final ODataCustomFieldAdapter sut = new ODataCustomFieldAdapter(GSON); + + assertThat(sut.read(jsonReader)).isEqualTo(Long.MAX_VALUE); + } + + @Test + void testReadDouble() + throws IOException + { + final JsonReader jsonReader = new JsonReader(new StringReader(String.valueOf(Double.MAX_VALUE))); + final ODataCustomFieldAdapter sut = new ODataCustomFieldAdapter(GSON); + + assertThat(sut.read(jsonReader)).isEqualTo(Double.MAX_VALUE); + } + + @Test + void testReadBoolean() + throws IOException + { + final JsonReader jsonReader = new JsonReader(new StringReader(String.valueOf(true))); + final ODataCustomFieldAdapter sut = new ODataCustomFieldAdapter(GSON); + + assertThat(sut.read(jsonReader)).isEqualTo(true); + } + + @Test + void testReadParsesDateString() + throws IOException + { + final JsonReader jsonReader = new JsonReader(new StringReader("\"/Date(1643775600000)/\"")); + final ODataCustomFieldAdapter sut = new ODataCustomFieldAdapter(GSON); + + final Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + calendar.setTime(Date.from(Instant.parse("2022-02-02T04:20:00Z"))); + + assertThat(sut.read(jsonReader)).isEqualTo(calendar); + } + + @Test + void testReadMalformedDateStringLeadsToNull() + throws IOException + { + // The given timestamp exceeds the allowed maximum while still conforming to the regex used to match Date strings. + // ==> The input is a "malformed" Date string. + // Therefore, parsing fails and the adapter should return null. + final JsonReader jsonReader = new JsonReader(new StringReader("\"/Date(" + Long.MAX_VALUE + "9)/\"")); + final ODataCustomFieldAdapter sut = new ODataCustomFieldAdapter(GSON); + + assertThat(sut.read(jsonReader)).isNull(); + } + + @Test + void testReadDoesntParseOffsetDateTimeString() + throws IOException + { + final JsonReader jsonReader = new JsonReader(new StringReader("\"/Date(2022-02-02T04:20:00Z)/\"")); + final ODataCustomFieldAdapter sut = new ODataCustomFieldAdapter(GSON); + + assertThat(sut.read(jsonReader)).isEqualTo("/Date(2022-02-02T04:20:00Z)/"); + } + + @Test + void testReadString() + throws IOException + { + final JsonReader jsonReader = new JsonReader(new StringReader("\"foo\"")); + final ODataCustomFieldAdapter sut = new ODataCustomFieldAdapter(GSON); + + assertThat(sut.read(jsonReader)).isEqualTo("foo"); + } + + @Test + void testReadArrayOfPrimitives() + throws IOException + { + final JsonReader jsonReader = new JsonReader(new StringReader("[\"foo\", \"bar\"]")); + final ODataCustomFieldAdapter sut = new ODataCustomFieldAdapter(GSON); + + assertThat(sut.read(jsonReader)).isEqualTo(Arrays.asList("foo", "bar")); + } + + @Test + void testReadObjectOfPrimitives() + throws IOException + { + final JsonReader jsonReader = new JsonReader(new StringReader("{\"foo\": \"bar\", \"baz\": 42}")); + final ODataCustomFieldAdapter sut = new ODataCustomFieldAdapter(GSON); + + final HashMap expected = new HashMap<>(); + expected.put("foo", "bar"); + expected.put("baz", 42); + + assertThat(sut.read(jsonReader)).isEqualTo(expected); + } + + @Test + void testReadDeferredObjectLeadsToNull() + throws IOException + { + final JsonReader jsonReader = new JsonReader(new StringReader("{\"__deferred\": \"some value\"}")); + final ODataCustomFieldAdapter sut = new ODataCustomFieldAdapter(GSON); + + assertThat(sut.read(jsonReader)).isNull(); + } + + @Test + void testReadSkipsMetadata() + throws IOException + { + final JsonReader jsonReader = + new JsonReader(new StringReader("{\"__metadata\": {\"key\": \"value\"}, \"foo\": \"bar\", \"baz\": 42}")); + final ODataCustomFieldAdapter sut = new ODataCustomFieldAdapter(GSON); + + final HashMap expected = new HashMap<>(); + expected.put("foo", "bar"); + expected.put("baz", 42); + + assertThat(sut.read(jsonReader)).isEqualTo(expected); + } + + @Test + void testReadReturnsResultsOnRootLevel() + throws IOException + { + final JsonReader jsonReader = new JsonReader(new StringReader("{\"results\": {\"key\": \"value\"}}")); + final ODataCustomFieldAdapter sut = new ODataCustomFieldAdapter(GSON); + + final HashMap expected = new HashMap<>(); + expected.put("key", "value"); + + assertThat(sut.read(jsonReader)).isEqualTo(expected); + } + + @Test + void testReadExpectsDeferredToBeTheLastObject() + { + final ODataCustomFieldAdapter sut = new ODataCustomFieldAdapter(GSON); + + { + // working example + final JsonReader jsonReader = + new JsonReader(new StringReader("{\"key\": \"value\", \"__deferred\": {\"foo\": \"bar\"}}")); + assertThatNoException().isThrownBy(() -> sut.read(jsonReader)); + } + + { + // failing example + final JsonReader jsonReader = + new JsonReader(new StringReader("{\"__deferred\": {\"foo\": \"bar\"}, \"key\": \"value\"}")); + assertThatThrownBy(() -> sut.read(jsonReader)) + .isExactlyInstanceOf(IllegalStateException.class) + .hasMessageContaining("Expected END_OBJECT but was NAME"); + } + } + + @Test + void testReadExpectsResultsToBeTheLastObject() + { + final ODataCustomFieldAdapter sut = new ODataCustomFieldAdapter(GSON); + + { + // working example + final JsonReader jsonReader = + new JsonReader(new StringReader("{\"key\": \"value\", \"results\": {\"foo\": \"bar\"}}")); + assertThatNoException().isThrownBy(() -> sut.read(jsonReader)); + } + + { + // failing example + final JsonReader jsonReader = + new JsonReader(new StringReader("{\"results\": {\"foo\": \"bar\"}, \"key\": \"value\"}")); + assertThatThrownBy(() -> sut.read(jsonReader)) + .isExactlyInstanceOf(IllegalStateException.class) + .hasMessageContaining("Expected END_OBJECT but was NAME"); + } + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataDateTimeStringCalendarConverterTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataDateTimeStringCalendarConverterTest.java new file mode 100644 index 0000000000..9b6486582b --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataDateTimeStringCalendarConverterTest.java @@ -0,0 +1,161 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.Calendar; +import java.util.Date; +import java.util.TimeZone; + +import org.junit.jupiter.api.Test; + +class ODataDateTimeStringCalendarConverterTest +{ + @Test + void testDateZeroToCalendar() + { + final ODataDateTimeStringCalendarConverter sut = new ODataDateTimeStringCalendarConverter(); + + final Calendar calendar = sut.toDomainNonNull("/Date(0)/").orNull(); + assertThat(calendar).isNotNull(); + assertThat(calendar.getTimeInMillis()).isEqualTo(0); + assertThat(calendar.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT")); + assertThat(calendar.toInstant()).isEqualTo(Instant.parse("1970-01-01T00:00:00Z")); + } + + @Test + void testDatePositiveToCalendar() + { + final ODataDateTimeStringCalendarConverter sut = new ODataDateTimeStringCalendarConverter(); + + final Calendar calendar = sut.toDomainNonNull("/Date(1643775600000)/").orNull(); + assertThat(calendar).isNotNull(); + assertThat(calendar.getTimeInMillis()).isEqualTo(1643775600000L); + assertThat(calendar.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT")); + assertThat(calendar.toInstant()).isEqualTo(Instant.parse("2022-02-02T04:20:00Z")); + } + + @Test + void testDateNegativeToCalendar() + { + final ODataDateTimeStringCalendarConverter sut = new ODataDateTimeStringCalendarConverter(); + + final Calendar calendar = sut.toDomainNonNull("/Date(-5480538000)/").orNull(); + assertThat(calendar).isNotNull(); + assertThat(calendar.getTimeInMillis()).isEqualTo(-5480538000L); + assertThat(calendar.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT")); + assertThat(calendar.toInstant()).isEqualTo(Instant.parse("1969-10-29T13:37:42Z")); + } + + @Test + void testDateLimitsToCalendar() + { + final ODataDateTimeStringCalendarConverter sut = new ODataDateTimeStringCalendarConverter(); + + // max + assertThat(sut.toDomainNonNull("/Date(" + Long.MAX_VALUE + ")/").isConvertible()).isTrue(); + // > max + assertThat(sut.toDomainNonNull("/Date(" + Long.MAX_VALUE + "9)/").isConvertible()).isFalse(); + // min + assertThat(sut.toDomainNonNull("/Date(" + Long.MIN_VALUE + ")/").isConvertible()).isTrue(); + // < min + assertThat(sut.toDomainNonNull("/Date(" + Long.MIN_VALUE + "9)/").isConvertible()).isFalse(); + } + + @Test + void testInvalidStringCannotBeConverted() + { + final ODataDateTimeStringCalendarConverter sut = new ODataDateTimeStringCalendarConverter(); + + assertThat(sut.toDomainNonNull("").isNotConvertible()).isTrue(); + assertThat(sut.toDomainNonNull("Not a number").isNotConvertible()).isTrue(); + assertThat(sut.toDomainNonNull("0").isNotConvertible()).isTrue(); + assertThat(sut.toDomainNonNull("-1").isNotConvertible()).isTrue(); + assertThat(sut.toDomainNonNull("1").isNotConvertible()).isTrue(); + assertThat(sut.toDomainNonNull("Date()").isNotConvertible()).isTrue(); + assertThat(sut.toDomainNonNull("/Date()/").isNotConvertible()).isTrue(); + assertThat(sut.toDomainNonNull("/date(0)/").isNotConvertible()).isTrue(); + assertThat(sut.toDomainNonNull("/Date(-)/").isNotConvertible()).isTrue(); + assertThat(sut.toDomainNonNull("/Date(+0)/").isNotConvertible()).isTrue(); + } + + @Test + void testCalendarZeroToString() + { + final Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + calendar.setTime(Date.from(Instant.parse("1970-01-01T00:00:00Z"))); + + final ODataDateTimeStringCalendarConverter sut = new ODataDateTimeStringCalendarConverter(); + + final String dateString = sut.fromDomainNonNull(calendar).orNull(); + assertThat(dateString).isNotNull(); + assertThat(dateString).isEqualTo("/Date(0)/"); + } + + @Test + void testCalendarZeroWithOffsetToString() + { + final Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT+01:00")); + calendar.setTime(Date.from(OffsetDateTime.parse("1970-01-01T00:00:00+01:00").toInstant())); + + final ODataDateTimeStringCalendarConverter sut = new ODataDateTimeStringCalendarConverter(); + + final String dateString = sut.fromDomainNonNull(calendar).orNull(); + assertThat(dateString).isNotNull(); + assertThat(dateString).isEqualTo("/Date(-3600000)/"); + } + + @Test + void testCalendarPositiveToString() + { + final Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + calendar.setTime(Date.from(Instant.parse("2022-02-02T04:20:00Z"))); + + final ODataDateTimeStringCalendarConverter sut = new ODataDateTimeStringCalendarConverter(); + + final String dateString = sut.fromDomainNonNull(calendar).orNull(); + assertThat(dateString).isNotNull(); + assertThat(dateString).isEqualTo("/Date(1643775600000)/"); + } + + @Test + void testCalendarPositiveWithOffsetToString() + { + final Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT-00:30")); + calendar.setTime(Date.from(OffsetDateTime.parse("2022-02-02T04:20:00-00:30").toInstant())); + + final ODataDateTimeStringCalendarConverter sut = new ODataDateTimeStringCalendarConverter(); + + final String dateString = sut.fromDomainNonNull(calendar).orNull(); + assertThat(dateString).isNotNull(); + assertThat(dateString).isEqualTo("/Date(1643777400000)/"); + } + + @Test + void testCalendarNegativeToString() + { + final Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + calendar.setTime(Date.from(Instant.parse("1969-10-29T13:37:42Z"))); + + final ODataDateTimeStringCalendarConverter sut = new ODataDateTimeStringCalendarConverter(); + + final String dateString = sut.fromDomainNonNull(calendar).orNull(); + assertThat(dateString).isNotNull(); + assertThat(dateString).isEqualTo("/Date(-5480538000)/"); + } + + @Test + void testCalendarNegativeWithOffsetToString() + { + final Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT+01:45")); + calendar.setTime(Date.from(OffsetDateTime.parse("1969-10-29T13:37:42+01:45").toInstant())); + + final ODataDateTimeStringCalendarConverter sut = new ODataDateTimeStringCalendarConverter(); + + final String dateString = sut.fromDomainNonNull(calendar).orNull(); + assertThat(dateString).isNotNull(); + assertThat(dateString).isEqualTo("/Date(-5486838000)/"); + } + +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataPrimitiveAdapterTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataPrimitiveAdapterTest.java new file mode 100644 index 0000000000..dc2eafea11 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ODataPrimitiveAdapterTest.java @@ -0,0 +1,260 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.Month; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.UUID; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.datamodel.odata.helper.VdmEntity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +class ODataPrimitiveAdapterTest +{ + private static final String primitivesEntityInput = """ + { + "EdmBinaryProperty" : "Rk9PIEJBUg==", + "EdmBooleanProperty" : true, + "EdmByteProperty" : 200, + "EdmDateTimeProperty" : "/Date(346833000)/", + "EdmDateTimeOffsetProperty" : "/Date(346833000-300)/", + "EdmDecimalProperty" : 9.223372036854776E18, + "EdmDoubleProperty" : 3.1415926535897E93, + "EdmGuidProperty" : "00000000-1111-2222-3333-444444444444", + "EdmInt16Property" : 1980, + "EdmInt32Property" : 16777216, + "EdmInt64Property" : 9223372036854775800, + "EdmSByteProperty" : -120, + "EdmSingleProperty" : 3.14, + "EdmStringProperty" : "TEST STRING", + "EdmTimeProperty" : "PT07H30M00S" + } + """; + + private static final String expectedSerializedEntity = """ + {\ + "versionIdentifier":null,\ + "EdmBinaryProperty":"Rk9PIEJBUg==",\ + "EdmBooleanProperty":true,\ + "EdmByteProperty":200,\ + "EdmDateTimeProperty":"/Date(1649342635567)/",\ + "EdmDateTimeOffsetProperty":"/Date(1649342635567-0300)/",\ + "EdmDecimalProperty":9.223372036854776E+18,\ + "EdmDoubleProperty":3.1415926535897E93,\ + "EdmGuidProperty":"00000000-1111-2222-3333-444444444444",\ + "EdmInt16Property":1980,\ + "EdmInt32Property":16777216,\ + "EdmInt64Property":9223372036854775800,\ + "EdmSByteProperty":-120,\ + "EdmSingleProperty":3.14,\ + "EdmStringProperty":"TEST STRING",\ + "EdmTimeProperty":"PT14H25M34.567S"\ + }\ + """; + + @Builder + @Data + @NoArgsConstructor + @AllArgsConstructor + @ToString( doNotUseGetters = true, callSuper = true ) + @EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataVdmEntityAdapterFactory.class ) + public static class PrimitivesEntity extends VdmEntity + { + @SerializedName( "EdmBinaryProperty" ) + @JsonProperty( "EdmBinaryProperty" ) + @Nullable + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataBinaryAdapter.class ) + private byte[] edmBinaryProperty; + + @SerializedName( "EdmBooleanProperty" ) + @JsonProperty( "EdmBooleanProperty" ) + @Nullable + @JsonAdapter( com.sap.cloud.sdk.s4hana.datamodel.odata.adapter.ODataBooleanAdapter.class ) + private Boolean edmBooleanProperty; + + @SerializedName( "EdmByteProperty" ) + @JsonProperty( "EdmByteProperty" ) + @Nullable + private Short edmByteProperty; + + @SerializedName( "EdmDateTimeProperty" ) + @JsonProperty( "EdmDateTimeProperty" ) + @Nullable + @JsonAdapter( LocalDateTimeAdapter.class ) + @JsonSerialize( using = JacksonLocalDateTimeSerializer.class ) + @JsonDeserialize( using = JacksonLocalDateTimeDeserializer.class ) + private LocalDateTime edmDateTimeProperty; + + @SerializedName( "EdmDateTimeOffsetProperty" ) + @JsonProperty( "EdmDateTimeOffsetProperty" ) + @Nullable + @JsonAdapter( ZonedDateTimeAdapter.class ) + @JsonSerialize( using = JacksonZonedDateTimeSerializer.class ) + @JsonDeserialize( using = JacksonZonedDateTimeDeserializer.class ) + private ZonedDateTime edmDateTimeOffsetProperty; + + @SerializedName( "EdmDecimalProperty" ) + @JsonProperty( "EdmDecimalProperty" ) + @Nullable + private BigDecimal edmDecimalProperty; + + @SerializedName( "EdmDoubleProperty" ) + @JsonProperty( "EdmDoubleProperty" ) + @Nullable + private Double edmDoubleProperty; + + @SerializedName( "EdmGuidProperty" ) + @JsonProperty( "EdmGuidProperty" ) + @Nullable + private UUID edmGuidProperty; + + @SerializedName( "EdmInt16Property" ) + @JsonProperty( "EdmInt16Property" ) + @Nullable + private Short edmInt16Property; + + @SerializedName( "EdmInt32Property" ) + @JsonProperty( "EdmInt32Property" ) + @Nullable + private Integer edmInt32Property; + + @SerializedName( "EdmInt64Property" ) + @JsonProperty( "EdmInt64Property" ) + @Nullable + private Long edmInt64Property; + + @SerializedName( "EdmSByteProperty" ) + @JsonProperty( "EdmSByteProperty" ) + @Nullable + private Byte edmSByteProperty; + + @SerializedName( "EdmSingleProperty" ) + @JsonProperty( "EdmSingleProperty" ) + @Nullable + private Float edmSingleProperty; + + @SerializedName( "EdmStringProperty" ) + @JsonProperty( "EdmStringProperty" ) + @Nullable + private String edmStringProperty; + + @SerializedName( "EdmTimeProperty" ) + @JsonProperty( "EdmTimeProperty" ) + @Nullable + @JsonSerialize( using = JacksonLocalTimeSerializer.class ) + @JsonDeserialize( using = JacksonLocalTimeDeserializer.class ) + @JsonAdapter( LocalTimeAdapter.class ) + private LocalTime edmTimeProperty; + + @Getter + @Setter + @Builder.Default + private transient String servicePath = "NOT_APPLICABLE"; + + @Override + protected String getEntityCollection() + { + return "PrimitivesCollection"; + } + + @Nonnull + @Override + public Class getType() + { + return PrimitivesEntity.class; + } + } + + @Test + void testGsonDeserialization() + { + final PrimitivesEntity primitivesEntity = new Gson().fromJson(primitivesEntityInput, PrimitivesEntity.class); + + assertThat(primitivesEntity).isNotNull(); + + assertThat(primitivesEntity.getEdmBinaryProperty()).containsExactly('F', 'O', 'O', ' ', 'B', 'A', 'R'); + assertThat(primitivesEntity.getEdmBooleanProperty()).isTrue(); + assertThat(primitivesEntity.getEdmByteProperty()).isEqualTo((short) 200); + + assertThat(primitivesEntity.getEdmDecimalProperty()).isEqualTo(new BigDecimal("9.223372036854776E18")); + assertThat(primitivesEntity.getEdmDoubleProperty()).isEqualTo(3.1415926535897E+93d); + assertThat(primitivesEntity.getEdmGuidProperty()) + .isEqualTo(UUID.fromString("00000000-1111-2222-3333-444444444444")); + assertThat(primitivesEntity.getEdmInt16Property()).isEqualTo((short) 1980); + assertThat(primitivesEntity.getEdmInt32Property()).isEqualTo(16777216); + assertThat(primitivesEntity.getEdmInt64Property()).isEqualTo(9223372036854775800L); + assertThat(primitivesEntity.getEdmSByteProperty()).isEqualTo((byte) -120); + assertThat(primitivesEntity.getEdmSingleProperty()).isEqualTo(3.14f); + assertThat(primitivesEntity.getEdmStringProperty()).isEqualTo("TEST STRING"); + assertThat(primitivesEntity.getEdmTimeProperty()).isEqualTo("07:30"); + } + + @Test + void testGsonSerialization() + throws Exception + { + final LocalDateTime expectedEdmDateTimeProperty = + LocalDateTime.of(2022, Month.APRIL, 7, 14, 43, 55, 567 * 1000000); + + final ZonedDateTime expectedEdmDateTimeOffsetProperty = + expectedEdmDateTimeProperty.atZone(ZoneId.of("GMT-05:00")); + + final LocalTime expectedEdmTimeProperty = LocalTime.of(14, 25, 34, 567 * 1000000); + + final PrimitivesEntity primitivesEntity = + PrimitivesEntity + .builder() + .edmBinaryProperty(new byte[] { 'F', 'O', 'O', ' ', 'B', 'A', 'R' }) + .edmBooleanProperty(true) + .edmByteProperty((short) 200) + .edmDateTimeProperty(expectedEdmDateTimeProperty) + .edmDateTimeOffsetProperty(expectedEdmDateTimeOffsetProperty) + .edmDecimalProperty(new BigDecimal("9.223372036854776E18")) + .edmDoubleProperty(3.1415926535897E93) + .edmGuidProperty(UUID.fromString("00000000-1111-2222-3333-444444444444")) + .edmInt16Property((short) 1980) + .edmInt32Property(16777216) + .edmInt64Property(9223372036854775800L) + .edmSByteProperty((byte) -120) + .edmSingleProperty(3.14f) + .edmStringProperty("TEST STRING") + .edmTimeProperty(expectedEdmTimeProperty) + .build(); + + // test gson + final GsonBuilder gsonBuilder = new GsonBuilder().serializeNulls().disableHtmlEscaping(); + final String actualGsonSerializedEntity = gsonBuilder.create().toJson(primitivesEntity); + assertThat(actualGsonSerializedEntity).isEqualTo(expectedSerializedEntity); + + // test jackson + final ObjectMapper jacksonMapper = new ObjectMapper(); + final String actualJacksonSerializedEntity = jacksonMapper.writeValueAsString(primitivesEntity); + assertThat(actualJacksonSerializedEntity).isEqualTo(expectedSerializedEntity); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ZonedDateTimeAdapterTest.java b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ZonedDateTimeAdapterTest.java new file mode 100644 index 0000000000..342577a681 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/s4hana/datamodel/odata/adapter/ZonedDateTimeAdapterTest.java @@ -0,0 +1,90 @@ +package com.sap.cloud.sdk.s4hana.datamodel.odata.adapter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Month; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +import org.junit.jupiter.api.Test; + +import com.google.gson.TypeAdapter; + +import lombok.SneakyThrows; + +class ZonedDateTimeAdapterTest +{ + @Test + @SneakyThrows + void standardFormatCases() + { + final TypeAdapter adapter = new ZonedDateTimeAdapter(); + + assertThat(adapter.fromJson("\"/Date(1649669575192)/\"")).isEqualTo("2022-04-11T09:32:55.192Z"); + assertThat(adapter.fromJson("\"/Date(1649669575192+0120)/\"")).isEqualTo("2022-04-11T09:32:55.192+02:00"); + } + + @Test + @SneakyThrows + void declaredOffsetTooLargeReturnsNull() + { + final TypeAdapter adapter = new ZonedDateTimeAdapter(); + + assertThat(adapter.fromJson("\"/Date(1649669575192+1441)/\"")).isNull(); + } + + @Test + @SneakyThrows + void invalidPatternReturnsNull() + { + final TypeAdapter adapter = new ZonedDateTimeAdapter(); + + assertThat(adapter.fromJson("\"Something that is not a date\"")).isNull(); + } + + @Test + @SneakyThrows + void isoPatternReturnsNull() + { + final TypeAdapter adapter = new ZonedDateTimeAdapter(); + + assertThat(adapter.fromJson("\"2022-04-11T12:47:14.1234567-02:30\"")).isNull(); + assertThat(adapter.fromJson("\"2022-04-11T12:47:14.1234567+4:30\"")).isNull(); + assertThat(adapter.fromJson("\"2022-04-11T12:47:14.1234567Z\"")).isNull(); + assertThat(adapter.fromJson("\"2022-04-11T12:47:14\"")).isNull(); + assertThat(adapter.fromJson("\"2022-04-11T12:47\"")).isNull(); + assertThat(adapter.fromJson("\"2-4-1T2:7\"")).isNull(); + } + + @Test + @SneakyThrows + void excessivelyLongNumberReturnsNull() + { + final TypeAdapter adapter = new ZonedDateTimeAdapter(); + + assertThat(adapter.fromJson("\"/Date(100000000000000000000000)/\"")).isNull(); + } + + @Test + @SneakyThrows + void nonStringValueReturnsNull() + { + final TypeAdapter adapter = new ZonedDateTimeAdapter(); + + assertThat(adapter.fromJson("1234")).isNull(); + } + + @Test + void writeZonedDateTime() + { + final TypeAdapter adapter = new ZonedDateTimeAdapter(); + + assertThat( + adapter.toJson(ZonedDateTime.of(2022, Month.APRIL.getValue(), 11, 11, 36, 55, 123000000, ZoneId.of("GMT")))) + .isEqualTo("\"/Date(1649677015123)/\""); + assertThat( + adapter + .toJson(ZonedDateTime.of(2022, Month.APRIL.getValue(), 11, 11, 36, 55, 123000000, ZoneId.of("GMT+2")))) + .isEqualTo("\"/Date(1649677015123+0120)/\""); + } +} diff --git a/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchFunctionImportTest/BatchRequestFunctionImportWithGet.txt b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchFunctionImportTest/BatchRequestFunctionImportWithGet.txt new file mode 100644 index 0000000000..32772d9ba7 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchFunctionImportTest/BatchRequestFunctionImportWithGet.txt @@ -0,0 +1,10 @@ +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 1 + +GET awesomeFunction?FirstName='John'&LastName='Doe' HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchFunctionImportTest/BatchRequestFunctionImportWithPost.txt b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchFunctionImportTest/BatchRequestFunctionImportWithPost.txt new file mode 100644 index 0000000000..436ae71527 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchFunctionImportTest/BatchRequestFunctionImportWithPost.txt @@ -0,0 +1,17 @@ +--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: 1 + +POST awesomeFunction?FirstName='John'&LastName='Doe' HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{} + +--changeset_00000000-0000-0000-0000-000000000002-- + +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchFunctionImportTest/BatchRequestFunctionImportWithPostWithCustomHeader.txt b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchFunctionImportTest/BatchRequestFunctionImportWithPostWithCustomHeader.txt new file mode 100644 index 0000000000..7c8d4ab875 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchFunctionImportTest/BatchRequestFunctionImportWithPostWithCustomHeader.txt @@ -0,0 +1,18 @@ +--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: 1 + +POST awesomeFunction?FirstName='John'&LastName='Doe' HTTP/1.1 +Accept: application/json +Content-Type: application/json +foo: bar + +{} + +--changeset_00000000-0000-0000-0000-000000000002-- + +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchFunctionImportTest/BatchResponse.txt b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchFunctionImportTest/BatchResponse.txt new file mode 100644 index 0000000000..cb7449970a --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchFunctionImportTest/BatchResponse.txt @@ -0,0 +1,90 @@ +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json + +{"d":{"results":[{"StringValue":"Foo","IntegerValue":42}]}} + +--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 + +HTTP/1.1 201 Created +Location: https://localhost/service/TestEntities('new') +Content-Type: application/json + +{"d":{"IntegerValue":1337}} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8cb4-- + +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json + +{"d":{"StringValue":"Foo","IntegerValue":9000}} + +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: multipart/mixed; boundary=changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000 + +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000 +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: text/plain + +{"value":42} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000 +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 204 No response. +Content-Type: application/json + +{"value":["Something","here"]} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000-- +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 400 Bad Request +Content-Type: application/json;charset=utf-8 +Content-Length: 1050 +dataserviceversion: 1.0 + +{"error":{"code":"ZCU/100","message":{"lang":"nl","value":"Service 0000000003 0000000010 niet gevonden voor operatie 0410"},"innererror":{"application":{"component_id":"","service_namespace":"/SAP/","service_id":"ZCU_PE_ORDER_SRV","service_version":"0001"},"transactionid":"23F2932D54040110E005FD84A23B406E","timestamp":"20201215151501.0634490","Error_Resolution":{"SAP_Transaction":"Run transaction /IWFND/ERROR_LOG on SAP Gateway hub system (System Alias ) 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)","Batch_SAP_Note":"See SAP Note 1869434 for details about working with $batch (https://service.sap.com/sap/support/notes/1869434)"},"errordetails":[{"code":"ZCU/100","message":"Service 0000000003 0000000010 niet gevonden voor operatie 0410","propertyref":"","severity":"error","target":""},{"code":"/IWBEP/CX_MGW_BUSI_EXCEPTION","message":"Fout bij wijzigen PE order.","propertyref":"","severity":"error","target":""}]}}} + +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json + +{"d":{"awesomeFunction":"awesomeStuff"}} + +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json + +{"d":{"awesomeFunction":["foo","bar"]}} + +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json + +{"d":{"IntegerValue":33,"StringValue":"Alice"}}} +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef-- diff --git a/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchRequestUnitTest/BatchRequest.txt b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchRequestUnitTest/BatchRequest.txt new file mode 100644 index 0000000000..7a25853f38 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchRequestUnitTest/BatchRequest.txt @@ -0,0 +1,83 @@ +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 1 + +GET Entities?$select=StringValue,IntegerValue&$top=10 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 Entities HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{"IntegerValue":12} + +--changeset_00000000-0000-0000-0000-000000000002-- + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 3 + +GET Entities(9000) HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: multipart/mixed;boundary=changeset_00000000-0000-0000-0000-000000000003 + +--changeset_00000000-0000-0000-0000-000000000003 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 4 + +PATCH Entities(13) HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{} + +--changeset_00000000-0000-0000-0000-000000000003 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 5 + +DELETE Entities(14) HTTP/1.1 +Accept: application/json + + +--changeset_00000000-0000-0000-0000-000000000003-- + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: multipart/mixed;boundary=changeset_00000000-0000-0000-0000-000000000004 + +--changeset_00000000-0000-0000-0000-000000000004 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 6 + +DELETE Entities(15) HTTP/1.1 +Accept: application/json + + +--changeset_00000000-0000-0000-0000-000000000004 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 7 + +DELETE Entities(16) HTTP/1.1 +Accept: application/json + + +--changeset_00000000-0000-0000-0000-000000000004-- + +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchRequestUnitTest/BatchRequestWithCustomHeaders.txt b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchRequestUnitTest/BatchRequestWithCustomHeaders.txt new file mode 100644 index 0000000000..4864bce1eb --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchRequestUnitTest/BatchRequestWithCustomHeaders.txt @@ -0,0 +1,90 @@ +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 1 + +GET Entities?$select=StringValue,IntegerValue&$top=10 HTTP/1.1 +Accept: application/json +header-read_all: read_all + + +--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 Entities HTTP/1.1 +Accept: application/json +Content-Type: application/json +header-create: create + +{"IntegerValue":12} + +--changeset_00000000-0000-0000-0000-000000000002-- + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 3 + +GET Entities(9000) HTTP/1.1 +Accept: application/json +header-read_by_key: read_by_key + + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: multipart/mixed;boundary=changeset_00000000-0000-0000-0000-000000000003 + +--changeset_00000000-0000-0000-0000-000000000003 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 4 + +PATCH Entities(13) HTTP/1.1 +Accept: application/json +Content-Type: application/json +header-update: update + +{} + +--changeset_00000000-0000-0000-0000-000000000003 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 5 + +DELETE Entities(14) HTTP/1.1 +Accept: application/json +header-delete: delete-entity14 + + +--changeset_00000000-0000-0000-0000-000000000003-- + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: multipart/mixed;boundary=changeset_00000000-0000-0000-0000-000000000004 + +--changeset_00000000-0000-0000-0000-000000000004 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 6 + +DELETE Entities(15) HTTP/1.1 +Accept: application/json +header-delete: delete-entity15 + + +--changeset_00000000-0000-0000-0000-000000000004 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 7 + +DELETE Entities(16) HTTP/1.1 +Accept: application/json +header-delete: delete-entity16 + + +--changeset_00000000-0000-0000-0000-000000000004-- + +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchRequestUnitTest/BatchResponse.txt b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchRequestUnitTest/BatchResponse.txt new file mode 100644 index 0000000000..68718fe227 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODataV2BatchRequestUnitTest/BatchResponse.txt @@ -0,0 +1,63 @@ +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json + +{"d":{"results":[{"StringValue":"Foo","IntegerValue":42}]}} + +--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 + +HTTP/1.1 201 Created +Location: https://localhost/service/TestEntities('new') +Content-Type: application/json + +{"d":{"IntegerValue":1337}} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8cb4-- + +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json + +{"d":{"StringValue":"Foo","IntegerValue":9000}} + +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: multipart/mixed; boundary=changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000 + +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000 +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: text/plain + +{"value":42} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000 +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 204 No response. +Content-Type: application/json + +{"value":["Something","here"]} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000-- +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 400 Bad Request +Content-Type: application/json;charset=utf-8 +Content-Length: 1050 +dataserviceversion: 1.0 + +{"error":{"code":"ZCU/100","message":{"lang":"nl","value":"Service 0000000003 0000000010 niet gevonden voor operatie 0410"},"innererror":{"application":{"component_id":"","service_namespace":"/SAP/","service_id":"ZCU_PE_ORDER_SRV","service_version":"0001"},"transactionid":"23F2932D54040110E005FD84A23B406E","timestamp":"20201215151501.0634490","Error_Resolution":{"SAP_Transaction":"Run transaction /IWFND/ERROR_LOG on SAP Gateway hub system (System Alias ) 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)","Batch_SAP_Note":"See SAP Note 1869434 for details about working with $batch (https://service.sap.com/sap/support/notes/1869434)"},"errordetails":[{"code":"ZCU/100","message":"Service 0000000003 0000000010 niet gevonden voor operatie 0410","propertyref":"","severity":"error","target":""},{"code":"/IWBEP/CX_MGW_BUSI_EXCEPTION","message":"Fout bij wijzigen PE order.","propertyref":"","severity":"error","target":""}]}}} +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef-- diff --git a/datamodel/odata-core-apache-httpclient5/src/test/resources/ODatav2BatchConnectionTest/BatchResponseWithChangeset.txt b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODatav2BatchConnectionTest/BatchResponseWithChangeset.txt new file mode 100644 index 0000000000..040aa489ca --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODatav2BatchConnectionTest/BatchResponseWithChangeset.txt @@ -0,0 +1,62 @@ +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json + +{"d":{"results":[{"StringValue":"Foo","IntegerValue":42}]}} + +--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 + +HTTP/1.1 201 Created +Location: https://localhost/service/TestEntities('new') +Content-Type: application/json + +{"d":{"IntegerValue":1337}} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8cb4-- + +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json + +{"d":{"StringValue":"Foo","IntegerValue":9000}} + +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: multipart/mixed; boundary=changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000 + +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000 +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: text/plain + +{"value":42} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000 +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 204 No response. +Content-Type: application/json + +{"value":["Something","here"]} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000 +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 201 Created +Location: https://localhost/service/TestEntities('new') +Content-Type: application/json + +{"d":{"IntegerValue":1447}} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000-- +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef-- diff --git a/datamodel/odata-core-apache-httpclient5/src/test/resources/ODatav2BatchConnectionTest/BatchResponseWithError.txt b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODatav2BatchConnectionTest/BatchResponseWithError.txt new file mode 100644 index 0000000000..7da32711f4 --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODatav2BatchConnectionTest/BatchResponseWithError.txt @@ -0,0 +1,29 @@ +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json + +{"d":{"results":[{"StringValue":"Foo","IntegerValue":42}]}} + +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json + +{"d":{"StringValue":"Foo","IntegerValue":9000}} + +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 400 Bad Request +Content-Type: application/json;charset=utf-8 +Content-Length: 1050 +dataserviceversion: 1.0 + +{"error":{"code":"ZCU/100","message":{"lang":"nl","value":"Service 0000000003 0000000010 niet gevonden voor operatie 0410"},"innererror":{"application":{"component_id":"","service_namespace":"/SAP/","service_id":"ZCU_PE_ORDER_SRV","service_version":"0001"},"transactionid":"23F2932D54040110E005FD84A23B406E","timestamp":"20201215151501.0634490","Error_Resolution":{"SAP_Transaction":"Run transaction /IWFND/ERROR_LOG on SAP Gateway hub system (System Alias ) 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)","Batch_SAP_Note":"See SAP Note 1869434 for details about working with $batch (https://service.sap.com/sap/support/notes/1869434)"},"errordetails":[{"code":"ZCU/100","message":"Service 0000000003 0000000010 niet gevonden voor operatie 0410","propertyref":"","severity":"error","target":""},{"code":"/IWBEP/CX_MGW_BUSI_EXCEPTION","message":"Fout bij wijzigen PE order.","propertyref":"","severity":"error","target":""}]}}} +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef-- diff --git a/datamodel/odata-core-apache-httpclient5/src/test/resources/ODatav2BatchConnectionTest/BatchResponseWithoutChangeset.txt b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODatav2BatchConnectionTest/BatchResponseWithoutChangeset.txt new file mode 100644 index 0000000000..cca2d42f9b --- /dev/null +++ b/datamodel/odata-core-apache-httpclient5/src/test/resources/ODatav2BatchConnectionTest/BatchResponseWithoutChangeset.txt @@ -0,0 +1,19 @@ +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json + +{"d":{"results":[{"StringValue":"Foo","IntegerValue":42}]}} + +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json + +{"d":{"StringValue":"Foo","IntegerValue":9000}} + +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef-- diff --git a/datamodel/odata-v4-core-apache-httpclient5/pom.xml b/datamodel/odata-v4-core-apache-httpclient5/pom.xml new file mode 100644 index 0000000000..cb906a52f8 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/pom.xml @@ -0,0 +1,145 @@ + + + 4.0.0 + + com.sap.cloud.sdk.datamodel + datamodel-parent + 5.35.0-SNAPSHOT + + odata-v4-core-apache-httpclient5 + jar + Data Model - OData V4 Services - Core (HttpClient 5) + OData V4 Services data model (VDM) - core classes 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 + + + + false + true + + + + com.sap.cloud.sdk.datamodel + odata-client-apache-httpclient5 + ${project.version} + + + com.sap.cloud.sdk.cloudplatform + cloudplatform-core + + + com.sap.cloud.sdk.cloudplatform + cloudplatform-connectivity + + + com.sap.cloud.sdk.cloudplatform + connectivity-apache-httpclient5 + + + com.sap.cloud.sdk.datamodel + fluent-result + + + org.slf4j + slf4j-api + + + com.google.guava + guava + + + com.google.code.gson + gson + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-databind + + + org.apache.httpcomponents.core5 + httpcore5 + + + org.apache.httpcomponents.client5 + httpclient5 + + + io.vavr + vavr + + + + org.projectlombok + lombok + provided + + + + com.sap.cloud.sdk + testutil + test + + + org.assertj + assertj-core + test + + + org.skyscreamer + jsonassert + test + + + org.mockito + mockito-core + test + + + org.wiremock + wiremock + test + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + commons-beanutils:commons-beanutils + + + + + + diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/CustomBigDecimalTypeAdapter.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/CustomBigDecimalTypeAdapter.java new file mode 100644 index 0000000000..eeb7f604b8 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/CustomBigDecimalTypeAdapter.java @@ -0,0 +1,50 @@ +package com.sap.cloud.sdk.datamodel.odatav4.adapter; + +import java.io.IOException; +import java.math.BigDecimal; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.JsonSyntaxException; +import com.google.gson.TypeAdapter; +import com.google.gson.internal.LazilyParsedNumber; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +/** + * Custom type adapter for BigDecimal that doesn't serialise BigDecimal into it's scientific notation. For e.g. if the + * value of the attribute is 0.000000002, using this serializer ensures that it doesn't get converted to 2E-9. + */ +class CustomBigDecimalTypeAdapter extends TypeAdapter +{ + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final BigDecimal value ) + throws IOException + { + if( value == null ) { + out.nullValue(); + } else { + final Number number = new LazilyParsedNumber(value.toPlainString()); + out.value(number); + } + } + + @Override + @Nullable + public BigDecimal read( @Nonnull final JsonReader in ) + throws IOException + { + if( in.peek() == JsonToken.NULL ) { + in.nextNull(); + return null; + } + try { + return new BigDecimal(in.nextString()); + } + catch( final NumberFormatException e ) { + throw new JsonSyntaxException("The string could not be parsed as decimal.", e); + } + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonCustomFieldAdapter.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonCustomFieldAdapter.java new file mode 100644 index 0000000000..76f4ac1de5 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonCustomFieldAdapter.java @@ -0,0 +1,64 @@ +package com.sap.cloud.sdk.datamodel.odatav4.adapter; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.JsonPrimitive; +import com.google.gson.TypeAdapter; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +import lombok.RequiredArgsConstructor; + +/** + * For internal use only by data model classes. + */ +@RequiredArgsConstructor +public class GsonCustomFieldAdapter extends TypeAdapter +{ + private static final Type MAP_TYPE = new TypeToken>() + { + }.getType(); + + @Nonnull + private final Gson gson; + + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final Object value ) + { + if( value instanceof String ) { + gson.toJson(new JsonPrimitive((String) value), out); + } + if( value instanceof Number ) { + gson.toJson(new JsonPrimitive((Number) value), out); + } + if( value instanceof Boolean ) { + gson.toJson(new JsonPrimitive((Boolean) value), out); + } + gson.toJson(value, MAP_TYPE, out); + } + + @Override + @Nullable + public Object read( @Nonnull final JsonReader in ) + throws IOException + { + if( JsonToken.STRING == in.peek() ) { + return in.nextString(); + } + if( JsonToken.NUMBER == in.peek() ) { + return in.nextDouble(); + } + if( JsonToken.BOOLEAN == in.peek() ) { + return in.nextBoolean(); + } + return gson.fromJson(in, MAP_TYPE); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonODataConverterAdapter.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonODataConverterAdapter.java new file mode 100644 index 0000000000..71344b6657 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonODataConverterAdapter.java @@ -0,0 +1,56 @@ +package com.sap.cloud.sdk.datamodel.odatav4.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.typeconverter.AbstractTypeConverter; +import com.sap.cloud.sdk.typeconverter.ConvertedObject; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor( staticName = "of" ) +class GsonODataConverterAdapter extends TypeAdapter +{ + private final AbstractTypeConverter converter; + + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final JavaT value ) + throws IOException + { + if( value == null ) { + out.nullValue(); + } else { + final ConvertedObject jsonValue = converter.toDomain(value); + if( jsonValue.isNotConvertible() ) { + log.warn("Not serializable: {}", value); + } else { + out.value(jsonValue.get()); + } + } + } + + @Override + @Nullable + public JavaT read( @Nonnull final JsonReader in ) + throws IOException + { + if( in.peek() == JsonToken.NULL ) { + in.nextNull(); + return null; + } + final String value = in.nextString(); + final ConvertedObject convertedObject = converter.fromDomain(value); + if( convertedObject.isNotConvertible() ) { + log.warn("Not deserializable: {}", value); + } + return convertedObject.orNull(); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonVdmAdapterFactory.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonVdmAdapterFactory.java new file mode 100644 index 0000000000..cc6bd7ef54 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonVdmAdapterFactory.java @@ -0,0 +1,47 @@ +package com.sap.cloud.sdk.datamodel.odatav4.adapter; + +import java.math.BigDecimal; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEnum; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmObject; + +import lombok.extern.slf4j.Slf4j; + +/** + * General purpose VDM adapter factory. + */ +@Slf4j +public class GsonVdmAdapterFactory implements TypeAdapterFactory +{ + @SuppressWarnings( "unchecked" ) + @Override + @Nullable + public TypeAdapter create( @Nonnull final Gson gson, @Nonnull final TypeToken type ) + { + final Class rawType = type.getRawType(); + if( VdmEnum.class.isAssignableFrom(rawType) ) { + return (TypeAdapter) new GsonVdmEnumAdapter<>((Class) rawType); + } + if( VdmObject.class.isAssignableFrom(rawType) ) { + return (TypeAdapter) new GsonVdmEntityAdapter<>(this, gson, rawType); + } + if( BigDecimal.class.isAssignableFrom(rawType) ) { + return (TypeAdapter) new CustomBigDecimalTypeAdapter(); + } + + for( final ODataGenericConverter converter : ODataGenericConverter.DEFAULT_CONVERTERS ) { + if( converter.getType().isAssignableFrom(rawType) ) { + return (TypeAdapter) GsonODataConverterAdapter.of(converter); + } + } + log.trace("Could not find custom type adapter for type {}.", rawType); + return null; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonVdmEntityAdapter.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonVdmEntityAdapter.java new file mode 100644 index 0000000000..e256f1bbf3 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonVdmEntityAdapter.java @@ -0,0 +1,350 @@ +package com.sap.cloud.sdk.datamodel.odatav4.adapter; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEntity; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmObject; +import com.sap.cloud.sdk.result.ElementName; + +import io.vavr.control.Option; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * For internal use only by data model classes + * + * @param + * The entity type. + */ +@Slf4j +public class GsonVdmEntityAdapter extends TypeAdapter> +{ + @Nonnull + private final Gson gson; + @Nonnull + private final Class entityRawType; + @Nonnull + private final TypeAdapterFactory adapterFactory; + + @Nullable + private TypeAdapter> delegateAdapter = null; + @Nullable + private GsonVdmEntityAdapter superClassAdapter = null; + + @Nonnull + private final Map entityProperties; + @Nonnull + private final TypeAdapter customFieldAdapter; + + @AllArgsConstructor + private static class PropertySerializationInfo + { + @Getter + private final Field javaField; + @Getter + private final TypeAdapter fieldAdapter; + } + + private TypeAdapter getAdapterFromField( final Field entityField, final Gson gson ) + { + if( entityField.isAnnotationPresent(JsonAdapter.class) ) { + try { + return (TypeAdapter) entityField + .getAnnotation(JsonAdapter.class) + .value() + .getDeclaredConstructor() + .newInstance(); + } + catch( final + InstantiationException + | IllegalAccessException + | NoSuchMethodException + | InvocationTargetException e ) { + log.warn("Could not instantiate the field '" + entityField.getName() + "'.", e); + } + } + if( Iterable.class.isAssignableFrom(entityField.getType()) ) { + final ParameterizedType entityFieldTypeParams = (ParameterizedType) entityField.getGenericType(); + final Type listEntityType = entityFieldTypeParams.getActualTypeArguments()[0]; + final TypeAdapter innerTypeAdapter = gson.getAdapter(TypeToken.get(listEntityType)); + return new GsonVdmEntityListAdapter<>(gson, innerTypeAdapter); + } + final TypeAdapter fieldAdapter = adapterFactory.create(gson, TypeToken.get(entityField.getType())); + if( fieldAdapter != null ) { + return fieldAdapter; + } + + return gson.getAdapter(entityField.getType()); + } + + /** + * For internal use only by data model classes. + * + * @param adapterFactory + * The adapter type factory. + * @param gson + * The GSON instance. + * @param entityRawType + * The entity type reference. + */ + @SuppressWarnings( "unchecked" ) + public GsonVdmEntityAdapter( + @Nonnull final TypeAdapterFactory adapterFactory, + @Nonnull final Gson gson, + @Nonnull final Class entityRawType ) + { + this.gson = gson; + this.entityRawType = entityRawType; + this.adapterFactory = adapterFactory; + + entityProperties = new LinkedHashMap<>(); + for( final Field entityField : entityRawType.getDeclaredFields() ) { + if( entityField.isAnnotationPresent(ElementName.class) + || entityField.isAnnotationPresent(SerializedName.class) ) { + + TypeAdapter fieldAdapter = null; + + // Don't get the adapter yet if the field is a navigation property. + // Otherwise an infinite loop can happen if there are circular navigation properties. + if( !VdmObject.class.isAssignableFrom(entityField.getType()) + && !Iterable.class.isAssignableFrom(entityField.getType()) ) { + + fieldAdapter = getAdapterFromField(entityField, gson); + } + + final String entityFieldKey = + entityField.isAnnotationPresent(ElementName.class) + ? entityField.getAnnotation(ElementName.class).value() + : entityField.getAnnotation(SerializedName.class).value(); + + entityProperties.put(entityFieldKey, new PropertySerializationInfo(entityField, fieldAdapter)); + } + } + + customFieldAdapter = new GsonCustomFieldAdapter(gson); + + final Class entityRawSuperType = entityRawType.getSuperclass(); + if( Object.class == entityRawSuperType ) { + delegateAdapter = + (TypeAdapter>) gson.getDelegateAdapter(adapterFactory, TypeToken.get(entityRawType)); + } else { + superClassAdapter = new GsonVdmEntityAdapter<>(adapterFactory, gson, entityRawSuperType); + } + } + + /** + * For internal use only by data model classes. + * + * @param jsonReader + * The JsonReader reference. + * @return The deserialized entity instance. + * @throws IOException + * When deserialization failed. + */ + @Override + @Nullable + public VdmObject read( @Nonnull final JsonReader jsonReader ) + throws IOException + { + try { + @SuppressWarnings( "unchecked" ) + final VdmObject entity = (VdmObject) entityRawType.getDeclaredConstructor().newInstance(); + + if( jsonReader.peek() == JsonToken.BEGIN_OBJECT ) { + jsonReader.beginObject(); + + while( jsonReader.hasNext() ) { + final String propertyKey = jsonReader.nextName(); + + if( "__metadata".equals(propertyKey) ) { + jsonReader.skipValue(); + } else if( "__deferred".equals(propertyKey) ) { + jsonReader.skipValue(); + jsonReader.endObject(); + return null; + } else { + final PropertySerializationInfo propertyInfo = getPropertySerializationInfo(propertyKey); + if( propertyInfo != null ) { + final Field entityField = propertyInfo.getJavaField(); + TypeAdapter fieldAdapter = propertyInfo.getFieldAdapter(); + + if( fieldAdapter == null ) { + fieldAdapter = getAdapterFromField(entityField, gson); + } + + if( fieldAdapter != null ) { + final Object attributeValue = fieldAdapter.read(jsonReader); + + // To be safe/secure, since fields are declared private in the VDM. + final boolean oldAccessibleValue = entityField.canAccess(entity); + entityField.setAccessible(true); + entityField.set(entity, attributeValue); + entityField.setAccessible(oldAccessibleValue); + } + } else { + final Object customValue = customFieldAdapter.read(jsonReader); + handleCustomField(entity, propertyKey, customValue); + } + } + } + + jsonReader.endObject(); + } else if( jsonReader.peek() == JsonToken.NULL ) { + jsonReader.nextNull(); + return null; + } + + return entity; + } + catch( final + InstantiationException + | IllegalAccessException + | NoSuchMethodException + | InvocationTargetException e ) { + log + .error( + "Could not instantiate or initialize '" + + entityRawType.getName() + + "'. " + + "Returning null instead.", + e); + } + return null; + } + + private void handleCustomField( final VdmObject object, final String name, final Object value ) + { + if( Arrays.asList(VdmObject.ODATA_TYPE_ANNOTATIONS).contains(name) ) { + // do nothing + return; + } + if( object instanceof VdmEntity && Arrays.asList(VdmObject.ODATA_VERSION_ANNOTATIONS).contains(name) ) { + ((VdmEntity) object).setVersionIdentifier(value.toString()); + return; + } + object.setCustomField(name, value); + } + + @Nullable + private PropertySerializationInfo getPropertySerializationInfo( final String propertyKey ) + { + if( entityProperties.containsKey(propertyKey) ) { + return entityProperties.get(propertyKey); + } + if( superClassAdapter != null ) { + return superClassAdapter.getPropertySerializationInfo(propertyKey); + } + return null; + } + + /** + * For internal use only by data model classes. + * + * @param out + * The JsonWriter reference. + * @param value + * The entity instance to be serialized. + * @throws IOException + * When serialization failed. + */ + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final VdmObject value ) + throws IOException + { + if( value != null ) { + final JsonObject entityAsJson = getEntityAsJsonObject(value); + final JsonObject customFieldsAsJson = gson.toJsonTree(value.getCustomFields()).getAsJsonObject(); + + for( final Map.Entry annotationProperty : value.getAnnotationProperties().entrySet() ) { + entityAsJson.add(annotationProperty.getKey(), gson.toJsonTree(annotationProperty.getValue())); + } + + for( final Map.Entry customField : customFieldsAsJson.entrySet() ) { + entityAsJson.add(customField.getKey(), customField.getValue()); + } + + gson.toJson(entityAsJson, out); + } else { + out.nullValue(); + } + } + + @SuppressWarnings( "unchecked" ) + private JsonObject getEntityAsJsonObject( final VdmObject value ) + { + if( delegateAdapter != null ) { + return delegateAdapter.toJsonTree(value).getAsJsonObject(); + } else { + final JsonObject entityAsJson = superClassAdapter.getEntityAsJsonObject(value); + for( final Map.Entry entityProperty : entityProperties.entrySet() ) { + try { + final PropertySerializationInfo serializationInfo = entityProperty.getValue(); + + // To be safe/secure, since fields are declared private in the VDM. + final Field propertyField = serializationInfo.getJavaField(); + final boolean oldAccessibleValue = propertyField.canAccess(value); + propertyField.setAccessible(true); + final Object propertyValue = propertyField.get(value); + propertyField.setAccessible(oldAccessibleValue); + + TypeAdapter fieldAdapter = (TypeAdapter) serializationInfo.getFieldAdapter(); + + if( fieldAdapter == null ) { + fieldAdapter = (TypeAdapter) getAdapterFromField(propertyField, gson); + } + + final JsonElement propertyValueAsJson = + (fieldAdapter != null) ? fieldAdapter.toJsonTree(propertyValue) : null; + + // Overwrites JSON property from the superclass if this class has a property with the same name. + entityAsJson.add(entityProperty.getKey(), propertyValueAsJson); + } + catch( final IllegalAccessException e ) { + log + .error( + "Could not serialize property '" + + entityProperty.getKey() + + "'. " + + "Returning null instead.", + e); + } + } + + // odata type + final String odataType = "#" + value.getOdataType(); + entityAsJson.addProperty(VdmObject.ODATA_TYPE_ANNOTATIONS[0], odataType); + + // odata version + if( value instanceof VdmEntity ) { + final Option versionIdentifier = ((VdmEntity) value).getVersionIdentifier(); + if( versionIdentifier.isDefined() ) { + entityAsJson.addProperty(VdmObject.ODATA_VERSION_ANNOTATIONS[0], versionIdentifier.get()); + } + } + + return entityAsJson; + } + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonVdmEntityListAdapter.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonVdmEntityListAdapter.java new file mode 100644 index 0000000000..a56b263c47 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonVdmEntityListAdapter.java @@ -0,0 +1,117 @@ +package com.sap.cloud.sdk.datamodel.odatav4.adapter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +/** + * For internal use only by data model classes. + * + * @param + * The entity type. + */ +public class GsonVdmEntityListAdapter extends TypeAdapter> +{ + @Nonnull + private final Gson gson; + @Nonnull + private final TypeAdapter entityAdapter; + + /** + * For internal use only by data model classes. + * + * @param gson + * The GSON instance. + * @param entityAdapter + * The entity adapter. + */ + public GsonVdmEntityListAdapter( @Nonnull final Gson gson, @Nonnull final TypeAdapter entityAdapter ) + { + this.gson = gson; + this.entityAdapter = entityAdapter; + } + + private List readArray( @Nonnull final JsonReader in ) + throws IOException + { + in.beginArray(); + final List entityList = new ArrayList<>(); + + while( in.hasNext() ) { + final T entity = entityAdapter.read(in); + entityList.add(entity); + } + + in.endArray(); + return entityList; + } + + /** + * For internal use only by data model classes. + * + * @param in + * The JsonReader reference. + * @return The deserialized List of entities. + * @throws IOException + * When deserialization failed. + */ + @Override + @Nullable + public List read( @Nonnull final JsonReader in ) + throws IOException + { + List entityList = null; + if( in.peek() == JsonToken.BEGIN_OBJECT ) { + in.beginObject(); + if( in.peek() == JsonToken.NAME ) { + final String resultsKey = in.nextName(); + if( "results".equals(resultsKey) && in.peek() == JsonToken.BEGIN_ARRAY ) { + entityList = readArray(in); + } else { + in.skipValue(); + } + } + in.endObject(); + } else if( in.peek() == JsonToken.BEGIN_ARRAY ) { + entityList = readArray(in); + } else { + in.skipValue(); + } + return entityList; + } + + /** + * For internal use only by data model classes. + * + * @param out + * The JsonWriter reference. + * @param entityList + * The list of entities. + * @throws IOException + * When serialization failed. + */ + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final List entityList ) + throws IOException + { + if( entityList != null ) { + final JsonArray entityListAsJson = new JsonArray(); + for( final T entity : entityList ) { + entityListAsJson.add(entityAdapter.toJsonTree(entity)); + } + gson.toJson(entityListAsJson, out); + } else { + out.nullValue(); + } + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonVdmEnumAdapter.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonVdmEnumAdapter.java new file mode 100644 index 0000000000..54a8d8c67e --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/GsonVdmEnumAdapter.java @@ -0,0 +1,62 @@ +package com.sap.cloud.sdk.datamodel.odatav4.adapter; + +import java.io.IOException; +import java.util.Arrays; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; +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.odatav4.core.VdmEnum; + +/** + * The VdmEnum type adapter for serializing and deserializing JSON payload. + * + * @param + * The generic type for which the adapter is applied. + */ +class GsonVdmEnumAdapter extends TypeAdapter +{ + private final ImmutableMap nameEnumLookupMap; + + GsonVdmEnumAdapter( @Nonnull final Class enumType ) + { + final VdmEnum[] constants = enumType.getEnumConstants(); + if( constants == null ) { + throw new IllegalArgumentException("Enum type " + enumType.getSimpleName() + " has no enum constants."); + } + this.nameEnumLookupMap = Maps.uniqueIndex(Arrays.asList(constants), VdmEnum::getName); + } + + @Override + public void write( @Nonnull final JsonWriter out, @Nullable final T value ) + throws IOException + { + if( value == null ) { + out.nullValue(); + } else { + final String name = value.getName(); + out.value(name); + } + } + + @Override + @SuppressWarnings( "unchecked" ) + @Nullable + public T read( @Nonnull final JsonReader in ) + throws IOException + { + if( in.peek() == JsonToken.NULL ) { + in.nextNull(); + return null; + } + final String name = in.nextString(); + final VdmEnum value = nameEnumLookupMap.getOrDefault(name, null); + return (T) value; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/JacksonVdmEnumDeserializer.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/JacksonVdmEnumDeserializer.java new file mode 100644 index 0000000000..4cebbc98ef --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/JacksonVdmEnumDeserializer.java @@ -0,0 +1,83 @@ +package com.sap.cloud.sdk.datamodel.odatav4.adapter; + +import java.io.IOException; +import java.util.Arrays; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.deser.ContextualDeserializer; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEnum; + +import io.vavr.control.Option; +import lombok.extern.slf4j.Slf4j; + +/** + * Jackson deserializer adapter for {@link VdmEnum} types. + */ +@Slf4j +public class JacksonVdmEnumDeserializer extends StdDeserializer implements ContextualDeserializer +{ + private static final long serialVersionUID = 3282694560867458205L; + private ImmutableMap nameEnumLookupMap = ImmutableMap.of(); + private Class valueType; + + /** + * Default constructor. + */ + protected JacksonVdmEnumDeserializer() + { + super((Class) null); + } + + @Override + @Nonnull + public + JacksonVdmEnumDeserializer + createContextual( @Nonnull final DeserializationContext ctxt, @Nullable final BeanProperty property ) + { + final JavaType valueType = + Option + .of(property) + .map(BeanProperty::getType) + .map(t -> t.containedType(0)) + .getOrElse(ctxt::getContextualType); + final JacksonVdmEnumDeserializer deserializer = new JacksonVdmEnumDeserializer(); + + @SuppressWarnings( "unchecked" ) + final Class enumType = (Class) valueType.getRawClass(); + final VdmEnum[] constants = enumType.getEnumConstants(); + if( constants == null ) { + throw new IllegalStateException("Enum type " + enumType.getSimpleName() + " has no enum constants."); + } + deserializer.valueType = enumType; + deserializer.nameEnumLookupMap = Maps.uniqueIndex(Arrays.asList(constants), VdmEnum::getName); + return deserializer; + } + + @Override + @Nullable + public VdmEnum deserialize( @Nonnull final JsonParser parser, @Nonnull final DeserializationContext ctxt ) + throws IOException + { + final JsonToken currentToken = parser.currentToken(); + if( currentToken == JsonToken.VALUE_NULL ) { + return null; + } + if( currentToken != JsonToken.VALUE_STRING ) { + throw new IOException( + String.format("Failed to deserialize enum type %s from token kind %s", valueType, currentToken)); + } + + final String serializedValue = parser.getValueAsString(); + return nameEnumLookupMap.getOrDefault(serializedValue, null); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/JacksonVdmEnumSerializer.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/JacksonVdmEnumSerializer.java new file mode 100644 index 0000000000..b37bad844a --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/JacksonVdmEnumSerializer.java @@ -0,0 +1,58 @@ +package com.sap.cloud.sdk.datamodel.odatav4.adapter; + +import java.io.IOException; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.ContextualSerializer; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEnum; + +import lombok.extern.slf4j.Slf4j; + +/** + * Jackson serializer adapter for {@link VdmEnum} types. + */ +@Slf4j +public class JacksonVdmEnumSerializer extends StdSerializer implements ContextualSerializer +{ + private static final long serialVersionUID = -5794503059647076980L; + + /** + * Default constructor. + */ + protected JacksonVdmEnumSerializer() + { + super((Class) null); + } + + @Override + public void serialize( + @Nullable final VdmEnum object, + @Nonnull final JsonGenerator gen, + @Nonnull final SerializerProvider prov ) + throws IOException + { + if( object == null ) { + gen.writeNull(); + return; + } + final String enumName = object.getName(); + gen.writeString(enumName); + } + + @Override + @Nonnull + public + JsonSerializer + createContextual( @Nonnull final SerializerProvider prov, @Nullable final BeanProperty property ) + { + final JacksonVdmEnumSerializer serializer = new JacksonVdmEnumSerializer(); + return serializer; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/JacksonVdmObjectDeserializer.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/JacksonVdmObjectDeserializer.java new file mode 100644 index 0000000000..27e1c12ac9 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/JacksonVdmObjectDeserializer.java @@ -0,0 +1,242 @@ +package com.sap.cloud.sdk.datamodel.odatav4.adapter; + +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.deser.ContextualDeserializer; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.introspect.AnnotatedField; +import com.fasterxml.jackson.databind.introspect.AnnotationMap; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEntity; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmObject; +import com.sap.cloud.sdk.result.ElementName; +import com.sap.cloud.sdk.typeconverter.ConvertedObject; + +import io.vavr.control.Option; +import lombok.extern.slf4j.Slf4j; + +/** + * Jackson deserializer adapter for {@link VdmEntity} and {@link VdmObject} types. + */ +@Slf4j +public class JacksonVdmObjectDeserializer extends StdDeserializer> implements ContextualDeserializer +{ + private static final long serialVersionUID = -8440640969356203625L; + private JavaType valueType; + + /** + * Default constructor. + */ + protected JacksonVdmObjectDeserializer() + { + super((Class>) null); + } + + @Override + @Nonnull + public + JacksonVdmObjectDeserializer + createContextual( @Nonnull final DeserializationContext ctxt, @Nullable final BeanProperty property ) + { + final Option beanType = Option.of(property).map(BeanProperty::getType).map(t -> t.containedType(0)); + final JavaType valueType = beanType.getOrElse(ctxt::getContextualType); + final JacksonVdmObjectDeserializer deserializer = new JacksonVdmObjectDeserializer(); + deserializer.valueType = valueType; + return deserializer; + } + + @Override + @Nullable + public VdmObject deserialize( @Nonnull final JsonParser parser, @Nonnull final DeserializationContext ctxt ) + throws IOException + { + final Class vdmObjectType = valueType.getRawClass(); + final VdmObject vdmObject = instantiateVdmObject(vdmObjectType); + + // Handle current (first) JSON token + final JsonToken firstToken = parser.currentToken(); + if( firstToken == JsonToken.VALUE_NULL ) { + log.trace("VDM object is null"); + return null; + } + if( firstToken != JsonToken.START_OBJECT ) { + log.debug("VDM object can only be deserialized from JSON object."); + throw new IOException( + "Expected Start of JSON Object when deserializing an VDM object. Instead there was " + firstToken); + } + + // Create mapping for OData element name to actual Java field(s) + final Map> fieldValues = + Arrays + .stream(vdmObjectType.getDeclaredFields()) + .filter(f -> f.getAnnotation(ElementName.class) != null) + .collect(Collectors.groupingBy(f -> f.getDeclaredAnnotation(ElementName.class).value())); + + // Iterate JSON token handling + for( JsonToken token = parser.nextToken(); token != JsonToken.END_OBJECT; token = parser.nextToken() ) { + if( token != JsonToken.FIELD_NAME ) { + throw new IOException( + "Expected field name at current position of JSON object. Instead there was " + token); + } + final String fieldName = parser.currentName(); + + // Step from JSON element name to element value + parser.nextToken(); + + // Handle custom value when its field name is not declared on VDM object definition + if( !fieldValues.containsKey(fieldName) ) { + final Object value = ctxt.readValue(parser, Object.class); + handleCustomValue(vdmObject, fieldName, value); + continue; + } + + // Handle value for declared VDM object field + Object fieldValue = null; + for( final Field field : fieldValues.get(fieldName) ) { + if( fieldValue == null ) { + fieldValue = getVdmObjectFieldValue(field, parser, ctxt); + } + setVdmObjectFieldValue(vdmObject, field, fieldValue); + } + } + return vdmObject; + } + + @Nonnull + private VdmObject instantiateVdmObject( @Nonnull final Class vdmObjectType ) + throws IOException + { + try { + final Constructor declaredConstructor = vdmObjectType.getDeclaredConstructor(); + return (VdmObject) declaredConstructor.newInstance(); + } + catch( final + NoSuchMethodException + | InstantiationException + | IllegalArgumentException + | SecurityException + | IllegalAccessException + | InvocationTargetException e ) { + throw new IOException("Failed to create an instance from VDM object of type " + vdmObjectType, e); + } + } + + @Nullable + private Object getVdmObjectFieldValue( + @Nonnull final Field field, + @Nonnull final JsonParser parser, + @Nonnull final DeserializationContext ctxt ) + throws IOException + { + final JsonToken valueToken = parser.currentToken(); + + final JsonDeserialize customDeserialize = field.getAnnotation(JsonDeserialize.class); + if( customDeserialize != null ) { + try { + final AnnotationMap annotations = AnnotationMap.of(JsonDeserialize.class, customDeserialize); + final AnnotatedField annotated = new AnnotatedField(null, field, annotations); + final Object rawDeserializerType = ctxt.getAnnotationIntrospector().findDeserializer(annotated); + final Object rawDeserializer = ((Class) rawDeserializerType).getDeclaredConstructor().newInstance(); + @SuppressWarnings( "unchecked" ) + final JsonDeserializer deserializer = (JsonDeserializer) rawDeserializer; + return deserializer.deserialize(parser, ctxt); + } + catch( final Exception e ) { + final String msg = + String.format("Failed to use custom deserializer %s for field %s.", customDeserialize, field); + log.debug(msg, e); + throw new IOException(msg, e); + } + } + + if( valueToken == JsonToken.VALUE_STRING ) { + final String fieldValueString = parser.getText(); + final Option convertedObject = withCustomConverter(field.getType(), fieldValueString); + if( convertedObject.isDefined() ) { + return convertedObject.get(); + } + } + if( valueToken == JsonToken.VALUE_NULL ) { + return null; + } else { + final JavaType javaType = ctxt.constructType(field.getGenericType()); + return ctxt.readValue(parser, javaType); + } + } + + private void setVdmObjectFieldValue( + @Nonnull final VdmObject vdmObject, + @Nonnull final Field field, + @Nullable final Object value ) + throws IOException + { + try { + field.setAccessible(true); + field.set(vdmObject, value); + } + catch( final IllegalArgumentException | SecurityException | IllegalAccessException e ) { + final String className = vdmObject.getClass().getSimpleName(); + final String msg = String.format("Failed to set value for field %s on instance of %s", field, className); + log.debug(msg, e); + throw new IOException(msg, e); + } + } + + private void handleCustomValue( + @Nonnull final VdmObject object, + @Nonnull final String name, + @Nullable final Object value ) + { + if( Arrays.asList(VdmObject.ODATA_TYPE_ANNOTATIONS).contains(name) ) { + // do nothing + return; + } + if( object instanceof VdmEntity && Arrays.asList(VdmObject.ODATA_VERSION_ANNOTATIONS).contains(name) ) { + ((VdmEntity) object).setVersionIdentifier(String.valueOf(value)); + return; + } + object.setCustomField(name, value); + } + + @Nonnull + private Option withCustomConverter( @Nonnull final Class targetType, @Nonnull final String value ) + throws IOException + { + final Optional> matchingConverter = + Arrays + .stream(ODataGenericConverter.DEFAULT_CONVERTERS) + .filter(c -> targetType.isAssignableFrom(c.getType())) + .findFirst(); + + if( !matchingConverter.isPresent() ) { + return Option.none(); + } + + @SuppressWarnings( "unchecked" ) + final ConvertedObject convertedObject = + matchingConverter + .map(oDataGenericConverter -> ((ODataGenericConverter) oDataGenericConverter).fromDomain(value)) + .orElseGet(ConvertedObject::ofNotConvertible); + if( convertedObject.isNotConvertible() ) { + throw new IOException(String.format("Failed to convert %s to %s", value, targetType.getSimpleName())); + } + return Option.of(convertedObject.get()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/JacksonVdmObjectSerializer.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/JacksonVdmObjectSerializer.java new file mode 100644 index 0000000000..9fdc394243 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/JacksonVdmObjectSerializer.java @@ -0,0 +1,176 @@ +package com.sap.cloud.sdk.datamodel.odatav4.adapter; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.introspect.AnnotatedField; +import com.fasterxml.jackson.databind.introspect.AnnotationMap; +import com.fasterxml.jackson.databind.ser.ContextualSerializer; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEntity; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmObject; +import com.sap.cloud.sdk.result.ElementName; +import com.sap.cloud.sdk.typeconverter.ConvertedObject; + +import io.vavr.control.Option; +import io.vavr.control.Try; +import lombok.extern.slf4j.Slf4j; + +/** + * Jackson serializer adapter for {@link VdmEntity} and {@link VdmObject} types. + */ +@Slf4j +public class JacksonVdmObjectSerializer extends StdSerializer> implements ContextualSerializer +{ + private static final long serialVersionUID = 3559044362941940279L; + + /** + * Default constructor. + */ + protected JacksonVdmObjectSerializer() + { + super((Class>) null); + } + + @Override + public void serialize( + @Nullable final VdmObject object, + @Nonnull final JsonGenerator gen, + @Nonnull final SerializerProvider prov ) + throws IOException + { + if( object == null ) { + gen.writeNull(); + return; + } + final boolean nonNull = + prov.getDefaultPropertyInclusion(object.getType()).getContentInclusion() == JsonInclude.Include.NON_NULL; + + gen.enable(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN); + gen.writeStartObject(); + + // odata type + final String odataType = "#" + object.getOdataType(); + gen.writeStringField(VdmObject.ODATA_TYPE_ANNOTATIONS[0], odataType); + + // odata version + if( object instanceof VdmEntity ) { + final Option versionIdentifier = ((VdmEntity) object).getVersionIdentifier(); + if( versionIdentifier.isDefined() ) { + gen.writeStringField(VdmObject.ODATA_TYPE_ANNOTATIONS[0], versionIdentifier.get()); + } + } + + final Map> fieldValues = + Arrays + .stream(object.getClass().getDeclaredFields()) + .filter(f -> f.getAnnotation(ElementName.class) != null) + .collect(Collectors.toMap(Function.identity(), f -> getFieldValue(f, object))); + + for( final Map.Entry> propertyEntry : fieldValues.entrySet() ) { + final Option propertyValueOption = propertyEntry.getValue(); + final Field propertyField = propertyEntry.getKey(); + if( propertyValueOption.isEmpty() ) { + log.trace("Field value for {} is empty.", propertyField); + if( !nonNull ) { + gen.writeNull(); + } + } else { + final Object propertyValue = propertyValueOption.get(); + writePropertyValue(propertyField, propertyValue, nonNull, gen, prov); + } + } + gen.writeEndObject(); + } + + private void writePropertyValue( + @Nonnull final Field propertyField, + @Nullable final Object propertyValue, + final boolean isNonNull, + @Nonnull final JsonGenerator gen, + @Nonnull final SerializerProvider prov ) + throws IOException + { + final String propertyName = propertyField.getAnnotation(ElementName.class).value(); + final JsonSerialize customSerialize = propertyField.getAnnotation(JsonSerialize.class); + if( customSerialize != null ) { + try { + gen.writeFieldName(propertyName); + final AnnotationMap annotations = AnnotationMap.of(JsonSerialize.class, customSerialize); + final AnnotatedField annotated = new AnnotatedField(null, propertyField, annotations); + final Object rawSerializerType = prov.getAnnotationIntrospector().findSerializer(annotated); + final Object rawSerializer = ((Class) rawSerializerType).getDeclaredConstructor().newInstance(); + @SuppressWarnings( "unchecked" ) + final JsonSerializer serializer = (JsonSerializer) rawSerializer; + serializer.serialize(propertyValue, gen, prov); + return; + } + catch( final Exception e ) { + final String msg = + String + .format( + "Failed to use custom serializer %s for field %s and value %s", + customSerialize, + propertyField, + propertyValue); + log.debug(msg, e); + throw new IOException(msg, e); + } + } + + final ConvertedObject convertedValue = withCustomConverter(propertyValue); + if( convertedValue.isConvertible() ) { + final String value = convertedValue.get(); + if( value != null || !isNonNull ) { + gen.writeStringField(propertyName, value); + } + } else { + if( propertyValue != null || !isNonNull ) { + prov.defaultSerializeField(propertyName, propertyValue, gen); + } + } + } + + @SuppressWarnings( "unchecked" ) + @Nonnull + private ConvertedObject withCustomConverter( @Nullable final Object v ) + { + final Optional> matchingConverter = + Arrays.stream(ODataGenericConverter.DEFAULT_CONVERTERS).filter(c -> c.getType().isInstance(v)).findFirst(); + return matchingConverter + .map(oDataGenericConverter -> ((ODataGenericConverter) oDataGenericConverter).toDomain(v)) + .orElseGet(ConvertedObject::ofNotConvertible); + } + + @Nonnull + private Option getFieldValue( @Nonnull final Field f, @Nonnull final VdmObject object ) + { + return Try.of(() -> { + f.setAccessible(true); + return f.get(object); + }).onFailure(e -> log.error("Failed to get value for field {} in object {}", f, object, e)).toOption(); + } + + @Override + @Nonnull + public + JsonSerializer + createContextual( @Nonnull final SerializerProvider prov, @Nullable final BeanProperty property ) + { + return new JacksonVdmObjectSerializer(); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/ODataGenericConverter.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/ODataGenericConverter.java new file mode 100644 index 0000000000..fb0639c1ab --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/ODataGenericConverter.java @@ -0,0 +1,102 @@ +package com.sap.cloud.sdk.datamodel.odatav4.adapter; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.UUID; +import java.util.function.Function; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.typeconverter.AbstractTypeConverter; +import com.sap.cloud.sdk.typeconverter.ConvertedObject; + +import io.vavr.control.Try; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Generic fluent helper converters for String based OData V4 primitives. + * + * @param + * The Java type to which conversion happens. + */ +@RequiredArgsConstructor( access = AccessLevel.PRIVATE ) +final class ODataGenericConverter extends AbstractTypeConverter +{ + private static final ODataGenericConverter LOCAL_DATE = + new ODataGenericConverter<>( + LocalDate.class, + o -> o.format(DateTimeFormatter.ISO_LOCAL_DATE), + s -> LocalDate.parse(s, DateTimeFormatter.ISO_LOCAL_DATE)); + + private static final ODataGenericConverter LOCAL_TIME = + new ODataGenericConverter<>( + LocalTime.class, + o -> o.format(DateTimeFormatter.ISO_LOCAL_TIME), + s -> LocalTime.parse(s, DateTimeFormatter.ISO_LOCAL_TIME)); + + private static final ODataGenericConverter OFFSET_DATE_TIME = + new ODataGenericConverter<>( + OffsetDateTime.class, + o -> o.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), + s -> OffsetDateTime.parse(s, DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + + private static final ODataGenericConverter GUID = + new ODataGenericConverter<>(UUID.class, UUID::toString, UUID::fromString); + + private static final ODataGenericConverter DURATION = + new ODataGenericConverter<>(Duration.class, Duration::toString, Duration::parse); + + private static final ODataGenericConverter BINARY = + new ODataGenericConverter<>( + byte[].class, + Base64.getEncoder()::encodeToString, + ODataGenericConverter::decodeBinary); + + private static final ODataGenericConverter STRING = + new ODataGenericConverter<>(String.class, Function.identity(), Function.identity()); + + /** + * Array of OData value converters for primitive types. + */ + public static final ODataGenericConverter[] DEFAULT_CONVERTERS = + { LOCAL_DATE, LOCAL_TIME, OFFSET_DATE_TIME, GUID, DURATION, BINARY, STRING }; + + @Getter + private final Class type; + + @Getter + private final Class domainType = String.class; + + private final Function serializer; + private final Function deserializer; + + @Nonnull + private static byte[] decodeBinary( @Nonnull final String value ) + { + // Normalize URL-safe characters to standard Base64 + final String normalized = value.replace('-', '+').replace('_', '/'); + + return Base64.getDecoder().decode(normalized); + } + + @Nonnull + @Override + public ConvertedObject toDomainNonNull( @Nonnull final JavaT object ) + { + return ConvertedObject.of(serializer.apply(object)); + } + + @Nonnull + @Override + public ConvertedObject fromDomainNonNull( @Nonnull final String domainObject ) + { + final Try maybe = Try.of(() -> deserializer.apply(domainObject)); + return maybe.map(ConvertedObject::of).getOrElse(ConvertedObject::ofNotConvertible); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/AbstractBoundOperation.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/AbstractBoundOperation.java new file mode 100644 index 0000000000..c0701dda07 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/AbstractBoundOperation.java @@ -0,0 +1,62 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Map; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataFunctionParameters; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +abstract class AbstractBoundOperation implements BoundOperation +{ + @Nonnull + private final Class bindingType; + @Nonnull + private final Class returnType; + @Nonnull + private final String qualifiedName; + + abstract static class AbstractBoundFunction extends AbstractBoundOperation + implements + BoundFunction + { + @Getter( AccessLevel.PUBLIC ) + @Nonnull + private final ODataFunctionParameters parameters; + + AbstractBoundFunction( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name); + parameters = ODataFunctionParameters.of(args, ODataProtocol.V4); + } + } + + abstract static class AbstractBoundAction extends AbstractBoundOperation + implements + BoundAction + { + @Getter + @Nonnull + private final Map parameters; + + AbstractBoundAction( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map parameters ) + { + super(src, target, name); + this.parameters = parameters; + } + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/AbstractEntityBasedRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/AbstractEntityBasedRequestBuilder.java new file mode 100644 index 0000000000..24cd952279 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/AbstractEntityBasedRequestBuilder.java @@ -0,0 +1,52 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; + +import lombok.extern.slf4j.Slf4j; + +/** + * Representation of OData requests that operate on entities as a fluent interface for further configuring the request + * and {@link #execute(com.sap.cloud.sdk.cloudplatform.connectivity.Destination) executing} it. + * + * @param + * The specific request builder type. + * @param + * The type of the entity this OData request operates on, if any. + * @param + * The type of the result entity, if any. + */ +@Slf4j +abstract class AbstractEntityBasedRequestBuilder, EntityT extends VdmEntity, ResultT> + extends + AbstractRequestBuilder +{ + /** + * Returns a class object of the type this request builder works with. + * + * @return A class object of the handled type. + */ + @Nonnull + protected abstract Class getEntityClass(); + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param entityPath + * The resource path pointing to the entity (collection) this request should operate on. + */ + AbstractEntityBasedRequestBuilder( @Nonnull final String servicePath, @Nonnull final ODataResourcePath entityPath ) + { + super(servicePath, entityPath); + } + + @Nonnull + static > String getEntityCollectionFromEntityClass( + @Nonnull final Class entityClass ) + { + return new VdmEntityUtil<>(entityClass).newInstance().getEntityCollection(); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/AbstractRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/AbstractRequestBuilder.java new file mode 100644 index 0000000000..828b21007a --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/AbstractRequestBuilder.java @@ -0,0 +1,167 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestListener; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataUriFactory; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Representation of a generic OData request as a fluent interface for further configuring the request and + * {@link #execute(Destination) executing} it. + * + * @param + * The specific builder type. + * @param + * The type of the result entity, if any. + */ +@Slf4j +abstract class AbstractRequestBuilder, ResultT> + implements + RequestBuilder +{ + @Nonnull + @Getter( AccessLevel.PROTECTED ) + private final String servicePath; + + @Nonnull + @Getter( AccessLevel.PROTECTED ) + private final ODataResourcePath resourcePath; + + /** + * A map containing the headers to be used only for the actual request of this FluentHelper implementation. + */ + @Getter( AccessLevel.PROTECTED ) + @Nonnull + private final Map> headers = new HashMap<>(); + + /** + * A map containing the custom query parameters to be used only for the actual request of this FluentHelper + * implementation. + */ + @Getter( AccessLevel.PROTECTED ) + @Nonnull + private final Map parametersForRequestOnly = new HashMap<>(); + + @Getter( AccessLevel.PROTECTED ) + @Nonnull + private final List listeners = new ArrayList<>(); + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param resourcePath + * The resource path identifying the resource to operate on. + */ + public AbstractRequestBuilder( @Nonnull final String servicePath, @Nonnull final ODataResourcePath resourcePath ) + { + this.servicePath = servicePath; + this.resourcePath = resourcePath; + } + + /** + * Get the reference to this instance. + * + * @return The FluentHelper instance. + */ + @SuppressWarnings( "unchecked" ) + @Nonnull + protected BuilderT getThis() + { + return (BuilderT) this; + } + + /** + * An error handling class that implements the error result handler interface can be attached to this request + * builder. This allows custom logic to be called when an error occurs in the {@link #execute execute} method. If + * this method is not called, then an instance of ODataRequestListener is used. Only one handler can be attached at + * a time per request builder object, so calling this multiple times will replace the handler. + * + * @param listener + * Instance of an error handler class that implements the error result handler interface. + * + * @return The same request builder with its error handler set to the provided object. + */ + @Nonnull + public BuilderT withListener( @Nonnull final ODataRequestListener listener ) + { + this.listeners.add(listener); + return getThis(); + } + + @Nonnull + @Override + public BuilderT withHeader( @Nonnull final String key, @Nullable final String value ) + { + headers.computeIfAbsent(key, k -> new ArrayList<>(1)).add(value); + return getThis(); + } + + @Nonnull + @Override + public BuilderT withHeaders( @Nonnull final Map map ) + { + map.forEach(this::withHeader); + return getThis(); + } + + /** + * Gives the option to specify custom query parameters for the request. + * + *

+ * Note: It is recommended to only use this function for query parameters which are not supported + * by the VDM by default. Using this function to bypass request builder method calls can lead to unsupported + * response handling. There is no contract on the order or priority of parameters added to the request. + *

+ * + *

+ * Example: Use the request query option $search to reduce the result set, leaving + * only entities which match the specified search expression. This feature is supported in protocol OData v4. + * + *

+     * new DefaultBusinessPartnerService().getAllBusinessPartner().withQueryParameter("$search", "Köln OR Cologne")
+     * 
+ *

+ * + * @param key + * Name of the query parameter. + * @param value + * Value of the query parameter. + * + * @return The same request builder. + */ + @Nonnull + public BuilderT withQueryParameter( @Nonnull final String key, @Nullable final String value ) + { + parametersForRequestOnly.put(key, value); + return getThis(); + } + + @Nonnull + RequestT toRequest( @Nonnull final RequestT request ) + { + getHeaders().forEach(( k, values ) -> values.forEach(v -> request.addHeader(k, v))); + + getParametersForRequestOnly() + .forEach(( key, value ) -> request.addQueryParameter(key, ODataUriFactory.encodeQuery(value))); + + getListeners().forEach(request::addListener); + + return request; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/AbstractStructuredPropertyQuery.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/AbstractStructuredPropertyQuery.java new file mode 100644 index 0000000000..33b42073a8 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/AbstractStructuredPropertyQuery.java @@ -0,0 +1,112 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.cloudplatform.exception.ShouldNotHappenException; +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.FieldUntyped; +import com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Implementation that represents read queries and holds their state at runtime. It allows for nested queries in a + * recursive manner. The implementation is the same for queries over single entities and collections of entities. In the + * VDM the available functionality is limited by the interfaces. + * + * In order to support a fluent creation of nested queries both the entity and the parent entity type are stored via + * generics. By implementing {@link StructuredProperty} the API doesn't differentiate between selections via referencing + * navigational properties and selections via sub-queries. + * + * @param + * The generic navigation property entity source type. + * @param + * The generic navigation property entity target type. + */ +@RequiredArgsConstructor( access = AccessLevel.PACKAGE ) +abstract class AbstractStructuredPropertyQuery, EntityT extends VdmObject> + implements + ProtocolQueryRead, + StructuredProperty +{ + /** + * The delegate query. + */ + @Getter( AccessLevel.PROTECTED ) + protected final StructuredQuery delegateQuery; + + /** + * Query modifier to limit which field values of the entity {@linkplain EntityT} get fetched and populated. + * + * @param fields + * Properties of {@linkplain EntityT} to be selected. + * @return This query object with the added selections. + */ + @Nonnull + protected final AbstractStructuredPropertyQuery select( + @Nonnull final Iterable> fields ) + { + for( final Property field : fields ) { + if( field instanceof SimpleProperty || field instanceof ComplexProperty ) { + delegateQuery.select(field.getFieldName()); + continue; + + } + if( field instanceof StructuredProperty ) { + if( field instanceof ComplexPropertyQuery ) { + for( final String simpleSelector : ((ComplexPropertyQuery) field) + .getDelegateQuery() + .getSimpleSelectors() ) { + final FieldUntyped untypedField = FieldReference.ofPath(field.getFieldName(), simpleSelector); + delegateQuery.select(untypedField.getFieldName()); + } + + for( final StructuredQuery subQuery : ((ComplexPropertyQuery) field) + .getDelegateQuery() + .getComplexSelectors() ) { + + final FieldUntyped untypedField = + FieldReference.ofPath(field.getFieldName(), subQuery.getEntityOrPropertyName()); + delegateQuery + .select( + StructuredQuery + .asNestedQueryOnProperty(untypedField.getFieldName(), ODataProtocol.V4) + .select(subQuery.getSimpleSelectors().toArray(new String[0])) + .select(subQuery.getComplexSelectors().toArray(new StructuredQuery[0]))); + } + } else if( field instanceof AbstractStructuredPropertyQuery ) { + delegateQuery.select(((AbstractStructuredPropertyQuery) field).getDelegateQuery()); + } else { + delegateQuery + .select(StructuredQuery.asNestedQueryOnProperty(field.getFieldName(), ODataProtocol.V4)); + } + + continue; + } + throw new ShouldNotHappenException(); + } + return this; + } + + @Nonnull + @Override + public String getFieldName() + { + return delegateQuery.getEntityOrPropertyName(); + } + + @Nonnull + String getEncodedQueryString() + { + return delegateQuery.getEncodedQueryString(); + } + + @Nonnull + String getQueryString() + { + return delegateQuery.getQueryString(); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ActionRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ActionRequestBuilder.java new file mode 100644 index 0000000000..2227a2405a --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ActionRequestBuilder.java @@ -0,0 +1,147 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Collections; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.TypeAdapter; +import com.google.gson.reflect.TypeToken; +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.request.ODataRequestAction; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestFunction; +import com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory; + +import lombok.extern.slf4j.Slf4j; + +/** + * Representation of an Action OData request as a fluent interface for further configuring the request and + * {@link #execute(com.sap.cloud.sdk.cloudplatform.connectivity.Destination) executing} it. + * + * @param + * The request builder type. + * @param + * The type of the result, if any. + */ +@Slf4j +public abstract class ActionRequestBuilder, ResultT> + extends + AbstractRequestBuilder + implements + ModificationRequestBuilder +{ + private static final Gson gson = new GsonBuilder().serializeNulls().create(); + + private static final GsonVdmAdapterFactory GSON_VDM_ADAPTER_FACTORY = new GsonVdmAdapterFactory(); + private final Map parameters; + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param actionName + * The name of the unbound action + */ + public ActionRequestBuilder( @Nonnull final String servicePath, @Nonnull final String actionName ) + { + this(servicePath, actionName, Collections.emptyMap()); + } + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param actionName + * The name of the unbound action. + * @param parameters + * The parameters passed to the function. + */ + public ActionRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final String actionName, + @Nonnull final Map parameters ) + { + this(servicePath, ODataResourcePath.of(actionName), parameters); + } + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param actionPath + * The path to the unbound action. + * @param parameters + * The parameters passed to the function. + */ + public ActionRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath actionPath, + @Nonnull final Map parameters ) + { + super(servicePath, actionPath); + this.parameters = parameters; + } + + /** + * Serializes the passed parameter with custom type adapters and returns a JsonElement. + */ + @Nonnull + private JsonElement serialize( @Nullable final T parameter ) + { + if( parameter == null ) { + return JsonNull.INSTANCE; + } + + @SuppressWarnings( "unchecked" ) + final TypeToken typeToken = TypeToken.get((Class) parameter.getClass()); + final TypeAdapter typeAdapter = GSON_VDM_ADAPTER_FACTORY.create(gson, typeToken); + + final JsonElement jsonObject; + if( typeAdapter != null ) { + jsonObject = typeAdapter.toJsonTree(parameter); + } else { + jsonObject = gson.toJsonTree(parameter); + } + return jsonObject; + } + + /** + * Creates an instance of {@link ODataRequestAction} based on the Entity class. + *

+ * The following settings are used: + *

    + *
  • the endpoint URL
  • + *
  • the action name
  • + *
  • the parameters if applicable as JSON string
  • + *
+ * + * @return An initialized {@link ODataRequestFunction}. + */ + @Override + @Nonnull + public ODataRequestAction toRequest() + { + final ODataRequestAction request = + new ODataRequestAction(getServicePath(), getResourcePath(), serializeParameters(), ODataProtocol.V4); + return super.toRequest(request); + } + + @Nonnull + private String serializeParameters() + { + final JsonObject o = new JsonObject(); + parameters.forEach(( key, value ) -> o.add(key, serialize(value))); + + return gson.toJson(o); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ActionResponseCollection.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ActionResponseCollection.java new file mode 100644 index 0000000000..3d8005447c --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ActionResponseCollection.java @@ -0,0 +1,91 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import io.vavr.control.Option; +import io.vavr.control.Try; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +/** + * Generic OData service response wrapper for action requests. + * + * @param + * The generic result type. + */ +@EqualsAndHashCode +@ToString( doNotUseGetters = true ) +@RequiredArgsConstructor( staticName = "of", access = AccessLevel.PUBLIC ) +@Slf4j +public final class ActionResponseCollection +{ + // lazily evaluated response entity + private volatile Option> responseResult = null; + private final Object responseResultLock = new Object(); + + @Nonnull + private final ODataRequestResultGeneric result; + + @Getter + @Nonnull + private final Class actionResultClass; + + /** + * Get the optional result parsed by the HTTP content. + * + * @return The optional result entity. + */ + @Nonnull + public Option> getResponseResult() + { + if( responseResult == null ) { + synchronized( responseResultLock ) { + if( responseResult == null ) { + responseResult = parseEntityFromResponse(); + } + } + } + return responseResult; + } + + /** + * Get the response status code. + * + * @return The integer representation of the HTTP status code. + */ + public int getResponseStatusCode() + { + return result.getHttpResponse().getCode(); + } + + /** + * Get the response headers. + * + * @return The headers of the HTTP status code. + */ + @Nonnull + public Map> getResponseHeaders() + { + return result.getAllHeaderValues(); + } + + @Nonnull + private Option> parseEntityFromResponse() + { + if( actionResultClass.equals(Void.class) ) { + return Option.none(); + } + @SuppressWarnings( "unchecked" ) + final Try> parsedEntity = Try.of(() -> result.asList(actionResultClass)); + return parsedEntity.onFailure(e -> log.debug("Failed to parse entity from HTTP response.", e)).toOption(); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ActionResponseSingle.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ActionResponseSingle.java new file mode 100644 index 0000000000..5d3e107f58 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ActionResponseSingle.java @@ -0,0 +1,93 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Map; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import io.vavr.control.Option; +import io.vavr.control.Try; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +/** + * Generic OData service response wrapper for action requests. + * + * @param + * The generic result type. + */ +@EqualsAndHashCode +@ToString( doNotUseGetters = true ) +@RequiredArgsConstructor( staticName = "of", access = AccessLevel.PUBLIC ) +@Slf4j +public final class ActionResponseSingle +{ + // lazily evaluated response entity + private volatile Option responseResult = null; + private final Object responseResultLock = new Object(); + + @Nonnull + private final ODataRequestResultGeneric result; + + @Getter + @Nonnull + private final Class actionResultClass; + + /** + * Get the optional result parsed by the HTTP content. + * + * @return The optional result entity. + */ + @Nonnull + public Option getResponseResult() + { + if( responseResult == null ) { + synchronized( responseResultLock ) { + if( responseResult == null ) { + responseResult = parseEntityFromResponse(); + } + } + } + return responseResult; + } + + /** + * Get the response status code. + * + * @return The integer representation of the HTTP status code. + */ + public int getResponseStatusCode() + { + return result.getHttpResponse().getCode(); + } + + /** + * Get the response headers. + * + * @return The headers of the HTTP status code. + */ + @Nonnull + public Map> getResponseHeaders() + { + return result.getAllHeaderValues(); + } + + @Nonnull + private Option parseEntityFromResponse() + { + if( actionResultClass.equals(Void.class) ) { + return Option.none(); + } + final Try parsedEntity = Try.of(() -> result.as(actionResultClass)).peek(entity -> { + if( entity instanceof VdmEntity ) { + result.getVersionIdentifierFromHeader().peek(((VdmEntity) entity)::setVersionIdentifier); + } + }); + return parsedEntity.onFailure(e -> log.debug("Failed to parse entity from HTTP response.", e)).toOption(); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/BatchRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/BatchRequestBuilder.java new file mode 100644 index 0000000000..f5e4b27225 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/BatchRequestBuilder.java @@ -0,0 +1,153 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.Supplier; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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 com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestAction; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestBatch; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestCreate; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestDelete; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestFunction; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; +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.ODataRequestResultMultipartGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestUpdate; + +import lombok.AccessLevel; +import lombok.Getter; + +/** + * Representation of an OData Batch request as a fluent interface for combining multiple data reading and modifying + * operations in one HTTP request. + */ +public class BatchRequestBuilder extends AbstractRequestBuilder + implements + ModificationRequestBuilder +{ + private final ODataRequestBatch delegate; + + @Getter( AccessLevel.PROTECTED ) + private final Supplier uuidProvider = UUID::randomUUID; + + @Nonnull + @Getter( AccessLevel.PACKAGE ) + private final Map, ODataRequestGeneric> requestMapping = new IdentityHashMap<>(); + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + */ + @SuppressWarnings( "this-escape" ) // getUuidProvider() is designed to be overridable. + public BatchRequestBuilder( @Nonnull final String servicePath ) + { + super(servicePath, ODataResourcePath.of("$batch")); + this.delegate = new ODataRequestBatch(servicePath, ODataProtocol.V4, getUuidProvider()); + } + + /** + * Add read operations to the OData batch request. + * + * @param operations + * A var-arg array of read operations. + * @return The current reference of batch request builder. + * @see GetAllRequestBuilder + * @see GetByKeyRequestBuilder + * @see FunctionRequestBuilder + */ + @Nonnull + public BatchRequestBuilder addReadOperations( @Nonnull final ReadRequestBuilder... operations ) + { + for( final ReadRequestBuilder operation : operations ) { + final ODataRequestGeneric request = operation.toRequest(); + requestMapping.put(operation, request); + if( request instanceof ODataRequestRead ) { + delegate.addRead((ODataRequestRead) request); + } else if( request instanceof ODataRequestReadByKey ) { + delegate.addReadByKey((ODataRequestReadByKey) request); + } else if( request instanceof ODataRequestFunction ) { + delegate.addFunction((ODataRequestFunction) request); + } else { + throw new IllegalArgumentException( + "Failed to add unknown type of read operation to OData batch request: " + + operation.getClass().getSimpleName()); + } + } + return this; + } + + /** + * Add modifying operations to the OData batch request as combined changeset. + * + * @param operations + * A var-arg array of modifying operations. + * @return The current reference of batch request builder. + * @see CreateRequestBuilder + * @see DeleteRequestBuilder + * @see UpdateRequestBuilder + * @see ActionRequestBuilder + */ + @Nonnull + public BatchRequestBuilder addChangeset( @Nonnull final ModificationRequestBuilder... operations ) + { + final ODataRequestBatch.Changeset changeset = delegate.beginChangeset(); + for( final ModificationRequestBuilder operation : operations ) { + final ODataRequestGeneric request = operation.toRequest(); + requestMapping.put(operation, request); + if( request instanceof ODataRequestCreate ) { + changeset.addCreate((ODataRequestCreate) request); + } else if( request instanceof ODataRequestUpdate ) { + changeset.addUpdate((ODataRequestUpdate) request); + } else if( request instanceof ODataRequestDelete ) { + changeset.addDelete((ODataRequestDelete) request); + } else if( request instanceof ODataRequestAction ) { + changeset.addAction((ODataRequestAction) request); + } else { + throw new IllegalArgumentException( + "Failed to add unknown type of modifying operation to OData batch request: " + + operation.getClass().getSimpleName()); + } + } + changeset.endChangeset(); + return this; + } + + @Override + @Nonnull + public ODataRequestBatch toRequest() + { + return super.toRequest(delegate); + } + + @Override + @Nonnull + public BatchRequestBuilder withoutCsrfToken() + { + withHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER, "true"); + return this; + } + + @Nonnull + @Override + public BatchResponse execute( @Nonnull final Destination destination ) + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + + @SuppressWarnings( "PMD.CloseResource" ) // The ODataRequestResultMultipartGeneric is closed by BatchResponse + final ODataRequestResultMultipartGeneric response = toRequest().execute(httpClient); + + return BatchResponse.of(response, requestMapping); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/BatchResponse.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/BatchResponse.java new file mode 100644 index 0000000000..d4437ac570 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/BatchResponse.java @@ -0,0 +1,268 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataResponseException; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultMultipartGeneric; + +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +/** + * Generic OData service response wrapper for Batch response. + * + */ +@EqualsAndHashCode +@ToString +@RequiredArgsConstructor( staticName = "of", access = AccessLevel.PACKAGE ) +@Slf4j +public final class BatchResponse implements AutoCloseable +{ + @Nonnull + private final ODataRequestResultMultipartGeneric result; + + @Nonnull + private final Map, ODataRequestGeneric> requestMapping; + + /** + * Static factory method to convert from generic response to typed response. + * + * @param response + * The generic response that should be converted. + * @param initialRequest + * The initial BatchRequest + * @return The typed (high-level) BatchResponse object. + */ + @Nonnull + public static BatchResponse of( + @Nonnull final ODataRequestResultMultipartGeneric response, + @Nonnull final BatchRequestBuilder initialRequest ) + { + return BatchResponse.of(response, initialRequest.getRequestMapping()); + } + + /** + * Get the response status code. + * + * @return The integer representation of the HTTP status code. + */ + public int getResponseStatusCode() + { + return result.getHttpResponse().getCode(); + } + + /** + * Get the response headers. + * + * @return The headers of the HTTP status code. + */ + @Nonnull + public Map> getResponseHeaders() + { + return result.getAllHeaderValues(); + } + + /** + * Extract the batch item result for the provided OData request. + * + * @param operation + * The OData operation that was used in the OData batch request. + * @param + * The generic entity type. + * @return The list of entities as result of the request. + * @throws ODataResponseException + * When the OData batch response cannot be parsed. + * @throws IllegalArgumentException + * When the provided request reference could not be found in the original batch request. + */ + @Nonnull + public > List getReadResult( @Nonnull final GetAllRequestBuilder operation ) + { + final ODataRequestResultGeneric clientResult = result.getResult(requestMapping.get(operation)); + return clientResult.asList(operation.getEntityClass()); + } + + /** + * Extract the batch item result for the provided OData request. + * + * @param operation + * The OData operation that was used in the OData batch request. + * @param + * The generic object type. + * @return The list of objects as result of the request. + * @throws ODataResponseException + * When the OData batch response cannot be parsed. + * @throws IllegalArgumentException + * When the provided request reference could not be found in the original batch request. + */ + @Nonnull + public List getReadResult( @Nonnull final CollectionValueFunctionRequestBuilder operation ) + { + final ODataRequestResultGeneric clientResult = result.getResult(requestMapping.get(operation)); + return clientResult.asList(operation.getResultClass()); + } + + /** + * Extract the batch item result for the provided OData request. + * + * @param operation + * The OData operation that was used in the OData batch request. + * @param + * The generic entity type. + * @return The list of entities as result of the request. + * @throws ODataResponseException + * When the OData batch response cannot be parsed. + * @throws IllegalArgumentException + * When the provided request reference could not be found in the original batch request. + */ + @Nonnull + public > T getReadResult( @Nonnull final GetByKeyRequestBuilder operation ) + { + final ODataRequestResultGeneric clientResult = result.getResult(requestMapping.get(operation)); + return clientResult.as(operation.getEntityClass()); + } + + /** + * Extract the batch item result for the provided OData request. + * + * @param operation + * The OData operation that was used in the OData batch request. + * @param + * The generic object type. + * @return The object as result of the request. + * @throws ODataResponseException + * When the OData batch response cannot be parsed. + * @throws IllegalArgumentException + * When the provided request reference could not be found in the original batch request. + */ + @Nonnull + public T getReadResult( @Nonnull final SingleValueFunctionRequestBuilder operation ) + { + final ODataRequestResultGeneric clientResult = result.getResult(requestMapping.get(operation)); + return clientResult.as(operation.getResultClass()); + } + + /** + * Extract the batch item result for the provided OData request. + * + * @param operation + * The OData operation that was used in the OData batch request. + * @param + * The generic entity type. + * @return The generic modification response wrapper object as result of the modification request. + * @throws ODataResponseException + * When the OData batch response cannot be parsed. + * @throws IllegalArgumentException + * When the provided request reference could not be found in the original batch request. + */ + @Nonnull + public > ModificationResponse getModificationResult( + @Nonnull final CreateRequestBuilder operation ) + { + final ODataRequestResultGeneric clientResult = result.getResult(requestMapping.get(operation)); + return ModificationResponse.of(clientResult, operation.getEntity()); + } + + /** + * Extract the batch item result for the provided OData request. + * + * @param operation + * The OData operation that was used in the OData batch request. + * @param + * The generic entity type. + * @return The generic modification response wrapper object as result of the modification request. + * @throws ODataResponseException + * When the OData batch response cannot be parsed. + * @throws IllegalArgumentException + * When the provided request reference could not be found in the original batch request. + */ + @Nonnull + public > ModificationResponse getModificationResult( + @Nonnull final UpdateRequestBuilder operation ) + { + final ODataRequestResultGeneric clientResult = result.getResult(requestMapping.get(operation)); + return ModificationResponse.of(clientResult, operation.getEntity()); + } + + /** + * Extract the batch item result for the provided OData request. + * + * @param operation + * The OData operation that was used in the OData batch request. + * @param + * The generic entity type. + * @return The generic modification response wrapper object as result of the modification request. + * @throws ODataResponseException + * When the OData batch response cannot be parsed. + * @throws IllegalArgumentException + * When the provided request reference could not be found in the original batch request. + */ + @Nonnull + public > ModificationResponse getModificationResult( + @Nonnull final DeleteRequestBuilder operation ) + { + final ODataRequestResultGeneric clientResult = result.getResult(requestMapping.get(operation)); + return ModificationResponse.of(clientResult, operation.getEntity()); + } + + /** + * Extract the batch item result for the provided OData request. + * + * @param operation + * The OData operation that was used in the OData batch request. + * @param + * The generic object type. + * @return The generic action response wrapper object as result of the OData action request. + * @throws ODataResponseException + * When the OData batch response cannot be parsed. + * @throws IllegalArgumentException + * When the provided request reference could not be found in the original batch request. + */ + @Nonnull + public < + T> ActionResponseSingle getModificationResult( @Nonnull final SingleValueActionRequestBuilder operation ) + { + final ODataRequestResultGeneric clientResult = result.getResult(requestMapping.get(operation)); + return ActionResponseSingle.of(clientResult, operation.getResultClass()); + } + + /** + * Extract the batch item result for the provided OData request. + * + * @param operation + * The OData operation that was used in the OData batch request. + * @param + * The generic object type. + * @return The generic action response wrapper object as result of the OData action request. + * @throws ODataResponseException + * When the OData batch response cannot be parsed. + * @throws IllegalArgumentException + * When the provided request reference could not be found in the original batch request. + */ + @Nonnull + public ActionResponseCollection getModificationResult( + @Nonnull final CollectionValueActionRequestBuilder operation ) + { + final ODataRequestResultGeneric clientResult = result.getResult(requestMapping.get(operation)); + return ActionResponseCollection.of(clientResult, operation.getResultClass()); + } + + /** + * Closes the underlying HTTP response entity. + * + * @since 4.15.0 + */ + @Override + public void close() + { + result.close(); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/BoundAction.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/BoundAction.java new file mode 100644 index 0000000000..b44bfd00d4 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/BoundAction.java @@ -0,0 +1,158 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Map; + +import javax.annotation.Nonnull; + +/** + * Interface representing an action bound to a specific type. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ +public interface BoundAction extends BoundOperation +{ + /** + * The parameters to invoke this action with. + * + * @return The parameters to invoke this action with. + */ + @Nonnull + Map getParameters(); + + /** + * Specific {@link BoundAction action} operating on a single element and returning an object, if any. + * + * @param + * The type the action is bound to. + * @param + * The type this action returns. + */ + final class SingleToSingle extends AbstractBoundOperation.AbstractBoundAction + { + /** + * Default constructor. + * + * @param src + * The type the action is bound to. + * @param target + * The type this action returns. + * @param name + * The fully qualified name of the action. + * @param parameters + * The parameters to invoke this action with. + */ + public SingleToSingle( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map parameters ) + { + super(src, target, name, parameters); + } + } + + /** + * Specific {@link BoundAction action} operating on a single element and returning a collection of objects. + * + * @param + * The type the action is bound to. + * @param + * The type this action returns. + */ + final class SingleToCollection + extends + AbstractBoundOperation.AbstractBoundAction + { + /** + * Default constructor. + * + * @param src + * The type the action is bound to. + * @param target + * The type this action returns. + * @param name + * The fully qualified name of the action. + * @param parameters + * The parameters to invoke this action with. + */ + public SingleToCollection( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map parameters ) + { + super(src, target, name, parameters); + } + } + + /** + * Specific {@link BoundAction action} operating on a collection of elements and returning an object, if any. + * + * @param + * The type the action is bound to. + * @param + * The type this action returns. + */ + final class CollectionToSingle + extends + AbstractBoundOperation.AbstractBoundAction + { + /** + * Default constructor. + * + * @param src + * The type the action is bound to. + * @param target + * The type this action returns. + * @param name + * The fully qualified name of the action. + * @param parameters + * The parameters to invoke this action with. + */ + public CollectionToSingle( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map parameters ) + { + super(src, target, name, parameters); + } + } + + /** + * Specific {@link BoundAction action} operating on a collection of element and returning a collection of objects. + * + * @param + * The type the action is bound to. + * @param + * The type this action returns. + */ + final class CollectionToCollection + extends + AbstractBoundOperation.AbstractBoundAction + { + /** + * Default constructor. + * + * @param src + * The type the action is bound to. + * @param target + * The type this action returns. + * @param name + * The fully qualified name of the action. + * @param parameters + * The parameters to invoke this action with. + */ + public CollectionToCollection( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map parameters ) + { + super(src, target, name, parameters); + } + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/BoundFunction.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/BoundFunction.java new file mode 100644 index 0000000000..8fc44c2a70 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/BoundFunction.java @@ -0,0 +1,739 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Map; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataFunctionParameters; + +/** + * Interface representing a function bound to a specific type. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ +public interface BoundFunction extends BoundOperation +{ + /** + * The parameters this function is invoked with. + * + * @return The parameters this function is invoked with. + */ + @Nonnull + ODataFunctionParameters getParameters(); + + /* + we need to differentiate on the type system all following dimensions: + -> Composable (true/false) + -> Src Cardinality (single/collection) + -> Target Cardinality (single/collection) + -> Target Type (primitive, complex, entity) + + in total 24 combinations == 24 classes with this approach + */ + + /** + * Interface representing a composable bound function + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + interface Composable extends BoundFunction + { + } + + /*----------------------------------------------------------*/ + /* 1 - 1 Functions */ + /*----------------------------------------------------------*/ + + /** + * Specific {@link BoundFunction function} operating on a single element and returning a primitive. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + class SingleToSinglePrimitive extends SingleToSingle + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function argument names and their values + */ + public SingleToSinglePrimitive( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + } + + /** + * Specific {@link BoundFunction function} operating on a single element and returning a complex type. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + class SingleToSingleComplex> extends SingleToSingle + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public SingleToSingleComplex( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + } + + /** + * Specific {@link BoundFunction function} operating on a single element and returning an entity. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + class SingleToSingleEntity> extends SingleToSingle + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public SingleToSingleEntity( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + + /** + * Class representing a composable bound function + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + public static final class Composable> + extends + SingleToSingleEntity + implements + BoundFunction.Composable + { + + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public Composable( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + } + } + + /*----------------------------------------------------------*/ + /* 1 - N Functions */ + /*----------------------------------------------------------*/ + + /** + * Specific {@link BoundFunction function} operating on a single element and returning a collection of primitives. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + class SingleToCollectionPrimitive extends SingleToCollection + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public SingleToCollectionPrimitive( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + } + + /** + * Specific {@link BoundFunction function} operating on a single element and returning a collection of complex + * objects. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + class SingleToCollectionComplex> + extends + SingleToCollection + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public SingleToCollectionComplex( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + } + + /** + * Specific {@link BoundFunction function} operating on a single element and returning a collection of entities. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + class SingleToCollectionEntity> + extends + SingleToCollection + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public SingleToCollectionEntity( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + + /** + * Class representing a composable bound function + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + public static final class Composable> + extends + SingleToCollectionEntity + { + + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public Composable( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + } + } + + /*----------------------------------------------------------*/ + /* N - 1 Functions */ + /*----------------------------------------------------------*/ + /** + * Specific {@link BoundFunction function} operating on a collection of elements and returning a primitive. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + class CollectionToSinglePrimitive extends CollectionToSingle + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public CollectionToSinglePrimitive( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + } + + /** + * Specific {@link BoundFunction function} operating on a collection of elements and returning a complex object. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + class CollectionToSingleComplex> + extends + CollectionToSingle + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public CollectionToSingleComplex( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + } + + /** + * Specific {@link BoundFunction function} operating on a collection of elements and returning an entity. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + class CollectionToSingleEntity> + extends + CollectionToSingle + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public CollectionToSingleEntity( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + + /** + * Class representing a composable bound function + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + public static final class Composable> + extends + CollectionToSingleEntity + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public Composable( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + } + + } + + /*----------------------------------------------------------*/ + /* N - N Functions */ + /*----------------------------------------------------------*/ + /** + * Specific {@link BoundFunction function} operating on a collection of elements and returning a collection of + * primitives. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + class CollectionToCollectionPrimitive extends CollectionToCollection + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public CollectionToCollectionPrimitive( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + } + + /** + * Specific {@link BoundFunction function} operating on a collection of elements and returning a collection of + * complex objects. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + class CollectionToCollectionComplex> + extends + CollectionToCollection + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public CollectionToCollectionComplex( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + } + + /** + * Specific {@link BoundFunction function} operating on a collection of elements and returning a collection of + * entities. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + class CollectionToCollectionEntity> + extends + CollectionToCollection + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public CollectionToCollectionEntity( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + + /** + * Class representing a composable bound function + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + public static final class Composable> + extends + CollectionToCollectionEntity + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public Composable( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + } + } + + /*----------------------------------------------------------*/ + /* Variants without target type info */ + /* used by "applyFunction" which doesn't care about the */ + /* target type to be in the type system */ + /*----------------------------------------------------------*/ + + /** + * Specific {@link BoundFunction function} operating on a single element returning a single element. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + class SingleToSingle extends AbstractBoundOperation.AbstractBoundFunction + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public SingleToSingle( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + } + + /** + * Specific {@link BoundFunction function} operating on a single element returning a collection of elements. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + class SingleToCollection extends AbstractBoundOperation.AbstractBoundFunction + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public SingleToCollection( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + } + + /** + * Specific {@link BoundFunction function} operating on a collection of elements returning a single element. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + class CollectionToSingle extends AbstractBoundOperation.AbstractBoundFunction + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public CollectionToSingle( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + } + + /** + * Specific {@link BoundFunction function} operating on a collection of elements returning a collection of elements. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ + class CollectionToCollection + extends + AbstractBoundOperation.AbstractBoundFunction + { + /** + * Create an instance of a bound function. + * + * @param src + * The type this function is bound to. + * @param target + * The type this function returns. + * @param name + * The fully qualified name + * @param args + * Key-value-pairs of function arguments names and their values + */ + public CollectionToCollection( + @Nonnull final Class src, + @Nonnull final Class target, + @Nonnull final String name, + @Nonnull final Map args ) + { + super(src, target, name, args); + } + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/BoundOperation.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/BoundOperation.java new file mode 100644 index 0000000000..db19d07b17 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/BoundOperation.java @@ -0,0 +1,38 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +/** + * Interface representing OData operations (functions and actions) bound to a specific type. + * + * @param + * The type the function is bound to. + * @param + * The type this function returns. + */ +public interface BoundOperation +{ + /** + * The fully qualified name of the bound operation. + * + * @return The fully qualified name of bound operation. + */ + @Nonnull + String getQualifiedName(); + + /** + * The type this operations is bound to. + * + * @return The type this operations is bound to. + */ + @Nonnull + Class getBindingType(); + + /** + * The type this operations returns. + * + * @return The type this operations returns. + */ + @Nonnull + Class getReturnType(); +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/CollectionValueActionRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/CollectionValueActionRequestBuilder.java new file mode 100644 index 0000000000..4eec581e4c --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/CollectionValueActionRequestBuilder.java @@ -0,0 +1,105 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import lombok.AccessLevel; +import lombok.Getter; + +/** + * Representation of an OData action request as a fluent interface for further configuring the request and + * {@link #execute(Destination) executing} it. This one handles actions where the return type is a collection of + * primitive or entity or complex type values. + * + * @param + * The type of the result entity, or primitive or complex type + */ +public class CollectionValueActionRequestBuilder + extends + ActionRequestBuilder, ActionResponseCollection> +{ + @Getter( AccessLevel.PROTECTED ) + private final Class resultClass; + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param actionName + * The name of the unbound action + * @param resultClass + * The expected collection return type of the action. + */ + public CollectionValueActionRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final String actionName, + @Nonnull final Class resultClass ) + { + super(servicePath, actionName); + this.resultClass = resultClass; + } + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param actionName + * The name of the unbound action + * @param parameters + * The parameters passed to the action. + * @param resultClass + * The expected collection return type of the action. + */ + public CollectionValueActionRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final String actionName, + @Nonnull final Map parameters, + @Nonnull final Class resultClass ) + { + super(servicePath, actionName, parameters); + this.resultClass = resultClass; + } + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param actionPath + * The path to the unbound action + * @param parameters + * The parameters passed to the action. + * @param resultClass + * The expected collection return type of the action. + */ + public CollectionValueActionRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath actionPath, + @Nonnull final Map parameters, + @Nonnull final Class resultClass ) + { + super(servicePath, actionPath, parameters); + this.resultClass = resultClass; + } + + @Nonnull + @Override + public ActionResponseCollection execute( @Nonnull final Destination destination ) + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + + final ODataRequestResultGeneric response = toRequest().execute(httpClient); + + return ActionResponseCollection.of(response, resultClass); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/CollectionValueFunctionRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/CollectionValueFunctionRequestBuilder.java new file mode 100644 index 0000000000..68a81b31da --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/CollectionValueFunctionRequestBuilder.java @@ -0,0 +1,107 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +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 com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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 com.sap.cloud.sdk.datamodel.odata.client.request.ODataFunctionParameters; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import lombok.AccessLevel; +import lombok.Getter; + +/** + * Representation of an OData function request as a fluent interface for further configuring the request and + * {@link #execute(Destination) executing} it. This one handles functions where the return type is a collection of + * primitive or entity values. + * + * @param + * The type of the result entity, if any. + */ +public class CollectionValueFunctionRequestBuilder + extends + FunctionRequestBuilder, List> +{ + @Nonnull + @Getter( AccessLevel.PROTECTED ) + private final Class resultClass; + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param functionName + * The name of the unbound function + * @param resultClass + * The expected collection return type of the function. + */ + public CollectionValueFunctionRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final String functionName, + @Nonnull final Class resultClass ) + { + this(servicePath, functionName, Collections.emptyMap(), resultClass); + } + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param functionName + * The name of the unbound function + * @param parameters + * The parameters passed to the function. + * @param resultClass + * The expected collection return type of the function. + */ + public CollectionValueFunctionRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final String functionName, + @Nonnull final Map parameters, + @Nonnull final Class resultClass ) + { + this( + servicePath, + ODataResourcePath.of(functionName, ODataFunctionParameters.of(parameters, ODataProtocol.V4)), + resultClass); + + } + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param functionPath + * The {@link ODataResourcePath} identifying the function to invoke. + * @param resultClass + * The expected collection return type of the function. + */ + CollectionValueFunctionRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath functionPath, + @Nonnull final Class resultClass ) + { + super(servicePath, functionPath); + this.resultClass = resultClass; + } + + @Nonnull + @Override + public List execute( @Nonnull final Destination destination ) + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + final ODataRequestResultGeneric response = toRequest().execute(httpClient); + return response.asList(getResultClass()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ComplexProperty.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ComplexProperty.java new file mode 100644 index 0000000000..93d0e12ef6 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ComplexProperty.java @@ -0,0 +1,82 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableCollection; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableComplex; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Interface representing a complex property of {@link EntityT} that holds a complex type of {@link TargetT}. + * + * @param + * Entity this property is part of. + * @param + * {@link VdmComplex} this property represents. + */ +public interface ComplexProperty, TargetT extends VdmComplex> + extends + StructuredProperty +{ + /** + * A collection of complex objects. + * + * @param + * The entity type. + * @param + * The complex type. + */ + @Getter + @RequiredArgsConstructor + class Collection, ValueT extends VdmComplex> + implements + ComplexProperty, + ProtocolQueryRead, + FilterableCollection + { + private final Class entityType; + private final String fieldName; + private final Class itemType; + + @Override + @Nonnull + @SafeVarargs + @SuppressWarnings( "varargs" ) + public final ComplexPropertyQuery select( @Nonnull final Property... fields ) + { + return ComplexPropertyQuery. onProperty(getFieldName()).select(fields); + } + } + + /** + * A navigational property to a single other entity reference. + * + * @param + * The entity type. + * @param + * The navigable entity type. + */ + @Getter + @RequiredArgsConstructor + class Single, ValueT extends VdmComplex> + implements + ComplexProperty, + ProtocolQueryRead, + FilterableComplex + { + private final Class entityType; + private final String fieldName; + private final Class itemType; + + @Override + @Nonnull + @SafeVarargs + @SuppressWarnings( "varargs" ) + public final ComplexPropertyQuery select( @Nonnull final Property... fields ) + { + return ComplexPropertyQuery. onProperty(getFieldName()).select(fields); + } + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ComplexPropertyQuery.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ComplexPropertyQuery.java new file mode 100644 index 0000000000..b8e49e7a09 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ComplexPropertyQuery.java @@ -0,0 +1,50 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Arrays; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery; + +/** + * Implementation that represents read queries and holds their state at runtime. It allows for nested queries in a + * recursive manner. The implementation is the same for queries over single entities and collections of entities. In the + * VDM the available functionality is limited by the interfaces. + * + * In order to support a fluent creation of nested queries both the entity and the parent entity type are stored via + * generics. By implementing {@link AbstractStructuredPropertyQuery} the API doesn't differentiate between selections + * via referencing complex properties and selections via sub-queries. + * + * @param + * {@link VdmObject} this property is part of. + * @param + * {@link VdmComplex} type this property references to. + */ +public final class ComplexPropertyQuery, PropertyT extends VdmComplex> + extends + AbstractStructuredPropertyQuery +{ + private ComplexPropertyQuery( final StructuredQuery delegateQuery ) + { + super(delegateQuery); + } + + static < + ParentEntityT extends VdmObject, EntityT extends VdmComplex> + ComplexPropertyQuery + onProperty( @Nonnull final String fieldName ) + { + return new ComplexPropertyQuery<>(StructuredQuery.asNestedQueryOnProperty(fieldName, ODataProtocol.V4)); + } + + @Override + @SafeVarargs + @Nonnull + @SuppressWarnings( "varargs" ) + public final ComplexPropertyQuery select( @Nonnull final Property... fields ) + { + super.select(Arrays.asList(fields)); + return this; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/CountRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/CountRequestBuilder.java new file mode 100644 index 0000000000..92f9f4d1cb --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/CountRequestBuilder.java @@ -0,0 +1,152 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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 com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestCount; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableBoolean; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Representation of an OData request as a fluent interface for further configuring the request and + * {@link #execute(Destination) executing} it. + * + * @param + * The generic entity type. + */ +@Slf4j +public class CountRequestBuilder> + extends + AbstractEntityBasedRequestBuilder, EntityT, Long> + implements + ProtocolQueryFilter +{ + private final NavigationPropertyCollectionQuery delegateQuery; + + @Getter( AccessLevel.PROTECTED ) + private final Class entityClass; + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param entityClass + * The expected entity type. + * @param entityCollection + * The entity collection + */ + public CountRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final Class entityClass, + @Nonnull final String entityCollection ) + { + this(servicePath, ODataResourcePath.of(entityCollection), entityClass); + } + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param resourceToCount + * {@link ODataResourcePath} that identifies the collection to count. + * @param entityClass + * The expected entity type. + */ + @SuppressWarnings( "this-escape" ) + CountRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath resourceToCount, + @Nonnull final Class entityClass ) + { + super(servicePath, resourceToCount); + this.entityClass = entityClass; + this.delegateQuery = NavigationPropertyCollectionQuery.ofRootQuery(getResourcePath().toString()); + } + + /** + * Creates an instance of the {@link ODataRequestCount} based on the Entity class. + *

+ * The following settings are used: + *

    + *
  • the endpoint URL
  • + *
  • the entity collection name
  • + *
  • the filters to be applied
  • + *
+ * + * @return An initialized {@link ODataRequestCount}. + */ + @Override + @Nonnull + public ODataRequestCount toRequest() + { + final ODataRequestCount request = + new ODataRequestCount( + getServicePath(), + getResourcePath(), + delegateQuery.getEncodedQueryString(), + ODataProtocol.V4); + + return super.toRequest(request); + } + + @Override + @Nonnull + public Long execute( @Nonnull final Destination destination ) + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + final ODataRequestResultGeneric response = toRequest().execute(httpClient); + return response.as(Long.class); + } + + @Override + @SafeVarargs + @Nonnull + @SuppressWarnings( "varargs" ) + public final CountRequestBuilder filter( @Nonnull final FilterableBoolean... filters ) + { + delegateQuery.filter(filters); + return this; + } + + @Override + @Nonnull + public CountRequestBuilder search( @Nonnull final String search ) + { + delegateQuery.search(search); + return this; + } + + @Override + @Nonnull + public CountRequestBuilder search( @Nonnull final SearchExpression expression ) + { + delegateQuery.search(expression); + return this; + } + + /** + * Activates CSRF token retrieval for this OData request. + * + * @return The same request builder that will now fetch a CSRF token. + * @deprecated CSRF token handling is now performed automatically by the underlying HTTP client. This method is a + * no-op retained only for source compatibility, as read requests never require a CSRF token. It is + * scheduled for removal. + */ + @Deprecated + @Nonnull + public CountRequestBuilder withCsrfToken() + { + return this; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/CreateRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/CreateRequestBuilder.java new file mode 100644 index 0000000000..de043544c7 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/CreateRequestBuilder.java @@ -0,0 +1,155 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.google.gson.Gson; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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.ODataSerializationException; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestCreate; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Representation of an OData request as a fluent interface for further configuring the request and + * {@link #execute(Destination) executing} it. + * + * @param + * The type of the result entity. + */ +@Slf4j +public class CreateRequestBuilder> + extends + AbstractEntityBasedRequestBuilder, EntityT, ModificationResponse> + implements + ModificationRequestBuilder> +{ + /** + * Getter for the VDM representation of the entity to be created. + */ + @Nonnull + @Getter( AccessLevel.PROTECTED ) + private final EntityT entity; + + /** + * Instantiates a {@code CreateRequestBuilder}. + * + * @param servicePath + * The service path to direct the requests to. + * @param entity + * The entity to create. + * @param entityCollection + * The entity collection + */ + public CreateRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final EntityT entity, + @Nonnull final String entityCollection ) + { + this(servicePath, ODataResourcePath.of(entityCollection), entity); + } + + /** + * Instantiates a {@code CreateRequestBuilder}. + * + * @param servicePath + * The service path to direct the requests to. + * @param entityPath + * {@link ODataResourcePath} identifying the collection the entity should be created into. + * @param entity + * The entity to create. + */ + CreateRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath entityPath, + @Nonnull final EntityT entity ) + { + super(servicePath, entityPath); + this.entity = entity; + } + + @SuppressWarnings( "unchecked" ) + @Nonnull + @Override + protected Class getEntityClass() + { + return (Class) getEntity().getClass(); + } + + /** + * Execute the OData create request for the provided entity. + * + * {@inheritDoc} + * + * @return The wrapped service response, exposing response headers, status code and entity references. If the HTTP + * response is not within healthy bounds, then one of the declared runtime exceptions will be thrown with + * further details. + */ + @Nonnull + @Override + public ModificationResponse execute( @Nonnull final Destination destination ) + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + + final ODataRequestResultGeneric response = toRequest().execute(httpClient); + + return ModificationResponse.of(response, getEntity()); + } + + /** + * Creates an instance of the {@link ODataRequestCreate} based on the Entity class. + *

+ * The following settings are used: + *

    + *
  • the endpoint URL
  • + *
  • the entity collection name
  • + *
  • the entity JSON payload
  • + *
+ * + * @return An initialized {@link ODataRequestCreate}. + * @throws ODataSerializationException + * If entity cannot be serialized for HTTP request. + */ + @Override + @Nonnull + public ODataRequestCreate toRequest() + { + try { + final String serializedEntity = new Gson().toJson(entity); + + final ODataRequestCreate request = + new ODataRequestCreate(getServicePath(), getResourcePath(), serializedEntity, ODataProtocol.V4); + + return super.toRequest(request); + } + catch( final Exception e ) { + final String msg = "Failed to serialize HTTP request entity of type " + getEntityClass().getSimpleName(); + log.debug(msg, e); + final ODataRequestGeneric request = + new ODataRequestCreate(getServicePath(), getResourcePath(), "", ODataProtocol.V4); + throw new ODataSerializationException(request, entity, msg, e); + } + } + + /** + * Deactivates the CSRF token retrieval for this OData request. This is useful if the server does not support or + * require CSRF tokens as part of the request. + * + * @return The same builder + */ + @Override + @Nonnull + public CreateRequestBuilder withoutCsrfToken() + { + withHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER, "true"); + return this; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/DecimalDescriptor.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/DecimalDescriptor.java new file mode 100644 index 0000000000..ae2173351e --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/DecimalDescriptor.java @@ -0,0 +1,28 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation indicating the precision and scale of a decimal field + */ +@Retention( RetentionPolicy.RUNTIME ) +@Target( ElementType.FIELD ) +public @interface DecimalDescriptor { + + /** + * The associated precision of the decimal number + * + * @return The associated precision of the decimal number + */ + int precision(); + + /** + * The associated scale of the decimal number + * + * @return The associated scale of the decimal number + */ + int scale(); +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/DeleteRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/DeleteRequestBuilder.java new file mode 100644 index 0000000000..94a45c6582 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/DeleteRequestBuilder.java @@ -0,0 +1,171 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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 com.sap.cloud.sdk.datamodel.odata.client.request.ETagSubmissionStrategy; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestDelete; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Representation of an OData delete request as a fluent interface for further configuring the request and + * {@link #execute(Destination) executing} it. + * + * @param + * The type of the entity to delete. + */ +@Slf4j +public class DeleteRequestBuilder> + extends + AbstractEntityBasedRequestBuilder, EntityT, ModificationResponse> + implements + ModificationRequestBuilder> +{ + /** + * The entity object to be deleted by calling the {@link #execute(Destination)} method. + */ + @Nonnull + @Getter( AccessLevel.PROTECTED ) + private final EntityT entity; + + private ETagSubmissionStrategy eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_ETAG_FROM_ENTITY; + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param entity + * The entity to delete. + * @param entityCollection + * The entity collection + */ + public DeleteRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final EntityT entity, + @Nonnull final String entityCollection ) + { + this(servicePath, ODataResourcePath.of(entityCollection, entity.getKey()), entity); + } + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param entityPath + * The {@link ODataResourcePath} that identifies the entity to delete. + * @param entity + * The entity to delete. + */ + DeleteRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath entityPath, + @Nonnull final EntityT entity ) + { + super(servicePath, entityPath); + this.entity = entity; + } + + /** + * Execute the OData delete request for the provided entity. + * + * {@inheritDoc} + * + * @return The wrapped service response, exposing response headers, status code and entity references. If the HTTP + * response is not within healthy bounds, then one of the declared runtime exceptions will be thrown with + * further details. + */ + @Nonnull + @Override + public ModificationResponse execute( @Nonnull final Destination destination ) + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + + final ODataRequestResultGeneric response = toRequest().execute(httpClient); + + return ModificationResponse.of(response, getEntity()); + } + + /** + * Creates an instance of the {@link ODataRequestDelete} based on the Entity class. + *

+ * The following settings are used: + *

    + *
  • the endpoint URL
  • + *
  • the entity collection name
  • + *
  • the key fields of the entity
  • + *
+ * + * @return An initialized {@link ODataRequestDelete}. + */ + @Override + @Nonnull + public ODataRequestDelete toRequest() + { + final String versionIdentifier = + eTagSubmissionStrategy.getHeaderFromVersionIdentifier(entity.getVersionIdentifier()); + + final ODataRequestDelete request = + new ODataRequestDelete(getServicePath(), getResourcePath(), versionIdentifier, ODataProtocol.V4); + + return super.toRequest(request); + } + + /** + * The delete request will ignore any version identifier present on the entity and not send an `If-Match` header. + *

+ * Warning: This might lead to a response from the remote system that the `If-Match` header is missing. + *

+ * It depends on the implementation of the remote system whether the `If-Match` header is expected. + * + * @return The same request builder that will not send the `If-Match` header in the delete request + */ + @Nonnull + public DeleteRequestBuilder disableVersionIdentifier() + { + eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_NO_ETAG; + return this; + } + + /** + * The delete request will ignore any version identifier present on the entity and delete the entity, regardless of + * any changes on the remote entity. + *

+ * Warning: Be careful with this option, as this might overwrite any changes made to the remote + * representation of this object. + * + * @return The same request builder that will ignore the version identifier of the entity to delete + */ + @Nonnull + public DeleteRequestBuilder matchAnyVersionIdentifier() + { + eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_ANY_MATCH_ETAG; + return this; + } + + @SuppressWarnings( "unchecked" ) + @Nonnull + @Override + protected Class getEntityClass() + { + return (Class) entity.getClass(); + } + + @Nonnull + @Override + public DeleteRequestBuilder withoutCsrfToken() + { + withHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER, "true"); + return this; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/FunctionRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/FunctionRequestBuilder.java new file mode 100644 index 0000000000..4ae0c1bb84 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/FunctionRequestBuilder.java @@ -0,0 +1,74 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +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 com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestFunction; + +/** + * Representation of a non-CRUD OData request as a fluent interface for further configuring the request and + * {@link #execute(Destination) executing} it. + * + * @param + * The request builder type. + * @param + * The type of the result entity, if any. + */ +public abstract class FunctionRequestBuilder, ResultT> + extends + AbstractRequestBuilder + implements + ReadRequestBuilder +{ + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param functionPath + * The {@link ODataResourcePath} identifying the function to invoke. + */ + public FunctionRequestBuilder( @Nonnull final String servicePath, @Nonnull final ODataResourcePath functionPath ) + { + super(servicePath, functionPath); + } + + /** + * Creates an instance of {@link ODataRequestFunction} based on the Entity class. + *

+ * The following settings are used: + *

    + *
  • the endpoint URL
  • + *
  • the function name
  • + *
  • the parameters if applicable
  • + *
+ * + * @return An initialized {@link ODataRequestFunction}. + */ + @Override + @Nonnull + public ODataRequestFunction toRequest() + { + final ODataRequestFunction request = + new ODataRequestFunction(getServicePath(), getResourcePath(), null, ODataProtocol.V4); + + return super.toRequest(request); + } + + /** + * Activates CSRF token retrieval for this OData request. + * + * @return The same request builder that will now fetch a CSRF token. + * @deprecated CSRF token handling is now performed automatically by the underlying HTTP client. This method is a + * no-op retained only for source compatibility. It is scheduled for removal. + */ + @Deprecated + @Override + @Nonnull + public BuilderT withCsrfToken() + { + return getThis(); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/GetAllRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/GetAllRequestBuilder.java new file mode 100644 index 0000000000..0ed7f78bc6 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/GetAllRequestBuilder.java @@ -0,0 +1,274 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.List; +import java.util.stream.Stream; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.google.common.collect.Streams; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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 com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FieldOrdering; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableBoolean; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Representation of an OData request as a fluent interface for further configuring the request and + * {@link #execute(Destination) executing} it. + * + * @param + * The type of the result entity. + */ +@Slf4j +public class GetAllRequestBuilder> + extends + AbstractEntityBasedRequestBuilder, EntityT, List> + implements + ProtocolQueryRead, + ProtocolQueryReadCollection, + ReadRequestBuilder> +{ + private final NavigationPropertyCollectionQuery delegateQuery; + + @Getter( AccessLevel.PROTECTED ) + @Nonnull + private final Class entityClass; + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param entityClass + * The expected entity type. + * @param entityCollection + * The entity collection + */ + public GetAllRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final Class entityClass, + @Nonnull final String entityCollection ) + { + this(servicePath, ODataResourcePath.of(entityCollection), entityClass); + } + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param entityPath + * {@link ODataResourcePath} identifying the entity collection to read. + * @param entityClass + * The expected entity type. + */ + @SuppressWarnings( "this-escape" ) + GetAllRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath entityPath, + @Nonnull final Class entityClass ) + { + super(servicePath, entityPath); + this.entityClass = entityClass; + this.delegateQuery = NavigationPropertyCollectionQuery.ofRootQuery(getResourcePath().toString()); + } + + /** + * Creates an instance of the {@link ODataRequestRead} based on the Entity class. + *

+ * The following settings are used: + *

    + *
  • the endpoint URL
  • + *
  • the entity collection name
  • + *
  • the number of entries to select (top)
  • + *
  • the number of entries to ignore (skip)
  • + *
  • the order direction
  • + *
  • the filters to be applied
  • + *
  • the fields to be selected
  • + *
+ * + * @return An initialized {@link ODataRequestRead}. + */ + @Override + @Nonnull + public ODataRequestRead toRequest() + { + final ODataRequestRead request = + new ODataRequestRead( + getServicePath(), + getResourcePath(), + delegateQuery.getEncodedQueryString(), + ODataProtocol.V4); + + return super.toRequest(request); + } + + /** + * {@inheritDoc} + *

+ * Note: If the OData service responds with service-driven pagination, then the pages will be + * iterated automatically. The returned list is an eagerly loaded aggregation of all pages. Access to a lazy loading + * result-set can be enabled through the modifiers {@link #iteratingEntities()}, {@link #streamingEntities()} and + * {@link #iteratingPages()}. + */ + @Override + @Nonnull + public List execute( @Nonnull final Destination destination ) + { + final Iterable iterableItems = iteratingEntities().execute(destination); + return Lists.newArrayList(iterableItems); // eagerly request and parse all pages of the result-set + } + + /** + * Manually explore the individual pages from the result-set. The returning object allows for memory-efficient + * consumption of all data through server-driven pagination. + * + * @return An instance of {@link RequestBuilderExecutable} with a response object to lazily iterate through the + * pages of entities. + */ + @Nonnull + public RequestBuilderExecutable>> iteratingPages() + { + return this::executeInternal; + } + + /** + * Iterate through all entities from the result-set. The individual pages of the result-set are queried lazily. The + * returning object allows for memory-efficient consumption of all data through server-driven pagination. + * + * @return An instance of {@link RequestBuilderExecutable} with a response object to lazily iterate through the + * entities. + */ + @Nonnull + public RequestBuilderExecutable> iteratingEntities() + { + // concat applies lazy evaluation so individual pages will still be loaded lazily + return destination -> Iterables.concat(executeInternal(destination)); + } + + /** + * Stream through all entities from the result-set. The individual pages of the result-set are queried lazily. The + * returning object allows for memory-efficient consumption of all data through server-driven pagination. + * + * @return An instance of {@link RequestBuilderExecutable} with a response object to lazily iterate through the + * entities. + */ + @Nonnull + public RequestBuilderExecutable> streamingEntities() + { + // concat applies lazy evaluation so individual pages will still be loaded lazily + return destination -> Streams.stream(Iterables.concat(executeInternal(destination))); + } + + /** + * Set the preferred page size of the OData response. A result-set may be split into multiple pages, each including + * a subset of the entities matching the query. + *

+ * Note: The OData service might ignore the preferred page size setting and may not use pagination + * at all. + * + * @param size + * The preferred page size + * @return This request object with the added parameter. + */ + @Nonnull + public GetAllRequestBuilder withPreferredPageSize( final int size ) + { + return withHeader("Prefer", "odata.maxpagesize=" + size); + } + + @Override + @SafeVarargs + @Nonnull + @SuppressWarnings( { "varargs" } ) + public final GetAllRequestBuilder select( @Nonnull final Property... fields ) + { + delegateQuery.select(fields); + return this; + } + + @Override + @SafeVarargs + @Nonnull + @SuppressWarnings( "varargs" ) + public final GetAllRequestBuilder filter( @Nonnull final FilterableBoolean... filters ) + { + delegateQuery.filter(filters); + return this; + } + + @Override + @Nonnull + public GetAllRequestBuilder top( final int top ) + { + delegateQuery.top(top); + return this; + } + + @Override + @Nonnull + public GetAllRequestBuilder skip( final int skip ) + { + delegateQuery.skip(skip); + return this; + } + + @SafeVarargs + @Override + @Nonnull + @SuppressWarnings( "varargs" ) + public final GetAllRequestBuilder orderBy( @Nonnull final FieldOrdering... ordering ) + { + delegateQuery.orderBy(ordering); + return this; + } + + @Override + @Nonnull + public GetAllRequestBuilder search( @Nonnull final String search ) + { + delegateQuery.search(search); + return this; + } + + @Override + @Nonnull + public GetAllRequestBuilder search( @Nonnull final SearchExpression expression ) + { + delegateQuery.search(expression); + return this; + } + + @Nonnull + private Iterable> executeInternal( @Nonnull final Destination destination ) + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + return toRequest().execute(httpClient).iteratePages(getEntityClass()); + } + + /** + * Activates CSRF token retrieval for this OData request. + * + * @return The same request builder that will now fetch a CSRF token. + * @deprecated CSRF token handling is now performed automatically by the underlying HTTP client. This method is a + * no-op retained only for source compatibility, as read requests never require a CSRF token. It is + * scheduled for removal. + */ + @Deprecated + @Override + @Nonnull + public GetAllRequestBuilder withCsrfToken() + { + return this; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/GetByKeyRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/GetByKeyRequestBuilder.java new file mode 100644 index 0000000000..a0e374d0b6 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/GetByKeyRequestBuilder.java @@ -0,0 +1,172 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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 com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestReadByKey; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Representation of an OData request to retrieve an entity by its key as a fluent interface for further configuring the + * request and {@link #execute(Destination) executing} it. + * + * @param + * The type of the result entity. + */ +@Slf4j +public class GetByKeyRequestBuilder> + extends + AbstractEntityBasedRequestBuilder, EntityT, EntityT> + implements + ProtocolQueryRead, + ReadRequestBuilder +{ + private final NavigationPropertySingleQuery delegateQuery; + + @Getter( AccessLevel.PROTECTED ) + @Nonnull + private final Class entityClass; + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param entityClass + * The expected entity type. + * @param entityKey + * The composite entity key. + * @param entityCollection + * The entity collection + * @throws IllegalArgumentException + * When there is no mapping found for one of the provided Java literal in the composite key. + */ + public GetByKeyRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final Class entityClass, + @Nonnull final Map entityKey, + @Nonnull final String entityCollection ) + { + this(servicePath, entityClass, ODataEntityKey.of(entityKey, ODataProtocol.V4), entityCollection); + } + + /** + * Instantiates a request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param entityClass + * The expected entity type. + * @param entityKey + * The composite entity key. + * @param entityCollection + * The entity collection + */ + GetByKeyRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final Class entityClass, + @Nonnull final ODataEntityKey entityKey, + @Nonnull final String entityCollection ) + { + this(servicePath, ODataResourcePath.of(entityCollection, entityKey), entityClass); + } + + /** + * Instantiates a request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param entityClass + * The expected entity type. + * @param entityPath + * The {@link ODataResourcePath} that identifies the entity to read. + */ + @SuppressWarnings( "this-escape" ) + GetByKeyRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath entityPath, + @Nonnull final Class entityClass ) + { + super(servicePath, entityPath); + this.entityClass = entityClass; + this.delegateQuery = NavigationPropertySingleQuery.ofRootQuery(getResourcePath().toString()); + } + + /** + * Creates an instance of {@link ODataRequestReadByKey} based on the Entity class. + *

+ * The following settings are used: + *

    + *
  • the endpoint URL
  • + *
  • the entity collection name
  • + *
  • the key fields of the entity
  • + *
  • the fields to be selected
  • + *
+ * + * @return An initialized {@link ODataRequestReadByKey}. + */ + @Override + @Nonnull + public ODataRequestReadByKey toRequest() + { + final ODataRequestReadByKey request = + new ODataRequestReadByKey( + getServicePath(), + getResourcePath(), + delegateQuery.getEncodedQueryString(), + ODataProtocol.V4); + + return super.toRequest(request); + } + + @Override + @Nonnull + public EntityT execute( @Nonnull final Destination destination ) + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + final ODataRequestResultGeneric response = toRequest().execute(httpClient); + final EntityT entity = response.as(getEntityClass()); + + response.getVersionIdentifierFromHeader().peek(entity::setVersionIdentifier); + + return entity; + } + + @Override + @SafeVarargs + @Nonnull + @SuppressWarnings( "varargs" ) + public final GetByKeyRequestBuilder select( @Nonnull final Property... fields ) + { + delegateQuery.select(fields); + return this; + } + + /** + * Activates CSRF token retrieval for this OData request. + * + * @return The same request builder that will now fetch a CSRF token. + * @deprecated CSRF token handling is now performed automatically by the underlying HTTP client. This method is a + * no-op retained only for source compatibility, as read requests never require a CSRF token. It is + * scheduled for removal. + */ + @Deprecated + @Override + @Nonnull + public GetByKeyRequestBuilder withCsrfToken() + { + return this; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ModificationRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ModificationRequestBuilder.java new file mode 100644 index 0000000000..d626128d1b --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ModificationRequestBuilder.java @@ -0,0 +1,32 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; + +/** + * Interface to mark OData request types as modifying operation. + * + * @param + * The type of the request's result, if any. + * + * @see CreateRequestBuilder + * @see DeleteRequestBuilder + * @see UpdateRequestBuilder + * @see ActionRequestBuilder + */ +public interface ModificationRequestBuilder extends RequestBuilder +{ + /** + * Deactivates the CSRF token retrieval for this OData request. This is useful if the server does not support or + * require CSRF tokens as part of the request. + * + * @return The same builder + */ + @Nonnull + default ModificationRequestBuilder withoutCsrfToken() + { + withHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER, "true"); + return this; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ModificationResponse.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ModificationResponse.java new file mode 100644 index 0000000000..945dc7b8fe --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ModificationResponse.java @@ -0,0 +1,147 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.Gson; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import io.vavr.control.Option; +import io.vavr.control.Try; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +/** + * Generic OData service response wrapper for data modification requests. + * + * @param + * The generic entity type. + */ +@EqualsAndHashCode +@ToString +@RequiredArgsConstructor( staticName = "of", access = AccessLevel.PUBLIC ) +@Slf4j +public final class ModificationResponse> +{ + private static final Gson GSON = new Gson(); + + @Nullable + private EntityT modifiedEntity; + + @Nonnull + private Option responseEntity = Option.none(); + + @Nonnull + private final ODataRequestResultGeneric result; + + @Nonnull + private final EntityT originalRequestEntity; + + /** + * Get an updated version of the entity. If the service responded with an entity it is returned here. If the service + * didn't respond with an entity but send an @{code ETag} header, the entity is updated and returned. Otherwise a + * copy of the original, unmodified entity is returned. + * + * @return The modified entity. + */ + @Nonnull + public synchronized EntityT getModifiedEntity() + { + if( modifiedEntity == null ) { + evaluateResponse(); + } + return modifiedEntity; + } + + /** + * Get the optional response entity parsed from the HTTP content. + * + * @return The parsed entity or none. + */ + @Nonnull + public synchronized Option getResponseEntity() + { + // We synchronize the full method here because Fortify does not like double checked locking: + // https://vulncat.fortify.com/en/detail?id=desc.structural.java.code_correctness_double_checked_locking + if( modifiedEntity == null ) { + evaluateResponse(); + } + return responseEntity; + } + + @SuppressWarnings( "unchecked" ) + private void evaluateResponse() + { + responseEntity = parseEntityFromResponse(); + + // create a copy before modifying the version identifier to not change existing objects + final EntityT entityToModify = responseEntity.getOrElse(originalRequestEntity); + + // clone entity + modifiedEntity = GSON.fromJson(GSON.toJson(entityToModify), (Class) originalRequestEntity.getClass()); + + modifiedEntity.setVersionIdentifier(getUpdatedVersionIdentifier().getOrNull()); + } + + /** + * Access the original entity used to make the request. + * + * @return The original entity object used to perform an OData request. + */ + @Nonnull + public EntityT getRequestEntity() + { + return originalRequestEntity; + } + + /** + * Get the response status code. + * + * @return The integer representation of the HTTP status code. + */ + public int getResponseStatusCode() + { + return result.getHttpResponse().getCode(); + } + + /** + * Get the response headers. + * + * @return The headers of the HTTP status code. + */ + @Nonnull + public Map> getResponseHeaders() + { + return result.getAllHeaderValues(); + } + + /** + * Get the version identifier present in the response headers, the identifier of the original entity or none, if + * neither exist. + * + * @return An up to date version identifier or none. + */ + @Nonnull + public Option getUpdatedVersionIdentifier() + { + return result + .getVersionIdentifierFromHeader() + .orElse(() -> getResponseEntity().flatMap(entity -> entity.getVersionIdentifier())); + } + + @Nonnull + @SuppressWarnings( "unchecked" ) + private Option parseEntityFromResponse() + { + return Try + .of(() -> result.as((Class) originalRequestEntity.getClass())) + .onFailure(e -> log.debug("Failed to parse entity from HTTP response.", e)) + .toOption() + .peek(entity -> result.getVersionIdentifierFromHeader().peek(entity::setVersionIdentifier)); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigableEntityCollection.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigableEntityCollection.java new file mode 100644 index 0000000000..cc0f990e56 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigableEntityCollection.java @@ -0,0 +1,46 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +/** + * Related interface to provide access to type-safe request builders for the generic entity type. + * + * @param + * The generic entity type for which request builders can be instantiated. + */ +public interface NavigableEntityCollection> extends OperationsOnEntityCollections +{ + /** + * Fetch multiple entities. + * + * @return A request builder to fetch multiple entities. This request builder allows methods which modify the + * underlying query to be called before executing the query itself. To perform execution, call the + * {@link GetAllRequestBuilder#execute execute} method on the request builder object. + */ + @Nonnull + GetAllRequestBuilder getAll(); + + /** + * Create a new entity and save it to the OData service. + * + * @param item + * The entity object that will be created and saved. + *

+ * Constraints: Not nullable + *

+ * @return A request builder to create and save a new entity. To perform execution, call the + * {@link CreateRequestBuilder#execute execute} method on the request builder object. + */ + @Nonnull + CreateRequestBuilder create( @Nonnull final EntityT item ); + + /** + * Fetch the number of entries from the entity collection matching the filter and search expressions. + * + * @return A request builder to fetch the count of entities. This request builder allows methods which modify the + * underlying query to be called before executing the query itself. To perform execution, call the + * {@link CountRequestBuilder#execute execute} method on the request builder object. + */ + @Nonnull + CountRequestBuilder count(); +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigableEntitySingle.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigableEntitySingle.java new file mode 100644 index 0000000000..362c229484 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigableEntitySingle.java @@ -0,0 +1,153 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +/** + * Related interface to provide access to type-safe request builders for the generic entity type. + * + * @param + * The generic entity type for which request builders can be instantiated. + */ +public interface NavigableEntitySingle> +{ + /** + * Fetch the current entity. + * + * @return A request builder to fetch multiple entities. This request builder allows methods which modify the + * underlying query to be called before executing the query itself. To perform execution, call the + * {@link GetByKeyRequestBuilder#execute execute} method on the request builder object. + */ + @Nonnull + GetByKeyRequestBuilder get(); + + /** + * Update an existing entity and save it to the OData service. + * + * @param item + * The entity object that will be updated. + *

+ * Constraints: Not nullable + *

+ * @return A request builder to update an existing entity. To perform execution, call the + * {@link UpdateRequestBuilder#execute execute} method on the request builder object. + */ + @Nonnull + UpdateRequestBuilder update( @Nonnull final EntityT item ); + + /** + * Deletes the current entity in the OData service. + * + * @return A request builder to delete an existing entity. To perform execution, call the + * {@link DeleteRequestBuilder#execute execute} method on the request builder object. + */ + @Nonnull + DeleteRequestBuilder delete(); + + /** + * Navigate to a specific navigation property of current entity type. + * + * @param property + * The navigation property (collection) to be used. + * @param + * The generic type of target entity. + * @return A request builder to access further navigation properties or to instantiate type-safe request builders. + */ + @Nonnull + > NavigableEntityCollection navigateTo( + @Nonnull final NavigationProperty.Collection property ); + + /** + * Navigate to a specific navigation property of current entity type. + * + * @param property + * The navigation property (single) to be used. + * @param + * The generic type of target entity. + * @return A request builder to access further navigation properties or to instantiate type-safe request builders. + */ + @Nonnull + > NavigableEntitySingle navigateTo( + @Nonnull final NavigationProperty.Single property ); + + /** + * Apply a bound function returning a single object to the current element. + * + * @param function + * The function to apply. Functions are available on generated entity classes. + * @param + * The return type of the function. + * @return A new request builder for the supplied function applied to the current element. + */ + @Nonnull + SingleValueFunctionRequestBuilder applyFunction( + @Nonnull final BoundFunction.SingleToSingle function ); + + /** + * Apply a bound function returning a collection of objects to the current element. + * + * @param function + * The function to apply. Functions are available on generated entity classes. + * @param + * The type of items in the result of the function. + * @return A new request builder for the supplied function applied to the current element. + */ + @Nonnull + CollectionValueFunctionRequestBuilder applyFunction( + @Nonnull final BoundFunction.SingleToCollection function ); + + /** + * Use a composable function as a path element in an OData request. Similar to + * {@link #applyFunction(BoundFunction.SingleToSingle) applyFunction} but allows further path segments after the + * function. Functions must be marked as {@code composable} by the service. + * + * @param function + * The composable functions. + * @param + * The return type of the function. + * @return A request builder that allows for adding further path segments. + */ + @Nonnull + > NavigableEntitySingle withFunction( + @Nonnull final BoundFunction.SingleToSingleEntity.Composable function ); + + /** + * Use a composable function as a path element in an OData request. Similar to + * {@link #applyFunction(BoundFunction.SingleToSingle) applyFunction} but allows further path segments after the + * function. Functions must be marked as {@code composable} by the service. + * + * @param function + * The composable functions. + * @param + * The return type of the function. + * @return A request builder that allows for adding further path segments. + */ + @Nonnull + > NavigableEntityCollection withFunction( + @Nonnull final BoundFunction.SingleToCollectionEntity.Composable function ); + + /** + * Apply a bound action returning a single or no object to the current element. + * + * @param action + * The action to apply. Actions are available on generated entity classes. + * @param + * The return type of the action. + * @return A new request builder for the supplied action applied to the current element. + */ + @Nonnull + SingleValueActionRequestBuilder applyAction( + @Nonnull final BoundAction.SingleToSingle action ); + + /** + * Apply a bound action returning a collection of elements to the current element. + * + * @param action + * The action to apply. Actions are available on generated entity classes. + * @param + * The return type of the action. + * @return A new request builder for the supplied action applied to the current element. + */ + @Nonnull + CollectionValueActionRequestBuilder applyAction( + @Nonnull final BoundAction.SingleToCollection action ); +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigationProperty.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigationProperty.java new file mode 100644 index 0000000000..62063b8c3a --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigationProperty.java @@ -0,0 +1,74 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableCollection; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableComplex; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Interface representing a navigational property of {@link EntityT} that points towards an entity of {@link TargetT}. + * + * @param + * Entity this property is part of. + * @param + * Entity this property references. + */ +public interface NavigationProperty, TargetT extends VdmEntity> + extends + StructuredProperty +{ + /** + * A navigational property to a Collection of another entity reference. + * + * @param + * The entity type. + * @param + * The navigable entity type. + */ + @Getter + @RequiredArgsConstructor + class Collection, ValueT extends VdmEntity> + extends + NavigationPropertyCollection + implements + NavigationProperty, + FilterableCollection + { + private final Class entityType; + private final String fieldName; + private final Class itemType; + } + + /** + * A navigational property to a single other entity reference. + * + * @param + * The entity type. + * @param + * The navigable entity type. + */ + @Getter + @RequiredArgsConstructor + class Single, ValueT extends VdmEntity> + implements + NavigationProperty, + ProtocolQueryRead, + FilterableComplex + { + private final Class entityType; + private final String fieldName; + private final Class itemType; + + @Override + @Nonnull + @SafeVarargs + @SuppressWarnings( "varargs" ) + public final NavigationPropertySingleQuery select( @Nonnull final Property... fields ) + { + return NavigationPropertySingleQuery. ofSubQuery(getFieldName()).select(fields); + } + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigationPropertyCollection.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigationPropertyCollection.java new file mode 100644 index 0000000000..1f99f9bbc6 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigationPropertyCollection.java @@ -0,0 +1,80 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odatav4.expression.FieldOrdering; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableBoolean; + +/** + * Abstract class representing the query capabilities of a collection of structured properties of {@link EntityT} with a + * type of {@link TargetT}. + * + * @param + * Entity this property is part of. + * @param + * Entity type of the collection this property references to. + */ +public abstract class NavigationPropertyCollection, TargetT extends VdmEntity> + implements + StructuredProperty, + ProtocolQueryReadCollection, + ProtocolQueryRead +{ + @Override + @SafeVarargs + @Nonnull + @SuppressWarnings( "varargs" ) + public final NavigationPropertyCollectionQuery select( + @Nonnull final Property... fields ) + { + return NavigationPropertyCollectionQuery. ofSubQuery(getFieldName()).select(fields); + } + + @Override + @SafeVarargs + @Nonnull + @SuppressWarnings( "varargs" ) + public final NavigationPropertyCollectionQuery filter( + @Nonnull final FilterableBoolean... filters ) + { + return NavigationPropertyCollectionQuery. ofSubQuery(getFieldName()).filter(filters); + } + + @Override + @Nonnull + public NavigationPropertyCollectionQuery top( final int top ) + { + return NavigationPropertyCollectionQuery. ofSubQuery(getFieldName()).top(top); + } + + @Override + @Nonnull + public NavigationPropertyCollectionQuery skip( final int skip ) + { + return NavigationPropertyCollectionQuery. ofSubQuery(getFieldName()).skip(skip); + } + + @Override + @SafeVarargs + @Nonnull + @SuppressWarnings( "varargs" ) + public final NavigationPropertyCollectionQuery orderBy( + @Nonnull final FieldOrdering... ordering ) + { + return NavigationPropertyCollectionQuery. ofSubQuery(getFieldName()).orderBy(ordering); + } + + @Override + @Nonnull + public NavigationPropertyCollectionQuery search( @Nonnull final String search ) + { + return NavigationPropertyCollectionQuery. ofSubQuery(getFieldName()).search(search); + } + + @Override + @Nonnull + public NavigationPropertyCollectionQuery search( @Nonnull final SearchExpression expression ) + { + return NavigationPropertyCollectionQuery. ofSubQuery(getFieldName()).search(expression); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigationPropertyCollectionQuery.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigationPropertyCollectionQuery.java new file mode 100644 index 0000000000..69669a66fc --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigationPropertyCollectionQuery.java @@ -0,0 +1,129 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; + +import javax.annotation.Nonnull; + +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 com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FieldOrdering; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableBoolean; + +/** + * Implementation that represents read queries and holds their state at runtime. It allows for nested queries in a + * recursive manner. The implementation is the same for queries over single entities and collections of entities. In the + * VDM the available functionality is limited by the interfaces. + * + * In order to support a fluent creation of nested queries both the entity and the parent entity type are stored via + * generics. By implementing {@link ProtocolQueryReadCollection} the API doesn't differentiate between selections via + * referencing navigational properties and selections via sub-queries. + * + * @param + * The generic navigation property entity source type. + * @param + * The generic navigation property entity target type. + */ +public final class NavigationPropertyCollectionQuery, EntityT extends VdmEntity> + extends + AbstractStructuredPropertyQuery + implements + ProtocolQueryReadCollection +{ + private NavigationPropertyCollectionQuery( final StructuredQuery delegateQuery ) + { + super(delegateQuery); + } + + static < + ParentEntityT extends VdmObject, EntityT extends VdmEntity> + NavigationPropertyCollectionQuery + ofRootQuery( @Nonnull final String fieldName ) + { + return new NavigationPropertyCollectionQuery<>(StructuredQuery.onEntity(fieldName, ODataProtocol.V4)); + } + + static < + ParentEntityT extends VdmObject, EntityT extends VdmEntity> + NavigationPropertyCollectionQuery + ofSubQuery( @Nonnull final String fieldName ) + { + return new NavigationPropertyCollectionQuery<>( + StructuredQuery.asNestedQueryOnProperty(fieldName, ODataProtocol.V4)); + } + + @Override + @SafeVarargs + @Nonnull + @SuppressWarnings( "varargs" ) + public final NavigationPropertyCollectionQuery select( + @Nonnull final Property... fields ) + { + super.select(Arrays.asList(fields)); + return this; + } + + @Override + @SafeVarargs + @Nonnull + @SuppressWarnings( "varargs" ) + public final NavigationPropertyCollectionQuery filter( + @Nonnull final FilterableBoolean... filters ) + { + final Collection untypedFilters = new ArrayList<>(); + for( final FilterableBoolean fb : filters ) { + untypedFilters.add(fb::getExpression); + } + untypedFilters.forEach(delegateQuery::filter); + return this; + } + + @Override + @Nonnull + public NavigationPropertyCollectionQuery top( final int top ) + { + delegateQuery.top(top); + return this; + } + + @Override + @Nonnull + public NavigationPropertyCollectionQuery skip( final int skip ) + { + delegateQuery.skip(skip); + return this; + } + + @SafeVarargs + @Override + @Nonnull + public final NavigationPropertyCollectionQuery orderBy( + @Nonnull final FieldOrdering... ordering ) + { + final OrderExpression expression = FieldOrdering.toOrderExpression(ordering); + if( expression != null ) { + delegateQuery.orderBy(expression); + } + return this; + } + + @Nonnull + @Override + public NavigationPropertyCollectionQuery search( @Nonnull final String search ) + { + delegateQuery.search(SearchExpression.getDoubleQuotedString(search)); + return this; + } + + @Nonnull + @Override + public NavigationPropertyCollectionQuery search( + @Nonnull final SearchExpression expression ) + { + delegateQuery.search(expression.getTerm()); + return this; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigationPropertySingleQuery.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigationPropertySingleQuery.java new file mode 100644 index 0000000000..32e4dc87f0 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/NavigationPropertySingleQuery.java @@ -0,0 +1,60 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Arrays; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery; + +/** + * Implementation that represents read queries and holds their state at runtime. It allows for nested queries in a + * recursive manner. The implementation is the same for queries over single entities and collections of entities. In the + * VDM the available functionality is limited by the interfaces. + * + * In order to support a fluent creation of nested queries both the entity and the parent entity type are stored via + * generics. By implementing {@link NavigationProperty} the API doesn't differentiate between selections via referencing + * navigational properties and selections via sub-queries. + * + * @param + * The generic navigation property entity source type. + * @param + * The generic navigation property entity target type. + */ +public final class NavigationPropertySingleQuery, EntityT extends VdmEntity> + extends + AbstractStructuredPropertyQuery +{ + private NavigationPropertySingleQuery( final StructuredQuery delegateQuery ) + { + super(delegateQuery); + } + + static < + ParentEntityT extends VdmObject, EntityT extends VdmEntity> + NavigationPropertySingleQuery + ofRootQuery( @Nonnull final String fieldName ) + { + return new NavigationPropertySingleQuery<>(StructuredQuery.onEntity(fieldName, ODataProtocol.V4)); + } + + static < + ParentEntityT extends VdmObject, EntityT extends VdmEntity> + NavigationPropertySingleQuery + ofSubQuery( @Nonnull final String fieldName ) + { + return new NavigationPropertySingleQuery<>( + StructuredQuery.asNestedQueryOnProperty(fieldName, ODataProtocol.V4)); + } + + @Override + @SafeVarargs + @Nonnull + @SuppressWarnings( "varargs" ) + public final NavigationPropertySingleQuery select( + @Nonnull final Property... fields ) + { + super.select(Arrays.asList(fields)); + return this; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/OperationsOnEntityCollections.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/OperationsOnEntityCollections.java new file mode 100644 index 0000000000..85522a1835 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/OperationsOnEntityCollections.java @@ -0,0 +1,125 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +/** + * Invoke actions and functions on OData collections + */ +public interface OperationsOnEntityCollections +{ + /** + * Create a generic type-safe request using navigation properties on entity sets of the OData service. + * + * @param entity + * A template entity instance that contains all necessary key attributes. + *

+ * Constraints: Not nullable + *

+ * @param + * The generic entity type for which navigation properties can be accessed. + * @return A request builder to navigate the service request using the provided key fields of an entity. With + * another navigation property this object can be used to instantiate new request builders. + */ + @Nonnull + > NavigableEntitySingle forEntity( @Nonnull final EntityT entity ); + + /** + * Use a composable function as a path element in an OData request. Similar to + * {@link #applyFunction(BoundFunction.CollectionToSingle) applyFunction} but allows further path segments after the + * function. Functions must be marked as {@code composable} by the service. + * + * @param function + * The composable functions. + * @param + * The entity type + * @param + * The return type of the function. + * @return A request builder that allows for adding further path segments. + */ + @Nonnull + < + EntityT extends VdmEntity, ResultT extends VdmEntity> + NavigableEntitySingle + + withFunction( @Nonnull final BoundFunction.CollectionToSingleEntity.Composable function ); + + /** + * Use a composable function as a path element in an OData request. Similar to + * {@link #applyFunction(BoundFunction.CollectionToSingle) applyFunction} but allows further path segments after the + * function. Functions must be marked as {@code composable} by the service. + * + * @param function + * The composable functions. + * @param + * The entity type + * @param + * The return type of the function. + * @return A request builder that allows for adding further path segments. + */ + @Nonnull + + < + EntityT extends VdmEntity, ResultT extends VdmEntity> + NavigableEntityCollection + withFunction( @Nonnull final BoundFunction.CollectionToCollectionEntity.Composable function ); + + /** + * Apply a bound action returning a collection of objects to the current entity collection. + * + * @param action + * The action to apply. Actions are available on generated entity classes. + * @param + * The entity type + * @param + * The return type of the action. + * @return A new request builder for the supplied action applied to the current elements. + */ + @Nonnull + , ResultT> CollectionValueActionRequestBuilder applyAction( + @Nonnull final BoundAction.CollectionToCollection action ); + + /** + * Apply a bound action returning a single or no object to an entity collection of the service. + * + * @param action + * The action to apply. Actions are available on generated entity classes. + * @param + * The type this function is bound to + * @param + * The return type of the action. + * @return A new request builder for the supplied action applied to the current elements. + */ + @Nonnull + , ResultT> SingleValueActionRequestBuilder applyAction( + @Nonnull final BoundAction.CollectionToSingle action ); + + /** + * Apply a bound function returning a single object to the current element. + * + * @param function + * The function to apply. Functions are available on generated entity classes. + * @param + * The type this function is bound to + * @param + * The return type of the function. + * @return A new request builder for the supplied function applied to the current element. + */ + @Nonnull + , ResultT> SingleValueFunctionRequestBuilder applyFunction( + @Nonnull final BoundFunction.CollectionToSingle function ); + + /** + * Apply a bound function returning a collection of objects to the current element. + * + * @param function + * The function to apply. Functions are available on generated entity classes. + * @param + * The type this function is bound to + * @param + * The type of items in the result of the function. + * @return A new request builder for the supplied function applied to the current element. + */ + @Nonnull + , ResultT> CollectionValueFunctionRequestBuilder applyFunction( + @Nonnull final BoundFunction.CollectionToCollection function ); +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/Property.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/Property.java new file mode 100644 index 0000000000..617cbee905 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/Property.java @@ -0,0 +1,13 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.FieldReference; + +/** + * Generic entity property. + * + * @param + * The generic entity type for this property. + */ +public interface Property extends FieldReference +{ +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ProtocolQueryFilter.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ProtocolQueryFilter.java new file mode 100644 index 0000000000..9efc3026df --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ProtocolQueryFilter.java @@ -0,0 +1,47 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableBoolean; + +/** + * Interface that allows for constructing filterable OData queries (reading) over a collection of entities. + * + * @param + * The entity type to be queried. + */ +interface ProtocolQueryFilter> +{ + /** + * Filter on properties of {@linkplain EntityT}. + * + * @param filters + * Filter expressions to be added to the request. + * @return This request object with the added filters. + */ + @Nonnull + @SuppressWarnings( { "varargs", "unchecked" } ) + ProtocolQueryFilter filter( @Nonnull final FilterableBoolean... filters ); + + /** + * Request modifier to return the set of entities that contain the specified value. If this method is never called, + * then all the accessible entities will be returned. + * + * @param search + * A string value as the search criteria. + * @return The same request builder with this request modifier applied. + */ + @Nonnull + ProtocolQueryFilter search( @Nonnull final String search ); + + /** + * Request modifier to return the set of entities corresponding to the specified boolean expression. If this method + * is never called, then all the accessible entities will be returned. + * + * @param expression + * SearchExpression as the search criteria + * @return The same request builder with this request modifier applied. + */ + @Nonnull + ProtocolQueryFilter search( @Nonnull final SearchExpression expression ); +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ProtocolQueryRead.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ProtocolQueryRead.java new file mode 100644 index 0000000000..8e6210059b --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ProtocolQueryRead.java @@ -0,0 +1,28 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +/** + * Interface that allows for constructing OData read queries. + * + * @param + * The entity type to be queried. + */ +interface ProtocolQueryRead> +{ + /** + * Query modifier to limit which field values of the entity {@linkplain EntityT} get fetched and populated. + * Navigational properties supplied here will be expanded. If this method is called at least once, then only the + * specified fields will be fetched and populated. + * + * If none of the select methods is called, then all fields will be fetched and populated. Calling this multiple + * times will combine the set(s) of fields of each call. + * + * @param fields + * Properties of {@linkplain EntityT} to be selected. + * @return This request object with the added selections. + */ + @Nonnull + @SuppressWarnings( { "varargs", "unchecked" } ) + ProtocolQueryRead select( @Nonnull final Property... fields ); +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ProtocolQueryReadCollection.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ProtocolQueryReadCollection.java new file mode 100644 index 0000000000..e7ddf4f34c --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ProtocolQueryReadCollection.java @@ -0,0 +1,47 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odatav4.expression.FieldOrdering; + +/** + * Interface that allows for constructing OData queries (reading) over a collection of entities. + * + * @param + * The entity type to be queried. + */ +interface ProtocolQueryReadCollection> extends ProtocolQueryFilter +{ + /** + * Limit the number of results of this request. + * + * @param top + * The maximum amount of elements this request shall return. + * @return This request with the top parameter set. + */ + @Nonnull + ProtocolQueryReadCollection top( int top ); + + /** + * Determine the how many first N entities of the result set should be skipped. If this method is never called, then + * the full list will be returned from the first entity. If this method is called multiple times, then only the + * value of the last call will be used. + * + * @param skip + * The amount of elements this request will skip. + * @return This request with the skip parameter set. + */ + @Nonnull + ProtocolQueryReadCollection skip( int skip ); + + /** + * Sort the set of returned entities by non-complex fields. + * + * @param ordering + * Fields to sort by. + * @return This request with the orderBy parameter set. + */ + @Nonnull + @SuppressWarnings( { "varargs", "unchecked" } ) + ProtocolQueryReadCollection orderBy( @Nonnull final FieldOrdering... ordering ); +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ReadRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ReadRequestBuilder.java new file mode 100644 index 0000000000..8276f9b9b5 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ReadRequestBuilder.java @@ -0,0 +1,31 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +/** + * Interface to mark OData request types as reading operation. + * + * @param + * The type of the request's result, if any. + * + * @see GetAllRequestBuilder + * @see GetByKeyRequestBuilder + * @see FunctionRequestBuilder + */ +public interface ReadRequestBuilder extends RequestBuilder +{ + /** + * Activates CSRF token retrieval for this OData request. + * + * @return The same request builder that will now fetch a CSRF token. + * @deprecated CSRF token handling is now performed automatically by the underlying HTTP client. This method is a + * no-op retained only for source compatibility, as read requests never require a CSRF token. It is + * scheduled for removal. + */ + @Deprecated + @Nonnull + default ReadRequestBuilder withCsrfToken() + { + return this; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/RequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/RequestBuilder.java new file mode 100644 index 0000000000..01ba7dae09 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/RequestBuilder.java @@ -0,0 +1,54 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; + +/** + * Representation of a generic OData request builder as a fluent interface. + * + * @param + * The type of the result entity, if any. + */ +public interface RequestBuilder extends RequestBuilderExecutable +{ + /** + * Assemble a generic, untyped request object that represents the request build up via this builder. + * + * @return A request object extending {@link ODataRequestGeneric}. + */ + @Nonnull + ODataRequestGeneric toRequest(); + + /** + * Gives the option to specify custom HTTP headers. Multiple headers with the same key can be specified. The + * returned object allows to specify the requests the headers should be used in. + * + * @param key + * Name of the (first) desired HTTP header parameter. + * @param value + * Value of the (first) desired HTTP header parameter. + * + * @return A request builder to specify further headers and their intended usage. + */ + @Nonnull + RequestBuilder withHeader( @Nonnull final String key, @Nullable final String value ); + + /** + * Gives the option to specify a map of custom HTTP headers. The returned object allows to specify the requests the + * headers should be used in. + * + * @param map + * A map of HTTP header key/value pairs. + * @return A request builder to specify further headers and their intended usage. + */ + @Nonnull + default RequestBuilder withHeaders( @Nonnull final Map map ) + { + map.forEach(this::withHeader); + return this; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/RequestBuilderExecutable.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/RequestBuilderExecutable.java new file mode 100644 index 0000000000..490465ffbd --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/RequestBuilderExecutable.java @@ -0,0 +1,52 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException; +import com.sap.cloud.sdk.cloudplatform.connectivity.exception.HttpClientInstantiationException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException; + +import io.vavr.control.Try; + +/** + * Representation of a generic executable OData request builder as a fluent interface. + * + * @param + * The type of the result entity, if any. + */ +public interface RequestBuilderExecutable +{ + /** + * Execute the OData request. + * + * @param destination + * The destination to be used as request target. + * @return The generic OData response result. + * + * @throws DestinationAccessException + * If there is an issue accessing the {@link Destination}. + * @throws HttpClientInstantiationException + * If there is an issue creating the {@link HttpClient}. + * @throws ODataException + * If the OData request execution failed. Please find the documentation for {@link ODataException} + * possible sub-types and error scenarios they can occur in. + */ + @Nonnull + ResultT execute( @Nonnull final Destination destination ); + + /** + * Safely execute the OData request. + * + * @param destination + * The destination to be used as request target. + * @return The generic OData response result wrapped in a {@code Try} block. + */ + @Nonnull + default Try tryExecute( @Nonnull final Destination destination ) + { + return Try.of(() -> execute(destination)); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ResourcePathUtil.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ResourcePathUtil.java new file mode 100644 index 0000000000..8f44a4cbea --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ResourcePathUtil.java @@ -0,0 +1,24 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; + +class ResourcePathUtil +{ + @Nonnull + static , ResultT> ODataResourcePath ofBoundOperation( + @Nonnull final BoundOperation operation ) + { + final String entityCollection = + new VdmEntityUtil<>(operation.getBindingType()).newInstance().getEntityCollection(); + + return new ODataResourcePath().addSegment(entityCollection); + } + + @Nonnull + static > ODataResourcePath ofEntity( @Nonnull final EntityT entity ) + { + return new ODataResourcePath().addSegment(entity.getEntityCollection(), entity.getKey()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/SearchExpression.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/SearchExpression.java new file mode 100644 index 0000000000..1a3b8b3163 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/SearchExpression.java @@ -0,0 +1,118 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Representation of a OData query parameter for Search Modifier + */ +@RequiredArgsConstructor +public class SearchExpression +{ + @Nonnull + @Getter( AccessLevel.PROTECTED ) + private final String term; + + /** + * Create a search expression for a single string. + * + * @param term + * The search string + * @return Search Expression with a single search string. + */ + @Nonnull + public static SearchExpression of( @Nonnull final String term ) + { + return new SearchExpression(getDoubleQuotedString(term)); + } + + /** + * Combine current string with another search string in conjunction. + * + * @param term + * The other search string. + * @return Search Expression with a conjunction string. + */ + @Nonnull + public SearchExpression and( @Nonnull final String term ) + { + return new SearchExpression("(" + this.term + " AND " + getDoubleQuotedString(term) + ")"); + } + + /** + * Combine current search expression with another search expression in conjunction. + * + * @param searchExpression + * The other search expression. + * @return Search Expression with a conjunction. + */ + @Nonnull + public SearchExpression and( @Nonnull final SearchExpression searchExpression ) + { + return new SearchExpression("(" + this.term + " AND " + searchExpression.getTerm() + ")"); + } + + /** + * Combine current string with another search string in disjunction. + * + * @param term + * The other search string. + * @return Search Expression with a disjunction string. + */ + @Nonnull + public SearchExpression or( @Nonnull final String term ) + { + return new SearchExpression("(" + this.term + " OR " + getDoubleQuotedString(term) + ")"); + } + + /** + * Combine current search expression with another search expression in disjunction. + * + * @param searchExpression + * The other search expression. + * @return Search Expression with a disjunction. + */ + @Nonnull + public SearchExpression or( @Nonnull final SearchExpression searchExpression ) + { + return new SearchExpression("(" + this.term + " OR " + searchExpression.getTerm() + ")"); + } + + /** + * Negate the current search expression. + * + * @return Modified search expression with negation. + */ + @Nonnull + public SearchExpression not() + { + return new SearchExpression("NOT " + this.term); + } + + /** + * Escape and encapsulate String literal. + * + * @param text + * The String to be quoted. + * @return The prepared String. + */ + @Nonnull + static String getDoubleQuotedString( @Nonnull final String text ) + { + if( text.contains("&") ) { + throw new IllegalArgumentException("Search literal contains a forbidden character '&'."); + } + + // escape backslash \ -> \\ + String encodedText = text.replaceAll("\\\\", "$0$0"); + + // escape double quotes " -> \" + encodedText = encodedText.replaceAll("\"", "\\\\$0"); + + // wrap escaped string into double quotes + return "\"" + encodedText + "\""; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ServiceWithNavigableEntities.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ServiceWithNavigableEntities.java new file mode 100644 index 0000000000..3469a6424c --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ServiceWithNavigableEntities.java @@ -0,0 +1,215 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; + +import io.vavr.control.Option; + +/** + * OData Service interface to provide type-safe and recursive access to nested entities and their navigation properties. + */ +public interface ServiceWithNavigableEntities extends OperationsOnEntityCollections +{ + /** + * Getter for the OData service root path. + * + * @return the service path. + */ + @Nonnull + String getServicePath(); + + /** + * Create a generic type-safe request using navigation properties on entity sets of the OData service. + * + * @param entity + * A template entity instance that contains all necessary key attributes. + *

+ * Constraints: Not nullable + *

+ * @param + * The generic entity type for which navigation properties can be accessed. + * @return A request builder to navigate the service request using the provided key fields of an entity. With + * another navigation property this object can be used to instantiate new request builders. + */ + @Override + @Nonnull + default < + EntityT extends VdmEntity> NavigableEntitySingle forEntity( @Nonnull final EntityT entity ) + { + if( !VdmEntitySet.class.isAssignableFrom(entity.getType()) ) { + throw new IllegalStateException( + "Entity type " + + entity.getType().getSimpleName() + + " must be a sub class of " + + VdmEntitySet.class.getName()); + } + + final ODataResourcePath resourcePath = ResourcePathUtil.ofEntity(entity); + + return new ServiceWithNavigableEntitiesImpl.EntitySingle<>( + getServicePath(), + resourcePath, + Option.of(entity), + entity.getType()); + } + + /** + * Use a composable function as a path element in an OData request. Similar to + * {@link #applyFunction(BoundFunction.CollectionToSingle) applyFunction} but allows further path segments after the + * function. Functions must be marked as {@code composable} by the service. + * + * @param function + * The composable functions. + * @param + * The entity type + * @param + * The return type of the function. + * @return A request builder that allows for adding further path segments. + */ + @Override + @Nonnull + default < + EntityT extends VdmEntity, ResultT extends VdmEntity> + NavigableEntitySingle + withFunction( @Nonnull final BoundFunction.CollectionToSingleEntity.Composable function ) + { + final ODataResourcePath resourcePath = + ResourcePathUtil + .ofBoundOperation(function) + .addSegment(function.getQualifiedName(), function.getParameters()); + + return new ServiceWithNavigableEntitiesImpl.EntitySingle<>( + getServicePath(), + resourcePath, + Option.none(), + function.getReturnType()); + } + + /** + * Use a composable function as a path element in an OData request. Similar to + * {@link #applyFunction(BoundFunction.CollectionToSingle) applyFunction} but allows further path segments after the + * function. Functions must be marked as {@code composable} by the service. + * + * @param function + * The composable functions. + * @param + * The entity type + * @param + * The return type of the function. + * @return A request builder that allows for adding further path segments. + */ + @Override + @Nonnull + default < + EntityT extends VdmEntity, ResultT extends VdmEntity> + NavigableEntityCollection + withFunction( @Nonnull final BoundFunction.CollectionToCollectionEntity.Composable function ) + { + final ODataResourcePath resourcePath = + ResourcePathUtil + .ofBoundOperation(function) + .addSegment(function.getQualifiedName(), function.getParameters()); + + return new ServiceWithNavigableEntitiesImpl.EntityCollection<>( + getServicePath(), + resourcePath, + function.getReturnType()); + } + + /** + * Apply a bound action returning a collection of objects to the current entity collection. + * + * @param action + * The action to apply. Actions are available on generated entity classes. + * @param + * The entity type + * @param + * The return type of the action. + * @return A new request builder for the supplied action applied to the current elements. + */ + @Override + @Nonnull + default , ResultT> CollectionValueActionRequestBuilder applyAction( + @Nonnull final BoundAction.CollectionToCollection action ) + { + final ODataResourcePath resourcePath = ResourcePathUtil.ofBoundOperation(action); + + return new ServiceWithNavigableEntitiesImpl.EntityCollection<>( + getServicePath(), + resourcePath, + action.getBindingType()).applyAction(action); + } + + /** + * Apply a bound action returning a single or no object to an entity collection of the service. + * + * @param action + * The action to apply. Actions are available on generated entity classes. + * @param + * The type this function is bound to + * @param + * The return type of the action. + * @return A new request builder for the supplied action applied to the current elements. + */ + @Override + @Nonnull + default , ResultT> SingleValueActionRequestBuilder applyAction( + @Nonnull final BoundAction.CollectionToSingle action ) + { + final ODataResourcePath resourcePath = ResourcePathUtil.ofBoundOperation(action); + + return new ServiceWithNavigableEntitiesImpl.EntityCollection<>( + getServicePath(), + resourcePath, + action.getBindingType()).applyAction(action); + } + + /** + * Apply a bound function returning a single object to the current element. + * + * @param function + * The function to apply. Functions are available on generated entity classes. + * @param + * The type this function is bound to + * @param + * The return type of the function. + * @return A new request builder for the supplied function applied to the current element. + */ + @Override + @Nonnull + default , ResultT> SingleValueFunctionRequestBuilder applyFunction( + @Nonnull final BoundFunction.CollectionToSingle function ) + { + final ODataResourcePath resourcePath = ResourcePathUtil.ofBoundOperation(function); + + return new ServiceWithNavigableEntitiesImpl.EntityCollection<>( + getServicePath(), + resourcePath, + function.getBindingType()).applyFunction(function); + } + + /** + * Apply a bound function returning a collection of objects to the current element. + * + * @param function + * The function to apply. Functions are available on generated entity classes. + * @param + * The type this function is bound to + * @param + * The type of items in the result of the function. + * @return A new request builder for the supplied function applied to the current element. + */ + @Override + @Nonnull + default , ResultT> CollectionValueFunctionRequestBuilder applyFunction( + @Nonnull final BoundFunction.CollectionToCollection function ) + { + final ODataResourcePath resourcePath = ResourcePathUtil.ofBoundOperation(function); + + return new ServiceWithNavigableEntitiesImpl.EntityCollection<>( + getServicePath(), + resourcePath, + function.getBindingType()).applyFunction(function); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ServiceWithNavigableEntitiesImpl.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ServiceWithNavigableEntitiesImpl.java new file mode 100644 index 0000000000..76f4eca02c --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/ServiceWithNavigableEntitiesImpl.java @@ -0,0 +1,308 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; + +import org.apache.hc.core5.http.HttpHeaders; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; + +import io.vavr.control.Option; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +class ServiceWithNavigableEntitiesImpl +{ + @RequiredArgsConstructor + static class EntitySingle> implements NavigableEntitySingle + { + /** + * The service path of the entity. + */ + @Nonnull + protected final String servicePath; + + /** + * The path to the entity. + */ + @Nonnull + protected final ODataResourcePath entityPath; + + /** + * The entity to be navigated. + */ + @Nonnull + protected final Option maybeEntity; + + @Nonnull + private final Class entityType; + + @Nonnull + @Override + public GetByKeyRequestBuilder get() + { + return new GetByKeyRequestBuilder<>(servicePath, entityPath, entityType); + } + + @Nonnull + @Override + public UpdateRequestBuilder update( @Nonnull final EntityT item ) + { + return new UpdateRequestBuilder<>(servicePath, entityPath, item); + } + + @Nonnull + @Override + public DeleteRequestBuilder delete() + { + return new DeleteRequestBuilder<>( + servicePath, + entityPath, + maybeEntity.getOrElse(() -> new VdmEntityUtil<>(entityType).newInstance())); + } + + @Nonnull + @Override + public > NavigableEntityCollection navigateTo( + @Nonnull final NavigationProperty.Collection property ) + { + return new EntityCollection<>( + servicePath, + entityPath.copy().addSegment(property.getFieldName()), + property.getItemType()); + } + + @Nonnull + @Override + public > NavigableEntitySingle navigateTo( + @Nonnull final NavigationProperty.Single property ) + { + return new EntitySingle<>( + servicePath, + entityPath.copy().addSegment(property.getFieldName()), + Option.none(), + property.getItemType()); + } + + @Nonnull + @Override + public SingleValueFunctionRequestBuilder applyFunction( + @Nonnull final BoundFunction.SingleToSingle function ) + { + return newSingleValueFunctionRequestBuilder(servicePath, entityPath, function); + } + + @Nonnull + @Override + public CollectionValueFunctionRequestBuilder applyFunction( + @Nonnull final BoundFunction.SingleToCollection function ) + { + return newCollectionValueFunctionRequestBuilder(servicePath, entityPath, function); + } + + @Override + @Nonnull + public > NavigableEntitySingle withFunction( + @Nonnull final BoundFunction.SingleToSingleEntity.Composable function ) + { + return new EntitySingle<>( + servicePath, + entityPath.copy().addSegment(function.getQualifiedName(), function.getParameters()), + Option.none(), + function.getReturnType()); + } + + @Override + @Nonnull + public > NavigableEntityCollection withFunction( + @Nonnull final BoundFunction.SingleToCollectionEntity.Composable function ) + { + return new EntityCollection<>( + servicePath, + entityPath.copy().addSegment(function.getQualifiedName(), function.getParameters()), + function.getReturnType()); + } + + @Override + @Nonnull + public SingleValueActionRequestBuilder applyAction( + @Nonnull final BoundAction.SingleToSingle action ) + { + final ODataResourcePath actionPath = entityPath.copy().addSegment(action.getQualifiedName()); + final SingleValueActionRequestBuilder requestBuilder = + new SingleValueActionRequestBuilder<>( + servicePath, + actionPath, + action.getParameters(), + action.getReturnType()); + maybeEntity + .filter(e -> e.getVersionIdentifier().isDefined()) + .map(VdmEntity::getVersionIdentifier) + .filter(Option::isDefined) + .map(Option::get) + .forEach(eTag -> requestBuilder.withHeader(HttpHeaders.IF_MATCH, eTag)); + return requestBuilder; + } + + @Override + @Nonnull + public CollectionValueActionRequestBuilder applyAction( + @Nonnull final BoundAction.SingleToCollection action ) + { + final ODataResourcePath actionPath = entityPath.copy().addSegment(action.getQualifiedName()); + final CollectionValueActionRequestBuilder requestBuilder = + new CollectionValueActionRequestBuilder<>( + servicePath, + actionPath, + action.getParameters(), + action.getReturnType()); + maybeEntity + .filter(e -> e.getVersionIdentifier().isDefined()) + .map(VdmEntity::getVersionIdentifier) + .filter(Option::isDefined) + .map(Option::get) + .forEach(eTag -> requestBuilder.withHeader(HttpHeaders.IF_MATCH, eTag)); + return requestBuilder; + } + } + + @RequiredArgsConstructor + static class EntityCollection> + implements + NavigableEntityCollection + { + @Getter + private final String servicePath; + private final ODataResourcePath entityPath; + private final Class navigationType; + + @Nonnull + @Override + public > NavigableEntitySingle forEntity( + @Nonnull final EntityT entity ) + { + return new EntitySingle<>( + servicePath, + entityPath.addParameterToLastSegment(entity.getKey()), + Option.of(entity), + entity.getType()); + } + + @Override + @Nonnull + public GetAllRequestBuilder getAll() + { + return new GetAllRequestBuilder<>(servicePath, entityPath, navigationType); + } + + @Override + @Nonnull + public CreateRequestBuilder create( @Nonnull final NavigationT item ) + { + return new CreateRequestBuilder<>(servicePath, entityPath, item); + } + + @Override + @Nonnull + public CountRequestBuilder count() + { + return new CountRequestBuilder<>(servicePath, entityPath, navigationType); + } + + @Nonnull + @Override + public < + EntityT extends VdmEntity, ResultT extends VdmEntity> + NavigableEntitySingle + withFunction( @Nonnull final BoundFunction.CollectionToSingleEntity.Composable function ) + { + return new ServiceWithNavigableEntitiesImpl.EntitySingle<>( + servicePath, + entityPath.copy().addSegment(function.getQualifiedName(), function.getParameters()), + Option.none(), + function.getReturnType()); + } + + @Nonnull + @Override + public < + EntityT extends VdmEntity, ResultT extends VdmEntity> + NavigableEntityCollection + withFunction( + @Nonnull final BoundFunction.CollectionToCollectionEntity.Composable function ) + { + return new ServiceWithNavigableEntitiesImpl.EntityCollection<>( + servicePath, + entityPath.copy().addSegment(function.getQualifiedName(), function.getParameters()), + function.getReturnType()); + } + + @Nonnull + @Override + public , ResultT> CollectionValueActionRequestBuilder applyAction( + @Nonnull final BoundAction.CollectionToCollection action ) + { + return new CollectionValueActionRequestBuilder<>( + servicePath, + entityPath.copy().addSegment(action.getQualifiedName()), + action.getParameters(), + action.getReturnType()); + } + + @Nonnull + @Override + public , ResultT> SingleValueActionRequestBuilder applyAction( + @Nonnull final BoundAction.CollectionToSingle action ) + { + return new SingleValueActionRequestBuilder<>( + servicePath, + entityPath.copy().addSegment(action.getQualifiedName()), + action.getParameters(), + action.getReturnType()); + } + + @Nonnull + @Override + public , ResultT> SingleValueFunctionRequestBuilder applyFunction( + @Nonnull final BoundFunction.CollectionToSingle function ) + { + return newSingleValueFunctionRequestBuilder(servicePath, entityPath, function); + } + + @Nonnull + @Override + public < + EntityT extends VdmEntity, ResultT> CollectionValueFunctionRequestBuilder applyFunction( + @Nonnull final BoundFunction.CollectionToCollection function ) + { + return newCollectionValueFunctionRequestBuilder(servicePath, entityPath, function); + } + } + + private static < + EntityT extends VdmEntity, ResultT> + SingleValueFunctionRequestBuilder + newSingleValueFunctionRequestBuilder( + final String servicePath, + final ODataResourcePath entityPath, + final BoundFunction function ) + { + return new SingleValueFunctionRequestBuilder<>( + servicePath, + entityPath.copy().addSegment(function.getQualifiedName(), function.getParameters()), + function.getReturnType()); + } + + private static < + EntityT extends VdmEntity, ResultT> + CollectionValueFunctionRequestBuilder + newCollectionValueFunctionRequestBuilder( + final String servicePath, + final ODataResourcePath entityPath, + final BoundFunction function ) + { + return new CollectionValueFunctionRequestBuilder<>( + servicePath, + entityPath.copy().addSegment(function.getQualifiedName(), function.getParameters()), + function.getReturnType()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/SimpleProperty.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/SimpleProperty.java new file mode 100644 index 0000000000..15bcc01ee6 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/SimpleProperty.java @@ -0,0 +1,241 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Collections; +import java.util.List; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odatav4.expression.FieldOrdering; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableBoolean; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableCollection; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableDate; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableDateTime; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableDuration; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableEnum; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableGuid; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableNumericDecimal; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableNumericInteger; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableString; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableTime; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Simple property. + * + * @param + */ +@SuppressWarnings( "PMD.UnnecessaryFullyQualifiedName" ) // due to java.lang.String +public interface SimpleProperty extends Property +{ + /** + * A pseudo property referencing all fields. + * + * @param + * The entity type. + */ + class All implements SimpleProperty + { + @Getter + private final java.lang.String fieldName = ""; + @Getter + private final List selections = Collections.singletonList("*"); + } + + /** + * A String property. + * + * @param + * The entity type. + */ + @Getter + @RequiredArgsConstructor + class String implements SimpleProperty, FilterableString + { + private final Class entityType; + private final java.lang.String fieldName; + } + + /** + * A Boolean property. + * + * @param + * The entity type. + */ + @Getter + @RequiredArgsConstructor + class Boolean implements SimpleProperty, FilterableBoolean + { + private final Class entityType; + private final java.lang.String fieldName; + } + + /** + * A Decimal property. + * + * @param + * The entity type. + */ + @Getter + @RequiredArgsConstructor + class NumericDecimal implements SimpleProperty, FilterableNumericDecimal + { + private final Class entityType; + private final java.lang.String fieldName; + } + + /** + * An Integer property. + * + * @param + * The entity type. + */ + @Getter + @RequiredArgsConstructor + class NumericInteger implements SimpleProperty, FilterableNumericInteger + { + private final Class entityType; + private final java.lang.String fieldName; + } + + /** + * A Guid property. + * + * @param + * The entity type. + */ + @Getter + @RequiredArgsConstructor + class Guid implements SimpleProperty, FilterableGuid + { + private final Class entityType; + private final java.lang.String fieldName; + } + + /** + * A Binary property. + * + * @param + * The entity type. + */ + @Getter + @RequiredArgsConstructor + class Binary implements SimpleProperty + { + private final Class entityType; + private final java.lang.String fieldName; + } + + /** + * A Duration property. + * + * @param + * The entity type. + */ + @Getter + @RequiredArgsConstructor + class Duration implements SimpleProperty, FilterableDuration + { + private final Class entityType; + private final java.lang.String fieldName; + } + + /** + * A DateTime property. + * + * @param + * The entity type. + */ + @Getter + @RequiredArgsConstructor + class DateTime implements SimpleProperty, FilterableDateTime + { + private final Class entityType; + private final java.lang.String fieldName; + } + + /** + * A Date property. + * + * @param + * The entity type. + */ + @Getter + @RequiredArgsConstructor + class Date implements SimpleProperty, FilterableDate + { + private final Class entityType; + private final java.lang.String fieldName; + } + + /** + * A Time property. + * + * @param + * The entity type. + */ + @Getter + @RequiredArgsConstructor + class Time implements SimpleProperty, FilterableTime + { + private final Class entityType; + private final java.lang.String fieldName; + } + + /** + * A composite property holding a collection of values. + * + * @param + * The entity type. + * @param + * The collection item type. + */ + @Getter + @RequiredArgsConstructor + class Collection implements SimpleProperty, FilterableCollection + { + private final Class entityType; + private final java.lang.String fieldName; + private final Class itemType; + } + + /** + * A property with predefined possible values. + * + * @param + * The entity type. + * @param + * The Enum type. + */ + @Getter + @RequiredArgsConstructor + class Enum implements SimpleProperty, FilterableEnum + { + private final Class entityType; + private final java.lang.String fieldName; + private final java.lang.String enumType; + } + + /** + * A property for order ascending. + * + * @return The FieldOrdering which has the field and ordering. + */ + @Nonnull + default FieldOrdering asc() + { + return FieldOrdering.asc(this); + } + + /** + * A property for order descending. + * + * @return The FieldOrdering which has the field and ordering. + */ + @Nonnull + default FieldOrdering desc() + { + return FieldOrdering.desc(this); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/SingleValueActionRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/SingleValueActionRequestBuilder.java new file mode 100644 index 0000000000..b4eaafac32 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/SingleValueActionRequestBuilder.java @@ -0,0 +1,106 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import lombok.AccessLevel; +import lombok.Getter; + +/** + * Representation of an OData action request as a fluent interface for further configuring the request and + * {@link #execute(Destination) executing} it. This one handles actions where the return type is a single primitive or + * entity value or complex type. It also handles actions with no return type. + * + * @param + * The type of the result entity or complex type or primitive type, if any. For actions that return a single + * value or nothing, the result is wrapped inside a {@link ActionResponseSingle } instance. + */ +public class SingleValueActionRequestBuilder + extends + ActionRequestBuilder, ActionResponseSingle> +{ + @Getter( AccessLevel.PROTECTED ) + private final Class resultClass; + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param actionName + * The name of the unbound action + * @param resultClass + * The expected return type of the action. + */ + public SingleValueActionRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final String actionName, + @Nonnull final Class resultClass ) + { + super(servicePath, actionName); + this.resultClass = resultClass; + } + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param actionName + * The name of the unbound action + * @param parameters + * The parameters passed to the action. + * @param resultClass + * The expected return type of the action. + */ + public SingleValueActionRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final String actionName, + @Nonnull final Map parameters, + @Nonnull final Class resultClass ) + { + super(servicePath, actionName, parameters); + this.resultClass = resultClass; + } + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param actionPath + * The path to the unbound action + * @param parameters + * The parameters passed to the action. + * @param resultClass + * The expected return type of the action. + */ + public SingleValueActionRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath actionPath, + @Nonnull final Map parameters, + @Nonnull final Class resultClass ) + { + super(servicePath, actionPath, parameters); + this.resultClass = resultClass; + } + + @Nonnull + @Override + public ActionResponseSingle execute( @Nonnull final Destination destination ) + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + + final ODataRequestResultGeneric response = toRequest().execute(httpClient); + + return ActionResponseSingle.of(response, resultClass); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/SingleValueFunctionRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/SingleValueFunctionRequestBuilder.java new file mode 100644 index 0000000000..853195d434 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/SingleValueFunctionRequestBuilder.java @@ -0,0 +1,110 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Collections; +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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 com.sap.cloud.sdk.datamodel.odata.client.request.ODataFunctionParameters; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; + +import lombok.AccessLevel; +import lombok.Getter; + +/** + * Representation of an OData function request as a fluent interface for further configuring the request and + * {@link #execute(Destination) executing} it. This one handles functions where the return type is a single primitive or + * entity value. + * + * @param + * The type of the result entity, if any. + */ +public class SingleValueFunctionRequestBuilder + extends + FunctionRequestBuilder, ResultT> +{ + @Nonnull + @Getter( AccessLevel.PROTECTED ) + private final Class resultClass; + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param functionName + * The name of the unbound function + * @param resultClass + * The expected return type of the function. + */ + public SingleValueFunctionRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final String functionName, + @Nonnull final Class resultClass ) + { + this(servicePath, functionName, Collections.emptyMap(), resultClass); + } + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param functionName + * The name of the unbound function + * @param parameters + * The parameters passed to the function. + * @param resultClass + * The expected collection return type of the function. + */ + public SingleValueFunctionRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final String functionName, + @Nonnull final Map parameters, + @Nonnull final Class resultClass ) + { + this( + servicePath, + ODataResourcePath.of(functionName, ODataFunctionParameters.of(parameters, ODataProtocol.V4)), + resultClass); + } + + /** + * Instantiates this request builder using the given service path to send the requests. + * + * @param servicePath + * The service path to direct the requests to. + * @param functionPath + * The {@link ODataResourcePath} identifying the function to invoke. + * @param resultClass + * The expected collection return type of the function. + */ + SingleValueFunctionRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath functionPath, + @Nonnull final Class resultClass ) + { + super(servicePath, functionPath); + this.resultClass = resultClass; + } + + @Nonnull + @Override + public ResultT execute( @Nonnull final Destination destination ) + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + final ODataRequestResultGeneric response = toRequest().execute(httpClient); + final ResultT result = response.as(getResultClass()); + + if( result instanceof VdmEntity ) { + response.getVersionIdentifierFromHeader().peek(((VdmEntity) result)::setVersionIdentifier); + } + return result; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/StructuredProperty.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/StructuredProperty.java new file mode 100644 index 0000000000..74b55e45de --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/StructuredProperty.java @@ -0,0 +1,17 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +/** + * Interface representing a structural property of {@link EntityT} that points towards an object of {@link TargetT}. + * Structured properties are either complex types or navigational properties. + * + * @see com.sap.cloud.sdk.datamodel.odatav4.core.ComplexProperty + * @see com.sap.cloud.sdk.datamodel.odatav4.core.NavigationProperty + * + * @param + * Entity this property is part of. + * @param + * {@link VdmObject} this property represents. + */ +interface StructuredProperty, TargetT extends VdmObject> extends Property +{ +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/UpdateRequestBuilder.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/UpdateRequestBuilder.java new file mode 100644 index 0000000000..8d165ef244 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/UpdateRequestBuilder.java @@ -0,0 +1,296 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; + +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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.ODataSerializationException; +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.request.ETagSubmissionStrategy; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestUpdate; +import com.sap.cloud.sdk.datamodel.odata.client.request.UpdateStrategy; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Representation of an OData request as a fluent interface for further configuring the request and + * {@link #execute(Destination) executing} it. + * + * @param + * The type of the result entity. + */ +@Slf4j +public class UpdateRequestBuilder> + extends + AbstractEntityBasedRequestBuilder, EntityT, ModificationResponse> + implements + ModificationRequestBuilder> +{ + private final Collection includedFields = new HashSet<>(); + + private final Collection excludedFields = new HashSet<>(); + + private UpdateStrategy updateStrategy = UpdateStrategy.MODIFY_WITH_PATCH; + + private ETagSubmissionStrategy eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_ETAG_FROM_ENTITY; + + /** + * The entity object to be updated by calling the {@link #execute(Destination)} method. + */ + @Getter( AccessLevel.PROTECTED ) + @Nonnull + private final EntityT entity; + + /** + * Instantiate an {@code UpdateRequestBuilder}. + * + * @param servicePath + * The service path. + * @param entity + * The entity to update. + * @param entityCollection + * The entity collection + */ + public UpdateRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final EntityT entity, + @Nonnull final String entityCollection ) + { + this(servicePath, ODataResourcePath.of(entityCollection, entity.getKey()), entity); + } + + /** + * Instantiate an {@code UpdateRequestBuilder}. + * + * @param servicePath + * The service path. + * @param entityPath + * The {@link ODataResourcePath} that identifies the entity to update. + * @param entity + * The entity to update. + */ + UpdateRequestBuilder( + @Nonnull final String servicePath, + @Nonnull final ODataResourcePath entityPath, + @Nonnull final EntityT entity ) + { + super(servicePath, entityPath); + this.entity = entity; + } + + @SuppressWarnings( "unchecked" ) + @Nonnull + @Override + protected Class getEntityClass() + { + return (Class) getEntity().getClass(); + } + + /** + * Execute the OData update request for the provided entity. + * + * {@inheritDoc} + * + * @return The wrapped service response, exposing response headers, status code and entity references. If the HTTP + * response is not within healthy bounds, then one of the declared runtime exceptions will be thrown with + * further details. + */ + @Nonnull + @Override + public ModificationResponse execute( @Nonnull final Destination destination ) + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + + final ODataRequestResultGeneric response = toRequest().execute(httpClient); + + return ModificationResponse.of(response, getEntity()); + } + + /** + * Creates an instance of the {@link ODataRequestUpdate} based on the Entity class. + *

+ * The following settings are used to build the Request Builder: + *

    + *
  • the endpoint URL
  • + *
  • the entity collection name
  • + *
  • the key fields of the entity
  • + *
  • the entity JSON payload
  • + *
  • the update strategy (full update or delta)
  • + *
+ * + * @return An initialized {@code ODataRequestUpdate}. + * @throws ODataSerializationException + * If entity cannot be serialized for HTTP request. + */ + @Override + @Nonnull + public ODataRequestUpdate toRequest() + { + final String versionIdentifier = + eTagSubmissionStrategy.getHeaderFromVersionIdentifier(entity.getVersionIdentifier()); + final String serializedEntity = getSerializedEntity(); + + final ODataRequestUpdate request = + new ODataRequestUpdate( + getServicePath(), + getResourcePath(), + serializedEntity, + updateStrategy, + versionIdentifier, + ODataProtocol.V4); + + return super.toRequest(request); + } + + /** + * Serialize entity to String depending on update strategy. + * + * @return The serialized String representing the entity. + * @throws ODataSerializationException + * If entity cannot be serialized for HTTP request. + */ + @Nonnull + private String getSerializedEntity() + { + final EntityT entity = getEntity(); + try { + switch( updateStrategy ) { + case REPLACE_WITH_PUT: + return new UpdateRequestHelperPut().toJson(entity, excludedFields); + case MODIFY_WITH_PATCH: + return new UpdateRequestHelperPatch().toJson(entity, includedFields); + default: + throw new IllegalStateException("Unexpected update Strategy: " + updateStrategy); + } + } + catch( final Exception e ) { + final String msg = + String + .format( + "Failed to serialize OData Update HTTP request entity for type %s with strategy %s", + getEntityClass().getSimpleName(), + updateStrategy); + + final ODataRequestUpdate request = + new ODataRequestUpdate( + getServicePath(), + getResourcePath(), + "", + updateStrategy, + eTagSubmissionStrategy.getHeaderFromVersionIdentifier(entity.getVersionIdentifier()), + ODataProtocol.V4); + + throw new ODataSerializationException(request, entity, msg, e); + } + } + + /** + * Allows to explicitly specify entity fields that shall be sent in an update request regardless if the values of + * these fields have been changed. This is helpful in case the API requires to send certain fields in any case in an + * update request. + * + * @param fields + * The fields to be included in the update execution. + * @return The same request builder which will include the specified fields in an update request. + */ + @Nonnull + public final UpdateRequestBuilder includingFields( @Nonnull final FieldReference... fields ) + { + includedFields.addAll(Arrays.asList(fields)); + return this; + } + + /** + * Allows to explicitly specify entity fields that should not be sent in an update request. This is helpful in case + * some services require no read only fields to be sent for update requests. These fields are only excluded in a PUT + * request, they are not considered in a PATCH request. + * + * @param fields + * The fields to be excluded in the update execution. + * @return The same request builder which will exclude the specified fields in an update request. + */ + @Nonnull + public final UpdateRequestBuilder excludingFields( @Nonnull final FieldReference... fields ) + { + Collections.addAll(excludedFields, fields); + return this; + } + + /** + * Allows to control that the 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. + * + * @return The same request builder which will replace the entity in the remote system + */ + @Nonnull + public final UpdateRequestBuilder replacingEntity() + { + updateStrategy = UpdateStrategy.REPLACE_WITH_PUT; + return this; + } + + /** + * Allows to control that the request to update the entity is sent with the HTTP method PATCH and its payload + * contains the changed fields only. + * + * @return The same request builder which will modify the entity in the remote system. + */ + @Nonnull + public final UpdateRequestBuilder modifyingEntity() + { + updateStrategy = UpdateStrategy.MODIFY_WITH_PATCH; + return this; + } + + /** + * The update request will ignore any version identifier present on the entity and not send an `If-Match` header. + *

+ * Warning: This might lead to a response from the remote system that the `If-Match` header is missing. + *

+ * It depends on the implementation of the remote system whether the `If-Match` header is expected. + * + * @return The same request builder that will not send the `If-Match` header in the update request + */ + @Nonnull + public UpdateRequestBuilder disableVersionIdentifier() + { + eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_NO_ETAG; + return this; + } + + /** + * The update request will ignore any version identifier present on the entity and update the entity, regardless of + * any changes on the remote entity. + *

+ * Warning: Be careful with this option, as this might overwrite any changes made to the remote + * representation of this object. + * + * @return The same request builder that will ignore the version identifier of the entity to update + */ + @Nonnull + public UpdateRequestBuilder matchAnyVersionIdentifier() + { + eTagSubmissionStrategy = ETagSubmissionStrategy.SUBMIT_ANY_MATCH_ETAG; + return this; + } + + @Override + @Nonnull + public UpdateRequestBuilder withoutCsrfToken() + { + withHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER, "true"); + return this; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/UpdateRequestHelperPatch.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/UpdateRequestHelperPatch.java new file mode 100644 index 0000000000..5668db98b6 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/UpdateRequestHelperPatch.java @@ -0,0 +1,211 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.common.collect.Maps; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FieldReference; +import com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +class UpdateRequestHelperPatch +{ + String toJson( @Nonnull final VdmEntity entity, @Nonnull final Collection includedFields ) + { + return new SerializeHelper(entity, includedFields).toJson(); + } + + @RequiredArgsConstructor + private static class SerializeHelper + { + @Nonnull + private final VdmEntity entity; + + @Nonnull + private final Collection includedFields; + + @Nonnull + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + @Nonnull + private final Map, JsonObject> cache = new IdentityHashMap<>(); + + @Nonnull + private static final TypeAdapterFactory GSON_VDM_ADAPTER_FACTORY = new GsonVdmAdapterFactory(); + + @Nonnull + String toJson() + { + // find field names to be patched + final Set fieldNamesToPatch = new HashSet<>(entity.getChangedFields().keySet()); + includedFields.stream().map(FieldReference::getFieldName).forEach(fieldNamesToPatch::add); + log.debug("The following fields are marked for updates: {}.", fieldNamesToPatch); + + // create the key-value map for entity properties to be patched + final Map patchValues = Maps.filterKeys(entity.toMap(), fieldNamesToPatch::contains); + + // starting helper collection to identify of recursion + final Set> parentObjects = Collections.newSetFromMap(new IdentityHashMap<>()); + parentObjects.add(entity); + + // serialize key-value properties map to GSON object. + final JsonObject o = Objects.requireNonNull((JsonObject) serializeComplexValue(patchValues, parentObjects)); + + if( o.size() < 1 ) { + log + .warn( + """ + Update strategy is to modify with PATCH, but no fields have changed. \ + Make sure to modify the entity via its setters or by naming them explicitly via 'includingFields(fields ...)'. \ + This request may be bound to fail in the target system.\ + """); + } + + // add default OData annotation properties, e.g. @odata.type + entity.getAnnotationProperties().forEach(o::addProperty); + + // translate JsonObject to Json String + return gson.toJson(o); + } + + @Nullable + private + JsonElement + serializeComplexValue( @Nullable final Object input, @Nonnull final Set> parentObjects ) + { + if( input == null ) { + return JsonNull.INSTANCE; + } + + if( input instanceof Map ) { + return ((Map) input) + .entrySet() + .stream() + .collect( + JsonObject::new, + ( m, v ) -> m.add(v.getKey().toString(), serializeComplexValue(v.getValue(), parentObjects)), + ( m1, m2 ) -> m2.keySet().forEach(k2 -> m1.add(k2, m2.get(k2)))); + } + if( input instanceof List ) { + return ((Collection) input) + .stream() + .map(v -> serializeComplexValue(v, parentObjects)) + .collect(JsonArray::new, JsonArray::add, JsonArray::addAll); + } + if( input instanceof VdmObject ) { + final JsonObject cachedValue = cache.get(input); + if( cachedValue != null ) { + return cachedValue; + } + + final JsonObject valueMap = new JsonObject(); + if( !parentObjects.contains(input) ) { + cache.put((VdmObject) input, valueMap); + + // for complex type consider all fields, for entity types filter for changed fields + final Map changedFields; + if( input instanceof VdmComplex ) { + changedFields = ((VdmObject) input).toMapOfFields(); + changedFields.putAll(((VdmObject) input).getCustomFields()); + } else { + changedFields = ((VdmObject) input).getChangedFields(); + } + + // derive helper collection to identify of recursion + final Set> nestedParentObjects = Collections.newSetFromMap(new IdentityHashMap<>()); + nestedParentObjects.addAll(parentObjects); + nestedParentObjects.add((VdmObject) input); + + // serialize changed fields of nested VDM object + final JsonElement serializedMapRaw = serializeComplexValue(changedFields, nestedParentObjects); + final JsonObject serializedMap = Objects.requireNonNull((JsonObject) serializedMapRaw); + serializedMap.keySet().forEach(key -> valueMap.add(key, serializedMap.get(key))); + } + + // if nested VDM object is a VDM entity, handle the key properties + if( input instanceof VdmEntity ) { + final VdmEntity entity = (VdmEntity) input; + + // if no key fields are marked as changed, then add the @id property + if( entity.getKey().getFieldNames().stream().noneMatch(valueMap::has) ) { + valueMap.addProperty("@id", entity.getEntityCollection() + entity.getKey().toEncodedString()); + } + } + + return valueMap; + } + + return serializeSimpleValue(input); + } + + @Nullable + private JsonElement serializeSimpleValue( @Nonnull final T object ) + { + @SuppressWarnings( "unchecked" ) + final TypeToken typeToken = TypeToken.get((Class) object.getClass()); + + final TypeAdapter typeAdapter = GSON_VDM_ADAPTER_FACTORY.create(gson, typeToken); + if( typeAdapter != null ) { + final JsonElement jsonObject = typeAdapter.toJsonTree(object); + log.trace("Simple entity property value {} is serialized to {}.", object, jsonObject); + return jsonObject; + } + + log.debug("GSON type adapter could not be found for entity property value of type {}.", typeToken); + + final JsonPrimitive jsonPrimitive = convertToJsonPrimitive(object, typeToken); + + if( jsonPrimitive == null ) { + log + .warn( + "Could not convert value of type {} to a {} representation.", + typeToken, + JsonElement.class.getSimpleName()); + } + + return jsonPrimitive; + } + + private JsonPrimitive convertToJsonPrimitive( final T value, final TypeToken typeToken ) + { + if( Number.class.isAssignableFrom(typeToken.getRawType()) ) { + final Number numberPrimitive = (Number) value; + return new JsonPrimitive(numberPrimitive); + } + + if( Boolean.class.isAssignableFrom(typeToken.getRawType()) ) { + final Boolean booleanPrimitive = (Boolean) value; + return new JsonPrimitive(booleanPrimitive); + } + + if( Character.class.isAssignableFrom(typeToken.getRawType()) ) { + final Character characterPrimitive = (Character) value; + return new JsonPrimitive(characterPrimitive); + } + + return null; + } + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/UpdateRequestHelperPut.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/UpdateRequestHelperPut.java new file mode 100644 index 0000000000..2ceef1b1d5 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/UpdateRequestHelperPut.java @@ -0,0 +1,27 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Collection; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FieldReference; + +class UpdateRequestHelperPut +{ + String toJson( @Nonnull final VdmEntity entity, @Nullable final Collection excludedFields ) + { + final Gson gson = new GsonBuilder().create(); + final JsonObject jsonObject = gson.toJsonTree(entity).getAsJsonObject(); + + // find field names to be removed from PUT request + if( excludedFields != null ) { + excludedFields.stream().map(FieldReference::getFieldName).forEach(jsonObject::remove); + } + + return gson.toJson(jsonObject); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmComplex.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmComplex.java new file mode 100644 index 0000000000..ffd7382fe3 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmComplex.java @@ -0,0 +1,12 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +/** + * Complex type in the virtual data model. + * + * @param + * Object type of the complex type. + */ +public abstract class VdmComplex extends VdmObject +{ + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmEntity.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmEntity.java new file mode 100644 index 0000000000..5b35788bb2 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmEntity.java @@ -0,0 +1,88 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import io.vavr.control.Option; +import lombok.EqualsAndHashCode; + +/** + * Base class for an OData entity. + * + * @param + * The entity type. + */ +@EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) +public abstract class VdmEntity extends VdmObject +{ + @Nullable + private String versionIdentifier = null; + + /** + * Select all properties of an entity. + * + * @param + * The entity type. + * @return A selector for all entity fields. + */ + @Nonnull + protected static SimpleProperty all() + { + return new SimpleProperty.All<>(); + } + + /** + * + * Getter for the version identifier of this entity. + *

+ * This identifier can be used to compare this entity with a remote one. As not the whole entity has to be sent this + * reduces the request overhead. + *

+ * Actual use cases can be checking whether this entity is still current with regards to the remote entity, and + * ensuring that a update/delete operation is done on the expected version of the remote entity. + * + * @return The version identifier. + */ + @Nonnull + public Option getVersionIdentifier() + { + return Option.of(versionIdentifier); + } + + /** + * Setter for the version identifier of this entity. + *

+ * This identifier can be used to compare this entity with a remote one. As not the whole entity has to be sent this + * reduces the request overhead. + *

+ * Actual use cases can be checking whether this entity is still current with regards to the remote entity, and + * ensuring that a update/delete operation is done on the expected version of the remote entity. + * + * @param versionIdentifier + * The version identifier of this entity. + */ + public void setVersionIdentifier( @Nullable final String versionIdentifier ) + { + this.versionIdentifier = versionIdentifier; + } + + /** + * Used by request builders and navigation property methods to construct OData requests. + * + * @return EDMX name of the entity collection identifier. + */ + @Nonnull + protected abstract String getEntityCollection(); + + /** + * Used by request builders and navigation property methods to construct OData requests. + * + * @return Default context path to the OData service. In other words, everything in between the + * {@code protocol://hostname:port} and the OData resource name (entity set, {@code $metadata}, etc.) + */ + @Nullable + protected String getDefaultServicePath() + { + return null; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmEntitySet.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmEntitySet.java new file mode 100644 index 0000000000..c3115b150a --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmEntitySet.java @@ -0,0 +1,8 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +/** + * Empty interface to indicate operative support for querying the OData service for the related EntitySet. + */ +public interface VdmEntitySet +{ +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmEntityUtil.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmEntityUtil.java new file mode 100644 index 0000000000..4b1f14deb2 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmEntityUtil.java @@ -0,0 +1,38 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.lang.reflect.InvocationTargetException; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.cloudplatform.exception.ShouldNotHappenException; + +import lombok.RequiredArgsConstructor; + +/** + * Utility class to manage OData entity deserialization. + * + * @param + * Entity type to create new instances from. + */ +@RequiredArgsConstructor +final class VdmEntityUtil> +{ + private final Class entityClass; + + @Nonnull + EntityT newInstance() + { + try { + return entityClass.getDeclaredConstructor().newInstance(); + } + catch( final + NoSuchMethodException + | InvocationTargetException + | InstantiationException + | IllegalAccessException e ) { + throw new ShouldNotHappenException( + "Failed to instantiate object of type " + entityClass.getSimpleName(), + e); + } + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmEnum.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmEnum.java new file mode 100644 index 0000000000..6775a4bf29 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmEnum.java @@ -0,0 +1,60 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Arrays; +import java.util.Objects; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Interface to manage the different properties an Edm.Enum literal contains. + */ +public interface VdmEnum +{ + /** + * Get the name property of the Edm.Enum literal + * + * @return The name property. + */ + @Nonnull + default String getName() + { + return toString(); + } + + /** + * Get the value property of the Edm.Enum literal + * + * @return The value property. + */ + @Nullable + default Long getValue() + { + return null; + } + + /** + * Helper function to resolve enum constant from given type reference and String identifier. + * + * @param enumType + * The enum type reference. + * @param identifier + * The enum constant identifier. + * @param + * The generic enum type. + * @return A + */ + @Nullable + static T getConstant( @Nonnull final Class enumType, @Nullable final String identifier ) + { + final T[] enumConstants = enumType.getEnumConstants(); + if( enumConstants == null ) { + return null; + } + return Arrays + .stream(enumConstants) + .filter(member -> Objects.equals(member.getName(), identifier)) + .findFirst() + .orElse(null); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmObject.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmObject.java new file mode 100644 index 0000000000..7dedb8f7f8 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmObject.java @@ -0,0 +1,403 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Set; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import com.google.common.collect.Streams; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; + +import lombok.EqualsAndHashCode; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +/** + * Superclass of all entities which contains common elements such as a generic representation of custom fields. + * + * @param + * The type of the implementing object. + */ +@Slf4j +@ToString( doNotUseGetters = true ) +@EqualsAndHashCode( doNotUseGetters = true ) +@JsonAutoDetect( + fieldVisibility = JsonAutoDetect.Visibility.ANY, + getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE, + setterVisibility = JsonAutoDetect.Visibility.NONE, + creatorVisibility = JsonAutoDetect.Visibility.NONE ) +public abstract class VdmObject +{ + /** + * The OData V4 JSON key to access an entity EDM type definition. + */ + public static final String[] ODATA_TYPE_ANNOTATIONS = { "@odata.type", "@type" }; + + /** + * The OData V4 JSON key to access an entity version identifier. + */ + public static final String[] ODATA_VERSION_ANNOTATIONS = { "@odata.etag", "@etag" }; + + @JsonIgnore + @Nonnull + private final transient Map customFields = new LinkedHashMap<>(); + + /** + * A mapping of the OData field name to the original value. + *

+ * This should be updated via {@link #rememberChangedField(String, Object)} on every set call of a property. + */ + @JsonIgnore + @Nonnull + protected final transient Map changedOriginalFields = new HashMap<>(); + + /** + * Returns the names of the custom fields of this object. + * + * @return The names of the custom fields of this object. + */ + @Nonnull + public Set getCustomFieldNames() + { + return customFields.keySet(); + } + + /** + * Returns the names and values of a custom field. + * + * @return All the names & values of custom fields as a map. + */ + @JsonAnyGetter + @Nonnull + public Map getCustomFields() + { + return customFields; + } + + /** + * Sets the value of a single custom field. + * + * @param customFieldName + * Name of the custom field. + * @param value + * Value of the custom field. + */ + @JsonAnySetter + public void setCustomField( @Nonnull final String customFieldName, @Nullable final Object value ) + { + rememberChangedField(customFieldName, customFields.get(customFieldName)); + customFields.put(customFieldName, value); + } + + /** + * Sets the value of a single custom field. If the EntityField passed as parameter holds a TypeConverter, the value + * will be converted before it's stored. + * + * @param customField + * Name of the custom field, represented as an EntityField object. + * @param value + * Value of the custom field. + * @param + * The type of the custom field to set. + */ + public < + FieldT> void setCustomField( @Nonnull final SimpleProperty customField, @Nullable final FieldT value ) + { + setCustomField(customField.getFieldName(), value); + } + + /** + * Checks whether this object contains a custom field with the given name. + * + * @param customFieldName + * Name of the custom field to check for + * + * @return {@code true} if this entity has a custom field with the given name, {@code false} otherwise. + */ + public boolean hasCustomField( @Nonnull final String customFieldName ) + { + return customFields.containsKey(customFieldName); + } + + /** + * Checks whether this object contains a value for the given custom field. + * + * @param customField + * Custom field to check for, represented as an {@code EntityField} object. + * + * @return {@code true} if this object has a custom field with the name of the given field, {@code false} otherwise. + */ + public boolean hasCustomField( @Nonnull final SimpleProperty customField ) + { + return hasCustomField(customField.getFieldName()); + } + + /** + * This method allows for retrieval of custom fields that are added to the underlying OData services. + * + * @param customFieldName + * Name of the field returned by the underlying OData service. + * @param + * The type of the returned field. + * + * @return The value of the custom field. Actual type will depend on the type configured in the underlying OData + * service. + * + * @throws NoSuchElementException + * if no field with the given name could be found. + */ + @SuppressWarnings( "unchecked" ) + @Nullable + public FieldT getCustomField( @Nonnull final String customFieldName ) + throws NoSuchElementException + { + if( !hasCustomField(customFieldName) ) { + final String msg = "Object has no field with name '" + customFieldName + "'."; + log.debug(msg); + throw new NoSuchElementException(msg); + } + return (FieldT) customFields.get(customFieldName); + } + + /** + * This method allows for retrieval of custom fields that are added to the underlying OData services. If the + * EntityField passed as parameter holds a TypeConverter, the value will be converted before it's returned. + * + * @param customField + * Field returned by the underlying OData service. + * @param + * The type of the returned field. + * + * @return The value of the custom field. Actual type will depend on the type configured in the underlying OData + * service. + * + * @throws NoSuchElementException + * if no field with the given name could be found. + */ + @Nullable + public FieldT getCustomField( @Nonnull final SimpleProperty customField ) + throws NoSuchElementException + { + return getCustomField(customField.getFieldName()); + } + + /** + * Returns the annotation properties. + * + * @return List of OData annotation properties. + */ + @Nonnull + public Map getAnnotationProperties() + { + final Map properties = new HashMap<>(); + properties.put(ODATA_TYPE_ANNOTATIONS[0], "#" + getOdataType()); + + return properties; + } + + /** + * Returns the EDMX type of this entity. + * + * @return The EDMX type of this entity. + */ + @Nonnull + public abstract String getOdataType(); + + /** + * Returns the class of this object. + * + * @return The class of this object. + */ + @Nonnull + public abstract Class getType(); + + /** + * Returns the compound key of this object. + * + * @return The compound key of this object. + */ + @Nonnull + protected ODataEntityKey getKey() + { + return new ODataEntityKey(ODataProtocol.V4); + } + + /** + * Read entity data from generic map. + * + * @param values + * The key-value map. + */ + protected void fromMap( final Map values ) + { + for( final Map.Entry entry : values.entrySet() ) { + setCustomField(entry.getKey(), entry.getValue()); + } + + resetChangedFields(); + } + + /** + * Get the custom fields as value map. + * + * @return The custom fields. + */ + @Nonnull + protected Map toMapOfCustomFields() + { + return new HashMap<>(getCustomFields()); + } + + /** + * Get the custom field names. + * + * @return The custom field names. + */ + @Nonnull + protected Set getSetOfCustomFields() + { + return new HashSet<>(getCustomFields().keySet()); + } + + /** + * Get all fields as map. + * + * @return The fields as map. + */ + @Nonnull + protected Map toMapOfFields() + { + return new HashMap<>(); + } + + /** + * Get the field names. + * + * @return The field names. + */ + @Nonnull + protected Set getSetOfFields() + { + return Sets.newHashSet(toMapOfFields().keySet()); + } + + /** + * Get navigation properties as map. + * + * @return The navigation properties. + */ + @Nonnull + protected Map toMapOfNavigationProperties() + { + return new HashMap<>(); + } + + /** + * Get navigation property names as set. + * + * @return The navigation property names. + */ + @Nonnull + protected Set getSetOfNavigationProperties() + { + return Sets.newHashSet(toMapOfNavigationProperties().keySet()); + } + + /** + * Translate the entity data to key-value map. + * + * @return The map representation of the entity. + */ + @Nonnull + protected Map toMap() + { + final Map values = new HashMap<>(); + + values.putAll(toMapOfFields()); + values.putAll(toMapOfNavigationProperties()); + values.putAll(toMapOfCustomFields()); + + return values; + } + + /** + * Returns map of all fields which have been changed on this entity along with their updated values. + * + * @return Map containing all changed fields with their current value. + */ + @Nonnull + public Map getChangedFields() + { + final Map currentFields = new HashMap<>(); + currentFields.putAll(toMapOfFields()); + currentFields.putAll(getCustomFields()); + + return Maps.filterEntries(currentFields, f -> f != null && isFieldChanged(f.getKey(), f.getValue())); + } + + private boolean isFieldChanged( @Nonnull final String fieldName, @Nullable final Object currentValue ) + { + final Object overriddenValue = changedOriginalFields.get(fieldName); + if( currentValue == null && overriddenValue == null ) { + return false; + } + + if( changedOriginalFields.containsKey(fieldName) && !Objects.equals(currentValue, overriddenValue) ) { + return true; + } + + // property was either not updated directly, or the values are still "equal" (according to Objects.equals) + if( currentValue instanceof VdmObject ) { + return !((VdmObject) currentValue).getChangedFields().isEmpty(); + } + + if( currentValue instanceof Iterable ) { + return Streams + .stream((Iterable) currentValue) + .filter(VdmObject.class::isInstance) + .anyMatch(obj -> !((VdmObject) obj).getChangedFields().isEmpty()); + } + + return false; + } + + /** + * Remembers the original value of a changed field. + * + * @param fieldName + * The name of the field that is changed. + * @param valueBeforeChange + * The original value before the change. + */ + protected void rememberChangedField( @Nonnull final String fieldName, @Nullable final Object valueBeforeChange ) + { + if( !changedOriginalFields.containsKey(fieldName) ) { + changedOriginalFields.put(fieldName, valueBeforeChange); + } + } + + /** + * Resets the map of all fields which have been changed on this entity. + *

+ * After calling this method, no field is considered changed, until you change the value of fields on this entity + * afterwards. + */ + public void resetChangedFields() + { + changedOriginalFields.clear(); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/EntityReference.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/EntityReference.java new file mode 100644 index 0000000000..0eb3d6531e --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/EntityReference.java @@ -0,0 +1,20 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import javax.annotation.Nonnull; + +/** + * Generic interface to provide the original entity class reference. + * + * @param + * Type of the entity which references the value. + */ +public interface EntityReference +{ + /** + * Get the type of the entity which references the value. + * + * @return The entity type, + */ + @Nonnull + Class getEntityType(); +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FieldOrdering.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FieldOrdering.java new file mode 100644 index 0000000000..18af8e7602 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FieldOrdering.java @@ -0,0 +1,80 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import java.util.Arrays; +import java.util.LinkedList; +import java.util.Queue; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +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.odatav4.core.SimpleProperty; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Represents an asc/desc ordering of an entity via a property. + * + * @param + * The entity type that is ordered. + */ +@Getter +@RequiredArgsConstructor +public class FieldOrdering +{ + private final SimpleProperty property; + private final Order order; + + /** + * Creates a {@code FieldOrdering} representing an ascending order over the given property + * + * @param + * The entity the ordering is applied to. + * @param property + * The property of {@code EntityT} to be sorted in descending order. + * @return The FieldOrdering. + */ + @Nonnull + public static FieldOrdering asc( @Nonnull final SimpleProperty property ) + { + return new FieldOrdering<>(property, Order.ASC); + } + + /** + * Creates a {@code FieldOrdering} representing an descending order over the given property. + * + * @param + * The entity the ordering is applied to. + * @param property + * The property of {@code EntityT} to be sorted in descending order. + * @return The FieldOrdering. + */ + @Nonnull + public static FieldOrdering desc( @Nonnull final SimpleProperty property ) + { + return new FieldOrdering<>(property, Order.DESC); + } + + /** + * Builds an {@link OrderExpression} out of individual field orderings. The expression represents a sorting where + * the orderings are applied in the order they are given. + * + * @param orderings + * The {@code FieldOrdering}s that should be applied to achieve a sorting. + * @return The resulting {@link OrderExpression} or {@code null}, if no orderings where given. + */ + @Nullable + public static OrderExpression toOrderExpression( @Nonnull final FieldOrdering... orderings ) + { + final Queue> fieldOrderings = new LinkedList<>(Arrays.asList(orderings)); + if( fieldOrderings.isEmpty() ) { + return null; + } + final FieldOrdering first = fieldOrderings.poll(); + final OrderExpression expr = OrderExpression.of(first.getProperty().getFieldName(), first.getOrder()); + fieldOrderings.forEach(o -> expr.and(o.getProperty().getFieldName(), o.getOrder())); + return expr; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableBoolean.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableBoolean.java new file mode 100644 index 0000000000..f237367500 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableBoolean.java @@ -0,0 +1,132 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.Expressions; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpression; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionLogical; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueBoolean; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * Fluent helper class to provide filter functions to OData expressions referenced by Boolean. + * + * @param + * Type of the entity which references the value. + */ +public interface FilterableBoolean extends FilterableValue +{ + /** + * Wrapper expression class, which delegates to another operation. + * + * @param + * Type of the entity which references the value. + */ + @RequiredArgsConstructor + @Getter + class Expression implements FilterableBoolean, FilterExpression + { + @Delegate + private final FilterExpression delegate; + + @Nonnull + private final Class entityType; + } + + /*** + * Creates a new Expression from a custom filter expression. Allows for untyped expressions to be supplied to the + * VDM. + * + * @param delegateExpression + * The expression to which the Expression delegates. + * @param entityType + * The expected entity type. + * @param + * Type of the entity which references the value. + * @return A new Expression. + * + * @since 4.8.0 + */ + @Nonnull + static FilterableBoolean fromCustomFilter( + @Nonnull final ValueBoolean delegateExpression, + @Nonnull final Class entityType ) + { + return new Expression<>((FilterExpression) delegateExpression, entityType); + } + + /** + * Combine current filter expression with another expression in conjunction. + * + * @param operand + * The other expression. + * @return This FluentHelper reference. + */ + @Nonnull + default FilterableBoolean and( @Nonnull final FilterableBoolean operand ) + { + final ValueBoolean value = operand::getExpression; + final ValueBoolean.Expression expression = FilterExpressionLogical.and(this::getExpression, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * Combine the filter expression with a boolean value in conjunction. + * + * @param operand + * A boolean value. + * @return This FluentHelper reference. + */ + @Nonnull + default FilterableBoolean and( @Nonnull final Boolean operand ) + { + final ValueBoolean value = (ValueBoolean) Expressions.createOperand(operand); + final ValueBoolean.Expression expression = FilterExpressionLogical.and(this::getExpression, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * Combine the filter expression with another expression in disjunction. + * + * @param operand + * The other expression. + * @return This FluentHelper reference. + */ + @Nonnull + default FilterableBoolean or( @Nonnull final FilterableBoolean operand ) + { + final ValueBoolean value = operand::getExpression; + final ValueBoolean.Expression expression = FilterExpressionLogical.or(this::getExpression, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * Combine the filter expression with a boolean value in disjunction. + * + * @param operand + * The other expression. + * @return This FluentHelper reference. + */ + @Nonnull + default FilterableBoolean or( @Nonnull final Boolean operand ) + { + final ValueBoolean value = (ValueBoolean) Expressions.createOperand(operand); + final ValueBoolean.Expression expression = FilterExpressionLogical.or(this::getExpression, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * Negate the filter expression. + * + * @return This FluentHelper reference. + */ + @Nonnull + default FilterableBoolean not() + { + final ValueBoolean.Expression expression = FilterExpressionLogical.not(this::getExpression); + return new Expression<>(expression, getEntityType()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableCollection.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableCollection.java new file mode 100644 index 0000000000..37b57b23e7 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableCollection.java @@ -0,0 +1,343 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import java.util.function.Predicate; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.Expressions; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FieldReference; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpression; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionCollection; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueBoolean; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueCollection; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueNumeric; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * Fluent helper class to provide filter functions to OData expressions referenced by Collection. + * + * @param + * Type of the entity which references the value. + * @param + * Type of the item type the collection holds. + */ +public interface FilterableCollection extends Expressions.OperandMultiple, EntityReference +{ + /** + * Get the item type the collection holds. + * + * @return The item type. + */ + @Nonnull + Class getItemType(); + + /** + * Wrapper expression class, which delegates to another operation. + * + * @param + * Type of the entity which references the value. + * @param + * Type of the item type the collection holds. + */ + @RequiredArgsConstructor + @Getter + class Expression implements FilterableCollection + { + @Delegate + @Nonnull + private final FilterExpression delegate; + + @Nonnull + private final Class entityType; + + @Nonnull + private final Class itemType; + } + + /** + * Filter by expression "hasSubset". + * + * @param operand + * Only operand of collection type. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean hasSubset( @Nonnull final FilterableCollection operand ) + { + final FilterExpression expression = FilterExpressionCollection.hasSubset(this, operand); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * Filter by expression "hasSubset". + * + * @param operand + * Only operand of Java iterable. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean hasSubset( @Nonnull final Iterable operand ) + { + final ValueCollection value = ValueCollection.literal(operand); + final FilterExpression expression = FilterExpressionCollection.hasSubset(this, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * Filter by expression "hasSubSequence". + * + * @param operand + * Only operand of collection type. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean hasSubSequence( @Nonnull final FilterableCollection operand ) + { + final FilterExpression expression = FilterExpressionCollection.hasSubSequence(this, operand); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * Filter by expression "hasSubSequence". + * + * @param operand + * Only operand of Java iterable. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean hasSubSequence( @Nonnull final Iterable operand ) + { + final ValueCollection value = ValueCollection.literal(operand); + final FilterExpression expression = FilterExpressionCollection.hasSubSequence(this, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * Filter by expression "contains". + * + * @param operand + * Only operand of collection type. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean contains( @Nonnull final FilterableCollection operand ) + { + final FilterExpression expression = FilterExpressionCollection.contains(this, operand); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * Filter by expression "contains". + * + * @param operand + * Only operand of Java iterable. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean contains( @Nonnull final Iterable operand ) + { + final Expressions.OperandMultiple value = ValueCollection.literal(operand); + final FilterExpression expression = FilterExpressionCollection.contains(this, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * Filter by expression "startsWith". + * + * @param operand + * Only operand of collection type. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean startsWith( @Nonnull final FilterableCollection operand ) + { + final FilterExpression expression = FilterExpressionCollection.startsWith(this, operand); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * Filter by expression "startsWith". + * + * @param operand + * Only operand of Java iterable. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean startsWith( @Nonnull final Iterable operand ) + { + final ValueCollection value = ValueCollection.literal(operand); + final FilterExpression expression = FilterExpressionCollection.startsWith(this, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * Filter by expression "endsWith". + * + * @param operand + * Only operand of collection type. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean endsWith( @Nonnull final FilterableCollection operand ) + { + final FilterExpression expression = FilterExpressionCollection.endsWith(this, operand); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * Filter by expression "endsWith". + * + * @param operand + * Only operand of Java iterable. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean endsWith( @Nonnull final Iterable operand ) + { + final ValueCollection value = ValueCollection.literal(operand); + final FilterExpression expression = FilterExpressionCollection.endsWith(this, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * Filter by expression "indexOf". + * + * @param operand + * Only operand of collection type. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger indexOf( @Nonnull final FilterableCollection operand ) + { + final FilterExpression expression = FilterExpressionCollection.indexOf(this, operand); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * Filter by expression "indexOf". + * + * @param operand + * Only operand of Java iterable. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger indexOf( @Nonnull final Iterable operand ) + { + final ValueCollection value = ValueCollection.literal(operand); + final FilterExpression expression = FilterExpressionCollection.indexOf(this, value); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * Filter by expression "concat". + * + * @param operand + * Only operand of collection type. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableCollection concat( @Nonnull final FilterableCollection operand ) + { + final FilterExpression expression = FilterExpressionCollection.concat(this, operand); + return new Expression<>(expression, getEntityType(), getItemType()); + } + + /** + * Filter by expression "concat". + * + * @param operand + * Only operand of Java iterable. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableCollection concat( @Nonnull final Iterable operand ) + { + final ValueCollection value = ValueCollection.literal(operand); + final FilterExpression expression = FilterExpressionCollection.concat(this, value); + return new Expression<>(expression, getEntityType(), getItemType()); + } + + /** + * Filter by expression "length". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger length() + { + final FilterExpression expression = FilterExpressionCollection.length(this); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * Filter by expression "substring". + * + * @param operand + * Only operand of Integer type. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableCollection substring( @Nonnull final Integer operand ) + { + final ValueNumeric value = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionCollection.substring(this, value); + return new Expression<>(expression, getEntityType(), getItemType()); + } + + /** + * 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 + FilterableCollection + substring( @Nonnull final Integer operandIndex, @Nonnull final Integer operandLength ) + { + final ValueNumeric value1 = ValueNumeric.literal(operandIndex); + final ValueNumeric value2 = ValueNumeric.literal(operandLength); + final ValueCollection.Expression expression = FilterExpressionCollection.substring(this, value1, value2); + return new Expression<>(expression, getEntityType(), getItemType()); + } + + /** + * Filter by lambda expression "all". + * + * @param operand + * Operand to provide a generic filter to the collection item. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean all( @Nonnull final FilterableBoolean operand ) + { + final Predicate lambdaFieldPredicate = + o -> o instanceof EntityReference && ((EntityReference) o).getEntityType().equals(getItemType()); + final ValueBoolean.Expression expression = + FilterExpressionCollection.all(this, operand::getExpression, lambdaFieldPredicate); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * Filter by lambda expression "any". + * + * @param operand + * Operand to provide a generic filter to the collection item. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean any( @Nonnull final FilterableBoolean operand ) + { + final Predicate lambdaFieldPredicate = + o -> o instanceof EntityReference && ((EntityReference) o).getEntityType().equals(getItemType()); + final FilterExpression expression = + FilterExpressionCollection.any(this, operand::getExpression, lambdaFieldPredicate); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableComplex.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableComplex.java new file mode 100644 index 0000000000..427b8fc1f4 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableComplex.java @@ -0,0 +1,94 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.Expressions; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpression; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionLogical; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueBoolean; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueEnum; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEnum; + +/** + * Fluent helper class to provide filter functions to OData expressions referenced by an OData complex property. + * + * @param + * Type of the entity which references the value. + * @param + * Type of the complex property. + */ +public interface FilterableComplex extends Expressions.OperandSingle, EntityReference +{ + /** + * Filter by expression "has". + * + * @param operand + * A generic String to be applied to the expression + * @return The FluentHelper filter + */ + @Nonnull + default FilterableBoolean has( @Nonnull final String operand ) + { + final ValueEnum value = ValueEnum.literal(operand); + final FilterExpression expression = FilterExpressionLogical.has(this, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * Filter by expression "has". + * + * @param operand + * A generic String to be applied to the expression + * @param + * The enum value type. + * @return The FluentHelper filter + */ + @Nonnull + default < + EnumT extends VdmEnum> FilterableBoolean has( @Nonnull final FilterableEnum operand ) + { + final FilterExpression expression = FilterExpressionLogical.has(this, operand::getExpression); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "in". + * + * @param operand + * The generic operands to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean in( @Nonnull final FilterableCollection operand ) + { + final ValueBoolean.Expression expression = FilterExpressionLogical.in(this, operand); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "eq null". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean equalToNull() + { + final ValueBoolean.Expression expression = FilterExpressionLogical.equalTo(this, Expressions.Operand.NULL); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "ne null". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean notEqualToNull() + { + final ValueBoolean.Expression expression = FilterExpressionLogical.notEqualTo(this, Expressions.Operand.NULL); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableDate.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableDate.java new file mode 100644 index 0000000000..641cb59644 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableDate.java @@ -0,0 +1,192 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import java.time.Duration; +import java.time.LocalDate; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpression; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionArithmetic; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionTemporal; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueDate; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueDuration; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * Fluent helper class to provide filter functions to OData expressions referenced by Date. + * + * @param + * Type of the entity which references the value. + */ +public interface FilterableDate extends FilterableValue +{ + /** + * Wrapper expression class, which delegates to another operation. + * + * @param + * Type of the entity which references the value. + */ + @RequiredArgsConstructor + @Getter + class Expression implements FilterableDate, FilterExpression + { + @Delegate + @Nonnull + private final FilterExpression delegate; + + @Nonnull + private final Class entityType; + } + + /** + * + * Filter by expression "day". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger dateDay() + { + final ValueDate thisDate = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.day(thisDate); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "month". + * + * @return The FluentHelper filter + */ + @Nonnull + default FilterableNumericInteger dateMonth() + { + final ValueDate thisDate = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.month(thisDate); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "year". + * + * @return The FluentHelper filter + */ + @Nonnull + default FilterableNumericInteger dateYear() + { + final ValueDate thisDate = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.year(thisDate); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "add". + * + * @param operand + * The duration to add to the date expression. + * @return The FluentHelper filter + */ + @Nonnull + default FilterableDate add( @Nonnull final FilterableDuration operand ) + { + final ValueDate thisDate = this::getExpression; + final ValueDuration value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.add(thisDate, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "add". + * + * @param operand + * The duration to ad to the date expression. + * + * @return The FluentHelper filter + */ + @Nonnull + default FilterableDate add( @Nonnull final Duration operand ) + { + final ValueDate thisDate = this::getExpression; + final ValueDuration value = ValueDuration.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.add(thisDate, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "sub". + * + * @param operand + * The duration to subtract from the date. + * + * @return The FluentHelper filter + */ + @Nonnull + default FilterableDate subtract( @Nonnull final FilterableDuration operand ) + { + final ValueDate thisDate = this::getExpression; + final ValueDuration value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.subtract(thisDate, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "sub". + * + * @param operand + * The duration to subtract from the date. + * + * @return The FluentHelper filter + */ + @Nonnull + default FilterableDate subtract( @Nonnull final Duration operand ) + { + final ValueDate thisDate = this::getExpression; + final ValueDuration value = ValueDuration.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.subtract(thisDate, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "sub". + * + * @param operand + * The other date to calculate the difference from. + * + * @return The FluentHelper filter + */ + @Nonnull + default FilterableDuration difference( @Nonnull final FilterableDate operand ) + { + final ValueDate thisDate = this::getExpression; + final ValueDuration value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.subtract(thisDate, value); + return new FilterableDuration.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "sub". + * + * @param operand + * The other date to calculate the difference from. + * + * @return The FluentHelper filter + */ + @Nonnull + default FilterableDuration difference( @Nonnull final LocalDate operand ) + { + final ValueDate thisDate = this::getExpression; + final ValueDate value = ValueDate.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.subtract(thisDate, value); + return new FilterableDuration.Expression<>(expression, getEntityType()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableDateTime.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableDateTime.java new file mode 100644 index 0000000000..ed6990ba42 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableDateTime.java @@ -0,0 +1,255 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import java.time.Duration; +import java.time.OffsetDateTime; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpression; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionArithmetic; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionTemporal; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueDateTimeOffset; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueDuration; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * Fluent helper class to provide filter functions to OData expressions referenced by DateTime. + * + * @param + * Type of the entity which references the value. + */ +public interface FilterableDateTime extends FilterableValue +{ + /** + * Wrapper expression class, which delegates to another operation. + * + * @param + * Type of the entity which references the value. + */ + @RequiredArgsConstructor + @Getter + class Expression implements FilterableDateTime, FilterExpression + { + @Delegate + @Nonnull + private final FilterExpression delegate; + + @Nonnull + private final Class entityType; + } + + /** + * + * Filter by expression "date". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableDate date() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.date(thisDateTime); + return new FilterableDate.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "fractionalseconds". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericDecimal timeFractionalSeconds() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.fractionalSeconds(thisDateTime); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "second". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger timeSecond() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.second(thisDateTime); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "minute". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger timeMinute() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.minute(thisDateTime); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "hour". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger timeHour() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.hour(thisDateTime); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "day". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger dateDay() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.day(thisDateTime); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "month". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger dateMonth() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.month(thisDateTime); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "year". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger dateYear() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.year(thisDateTime); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "time". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableTime time() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.time(thisDateTime); + return new FilterableTime.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "offsetminutes". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger offsetMinutes() + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.totalOffsetMinutes(thisDateTime); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "add". + * + * @param operand + * The duration to add to the date time. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableDateTime add( @Nonnull final FilterableDuration operand ) + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final ValueDuration value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.add(thisDateTime, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "add". + * + * @param operand + * The duration to add to the date time. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableDateTime add( @Nonnull final Duration operand ) + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final ValueDuration value = ValueDuration.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.add(thisDateTime, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "sub". + * + * @param operand + * The duration to subtract from the date time. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableDateTime subtract( @Nonnull final FilterableDuration operand ) + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final ValueDuration value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.subtract(thisDateTime, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "add". + * + * @param operand + * The duration to subtract from the date time. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableDateTime subtract( @Nonnull final Duration operand ) + { + final ValueDateTimeOffset thisDateTime = this::getExpression; + final ValueDuration value = ValueDuration.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.subtract(thisDateTime, value); + return new Expression<>(expression, getEntityType()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableDuration.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableDuration.java new file mode 100644 index 0000000000..626d26eef3 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableDuration.java @@ -0,0 +1,214 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import java.time.Duration; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpression; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionArithmetic; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionTemporal; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueDuration; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueNumeric; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * Fluent helper class to provide filter functions to OData expressions referenced by Duration. + * + * @param + * Type of the entity which references the value. + */ +public interface FilterableDuration extends FilterableValue +{ + /** + * Wrapper expression class, which delegates to another operation. + * + * @param + * Type of the entity which references the value. + */ + @RequiredArgsConstructor + @Getter + class Expression implements FilterableDuration, FilterExpression + { + @Delegate + @Nonnull + private final FilterExpression delegate; + + @Nonnull + private final Class entityType; + } + + /** + * + * Filter by expression "offsetseconds". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger offsetSeconds() + { + final ValueDuration thisDuration = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.totalOffsetSeconds(thisDuration); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "add". + * + * @param operand + * The duration to add to the duration. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableDuration add( @Nonnull final FilterableDuration operand ) + { + final ValueDuration thisDuration = this::getExpression; + final ValueDuration value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.add(thisDuration, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "add". + * + * @param operand + * The duration to add to the duration. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableDuration add( @Nonnull final Duration operand ) + { + final ValueDuration thisDuration = this::getExpression; + final ValueDuration value = ValueDuration.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.add(thisDuration, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "sub". + * + * @param operand + * The duration to subtract from this duration. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableDuration subtract( @Nonnull final FilterableDuration operand ) + { + final ValueDuration thisDuration = this::getExpression; + final ValueDuration value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.subtract(thisDuration, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "sub". + * + * @param operand + * The duration to subtract from this duration. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableDuration subtract( @Nonnull final Duration operand ) + { + final ValueDuration thisDuration = this::getExpression; + final ValueDuration value = ValueDuration.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.subtract(thisDuration, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "mul". + * + * @param operand + * The product to be used to multiply the duration. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableDuration multiply( @Nonnull final FilterableNumeric operand ) + { + final ValueDuration thisDuration = this::getExpression; + final ValueNumeric value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.multiply(thisDuration, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "mul". + * + * @param operand + * The product to be used to multiply the duration. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableDuration multiply( @Nonnull final Number operand ) + { + final ValueDuration thisDuration = this::getExpression; + final ValueNumeric value = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.multiply(thisDuration, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "div". + * + * @param operand + * The quotient to be used to divide the duration. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableDuration divide( @Nonnull final FilterableNumeric operand ) + { + final ValueDuration thisDuration = this::getExpression; + final ValueNumeric value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.divide(thisDuration, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "div". + * + * @param operand + * The quotient to be used to divide the duration. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableDuration divide( @Nonnull final Number operand ) + { + final ValueDuration thisDuration = this::getExpression; + final ValueNumeric value = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.divide(thisDuration, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "-". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableDuration negate() + { + final ValueDuration thisDuration = this::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.negate(thisDuration); + return new Expression<>(expression, getEntityType()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableEnum.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableEnum.java new file mode 100644 index 0000000000..f8d1b274ab --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableEnum.java @@ -0,0 +1,135 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.Expressions; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpression; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionLogical; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueBoolean; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueEnum; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEnum; + +/** + * Fluent helper class to provide filter functions to OData expressions referenced by Enum. + * + * @param + * Type of the entity which references the value. + * @param + * Type of the Enum value. + */ +public interface FilterableEnum extends Expressions.Operand, EntityReference +{ + /** + * OData Enum type identifier. + * + * @return The enum type identifier. + */ + @Nonnull + String getEnumType(); + + /** + * + * Filter by expression "eq". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean equalTo( @Nonnull final FilterableEnum operand ) + { + final FilterExpression expression = FilterExpressionLogical.equalTo(this, operand); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "eq". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean equalTo( @Nullable final EnumT operand ) + { + final Expressions.Operand value = + operand == null ? Expressions.Operand.NULL : ValueEnum.literal(getEnumType(), operand.getName()); + final FilterExpression expression = FilterExpressionLogical.equalTo(this, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "ne". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean notEqualTo( @Nonnull final FilterableEnum operand ) + { + final FilterExpression expression = FilterExpressionLogical.notEqualTo(this, operand); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "ne". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean notEqualTo( @Nullable final EnumT operand ) + { + final Expressions.Operand value = + operand == null ? Expressions.Operand.NULL : ValueEnum.literal(getEnumType(), operand.getName()); + final FilterExpression expression = FilterExpressionLogical.notEqualTo(this, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "eq null". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean equalToNull() + { + final ValueBoolean.Expression expression = FilterExpressionLogical.equalTo(this, Expressions.Operand.NULL); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "ne null". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean notEqualToNull() + { + final ValueBoolean.Expression expression = FilterExpressionLogical.notEqualTo(this, Expressions.Operand.NULL); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "in". + * + * @param operand + * The generic operands to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean in( @Nonnull final FilterableCollection operand ) + { + final ValueBoolean.Expression expression = FilterExpressionLogical.in(this, operand); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableGuid.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableGuid.java new file mode 100644 index 0000000000..03f3122da0 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableGuid.java @@ -0,0 +1,14 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import java.util.UUID; + +/** + * Fluent helper class to provide filter functions to OData expressions referenced by Guid. + * + * @param + * Type of the entity which references the value. + */ +public interface FilterableGuid extends FilterableValue +{ + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableNumeric.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableNumeric.java new file mode 100644 index 0000000000..cdfe021737 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableNumeric.java @@ -0,0 +1,340 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionArithmetic; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueNumeric; + +/** + * Fluent helper class to provide filter functions to OData expressions referenced by Number. + * + * @param + * Type of the entity which references the value. + */ +public interface FilterableNumeric extends FilterableValue +{ + /** + * + * Filter by expression "add". + * + * @param operand + * The number to add. + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumeric add( @Nonnull final Long operand ); + + /** + * + * Filter by expression "add". + * + * @param operand + * The number to add. + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumeric add( @Nonnull final Integer operand ); + + /** + * + * Filter by expression "add". + * + * @param operand + * The number to add. + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumericDecimal add( @Nonnull final Number operand ); + + /** + * + * Filter by expression "add". + * + * @param operand + * The number to add. + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumeric add( @Nonnull final FilterableNumericInteger operand ); + + /** + * + * Filter by expression "add". + * + * @param operand + * The number to add. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericDecimal add( @Nonnull final FilterableNumericDecimal operand ) + { + final ValueNumeric thisNumeric = this::getExpression; + final ValueNumeric value = operand::getExpression; + final ValueNumeric.Expression expression = FilterExpressionArithmetic.add(thisNumeric, value); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "mul". + * + * @param operand + * The number to multiply. + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumeric multiply( @Nonnull final Long operand ); + + /** + * + * Filter by expression "mul". + * + * @param operand + * The number to multiply. + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumeric multiply( @Nonnull final Integer operand ); + + /** + * + * Filter by expression "mul". + * + * @param operand + * The number to multiply. + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumericDecimal multiply( @Nonnull final Number operand ); + + /** + * + * Filter by expression "mul". + * + * @param operand + * The number to multiply. + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumeric multiply( @Nonnull final FilterableNumericInteger operand ); + + /** + * + * Filter by expression "mul". + * + * @param operand + * The number to multiply. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericDecimal multiply( @Nonnull final FilterableNumericDecimal operand ) + { + final ValueNumeric thisNumeric = this::getExpression; + final ValueNumeric value = operand::getExpression; + final ValueNumeric.Expression expression = FilterExpressionArithmetic.multiply(thisNumeric, value); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "sub". + * + * @param operand + * The number to subtract. + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumeric subtract( @Nonnull final Long operand ); + + /** + * + * Filter by expression "sub". + * + * @param operand + * The number to subtract. + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumeric subtract( @Nonnull final Integer operand ); + + /** + * + * Filter by expression "sub". + * + * @param operand + * The number to subtract. + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumericDecimal subtract( @Nonnull final Number operand ); + + /** + * + * Filter by expression "sub". + * + * @param operand + * The number to subtract. + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumeric subtract( @Nonnull final FilterableNumericInteger operand ); + + /** + * + * Filter by expression "sub". + * + * @param operand + * The number to subtract. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericDecimal subtract( @Nonnull final FilterableNumericDecimal operand ) + { + final ValueNumeric thisNumeric = this::getExpression; + final ValueNumeric value = operand::getExpression; + final ValueNumeric.Expression expression = FilterExpressionArithmetic.subtract(thisNumeric, value); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "div". + * + * @param operand + * The number to divide. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericDecimal divide( @Nonnull final FilterableNumericInteger operand ) + { + final ValueNumeric thisNumeric = this::getExpression; + final ValueNumeric value = operand::getExpression; + final ValueNumeric.Expression expression = FilterExpressionArithmetic.divide(thisNumeric, value); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + /** + * Filter by expression "div". + * + * @param operand + * The number to divide. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericDecimal divide( @Nonnull final FilterableNumericDecimal operand ) + { + final ValueNumeric thisNumeric = this::getExpression; + final ValueNumeric value = operand::getExpression; + final ValueNumeric.Expression expression = FilterExpressionArithmetic.divide(thisNumeric, value); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "div". + * + * @param operand + * The number to divide. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericDecimal divide( @Nonnull final Number operand ) + { + final ValueNumeric thisNumeric = this::getExpression; + final ValueNumeric value = ValueNumeric.literal(operand); + final ValueNumeric.Expression expression = FilterExpressionArithmetic.divide(thisNumeric, value); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "mod". + * + * @param operand + * The base number to calculate the modulo from. + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumeric modulo( @Nonnull final Long operand ); + + /** + * + * Filter by expression "mod". + * + * @param operand + * The base number to calculate the modulo from. + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumeric modulo( @Nonnull final Integer operand ); + + /** + * + * Filter by expression "mod". + * + * @param operand + * The base number to calculate the modulo from. + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumericDecimal modulo( @Nonnull final Number operand ); + + /** + * + * Filter by expression "mod". + * + * @param operand + * The base number to calculate the modulo from. + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumeric modulo( @Nonnull final FilterableNumericInteger operand ); + + /** + * + * Filter by expression "mod". + * + * @param operand + * The base number to calculate the modulo from. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericDecimal modulo( @Nonnull final FilterableNumericDecimal operand ) + { + final ValueNumeric thisNumeric = this::getExpression; + final ValueNumeric value = operand::getExpression; + final ValueNumeric.Expression expression = FilterExpressionArithmetic.modulo(thisNumeric, value); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "-". + * + * @return The FluentHelper filter. + */ + @Nonnull + FilterableNumeric negate(); +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableNumericDecimal.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableNumericDecimal.java new file mode 100644 index 0000000000..7b1761008e --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableNumericDecimal.java @@ -0,0 +1,249 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpression; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionArithmetic; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueNumeric; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * Fluent helper class to provide filter functions to OData expressions referenced by Integer. + * + * @param + * Type of the entity which references the value. + */ +public interface FilterableNumericDecimal extends FilterableNumeric +{ + /** + * Wrapper expression class, which delegates to another operation. + * + * @param + * Type of the entity which references the value. + */ + @RequiredArgsConstructor + @Getter + class Expression implements FilterableNumericDecimal, FilterExpression + { + @Delegate + @Nonnull + private final FilterExpression delegate; + + @Nonnull + private final Class entityType; + } + + @Override + @Nonnull + default FilterableNumericDecimal add( @Nonnull final FilterableNumericInteger operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.add(thisNumber, value); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Nonnull + @Override + default FilterableNumericDecimal add( @Nonnull final Number operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.add(thisNumber, literal); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericDecimal add( @Nonnull final Long operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.add(thisNumber, literal); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericDecimal add( @Nonnull final Integer operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.add(thisNumber, literal); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericDecimal multiply( @Nonnull final FilterableNumericInteger operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.multiply(thisNumber, value); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Nonnull + @Override + default FilterableNumericDecimal multiply( @Nonnull final Number operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.multiply(thisNumber, literal); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericDecimal multiply( @Nonnull final Long operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.multiply(thisNumber, literal); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericDecimal multiply( @Nonnull final Integer operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.multiply(thisNumber, literal); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericDecimal subtract( @Nonnull final FilterableNumericInteger operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.subtract(thisNumber, value); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Nonnull + @Override + default FilterableNumericDecimal subtract( @Nonnull final Number operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.subtract(thisNumber, literal); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericDecimal subtract( @Nonnull final Long operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.subtract(thisNumber, literal); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericDecimal subtract( @Nonnull final Integer operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.subtract(thisNumber, literal); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericDecimal modulo( @Nonnull final FilterableNumericInteger operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.modulo(thisNumber, value); + return new Expression<>(expression, getEntityType()); + } + + @Nonnull + @Override + default FilterableNumericDecimal modulo( @Nonnull final Number operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.modulo(thisNumber, literal); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericDecimal modulo( @Nonnull final Long operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.modulo(thisNumber, literal); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericDecimal modulo( @Nonnull final Integer operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.modulo(thisNumber, literal); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericDecimal negate() + { + final ValueNumeric thisNumber = this::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.negate(thisNumber); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "ceiling". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger ceil() + { + final ValueNumeric thisNumber = this::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.ceiling(thisNumber); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "floor". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger floor() + { + final ValueNumeric thisNumber = this::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.floor(thisNumber); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "round". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger round() + { + final ValueNumeric thisNumber = this::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.round(thisNumber); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableNumericInteger.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableNumericInteger.java new file mode 100644 index 0000000000..344b171c82 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableNumericInteger.java @@ -0,0 +1,207 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpression; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionArithmetic; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueNumeric; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * Fluent helper class to provide filter functions to OData expressions referenced by Integer. + * + * @param + * Type of the entity which references the value. + */ +public interface FilterableNumericInteger extends FilterableNumeric +{ + /** + * Wrapper expression class, which delegates to another operation. + * + * @param + * Type of the entity which references the value. + */ + @RequiredArgsConstructor + @Getter + class Expression implements FilterableNumericInteger, FilterExpression + { + @Delegate + @Nonnull + private final FilterExpression delegate; + + @Nonnull + private final Class entityType; + } + + @Override + @Nonnull + default FilterableNumericInteger add( @Nonnull final FilterableNumericInteger operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.add(thisNumber, value); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + @Nonnull + @Override + default FilterableNumericDecimal add( @Nonnull final Number operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.add(thisNumber, literal); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericInteger add( @Nonnull final Long operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.add(thisNumber, literal); + return new Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericInteger add( @Nonnull final Integer operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.add(thisNumber, literal); + return new Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericInteger multiply( @Nonnull final FilterableNumericInteger operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.multiply(thisNumber, value); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + @Nonnull + @Override + default FilterableNumericDecimal multiply( @Nonnull final Number operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.multiply(thisNumber, literal); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericInteger multiply( @Nonnull final Long operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.multiply(thisNumber, literal); + return new Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericInteger multiply( @Nonnull final Integer operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.multiply(thisNumber, literal); + return new Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericInteger subtract( @Nonnull final FilterableNumericInteger operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.subtract(thisNumber, value); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + @Nonnull + @Override + default FilterableNumericDecimal subtract( @Nonnull final Number operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.subtract(thisNumber, literal); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericInteger subtract( @Nonnull final Long operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.subtract(thisNumber, literal); + return new Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericInteger subtract( @Nonnull final Integer operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.subtract(thisNumber, literal); + return new Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericInteger modulo( @Nonnull final FilterableNumericInteger operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric value = operand::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.modulo(thisNumber, value); + return new Expression<>(expression, getEntityType()); + } + + @Nonnull + @Override + default FilterableNumericDecimal modulo( @Nonnull final Number operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.modulo(thisNumber, literal); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericInteger modulo( @Nonnull final Long operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.modulo(thisNumber, literal); + return new Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericInteger modulo( @Nonnull final Integer operand ) + { + final ValueNumeric thisNumber = this::getExpression; + final ValueNumeric literal = ValueNumeric.literal(operand); + final FilterExpression expression = FilterExpressionArithmetic.modulo(thisNumber, literal); + return new Expression<>(expression, getEntityType()); + } + + @Override + @Nonnull + default FilterableNumericInteger negate() + { + final ValueNumeric thisNumber = this::getExpression; + final FilterExpression expression = FilterExpressionArithmetic.negate(thisNumber); + return new Expression<>(expression, getEntityType()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableString.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableString.java new file mode 100644 index 0000000000..f87b5200b4 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableString.java @@ -0,0 +1,336 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.Expressions; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpression; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionString; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueBoolean; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueNumeric; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueString; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * Fluent helper class to provide filter functions to OData expressions referenced by String. + * + * @param + * Type of the entity which references the value. + */ +public interface FilterableString extends FilterableValue +{ + /** + * Wrapper expression class, which delegates to another operation. + * + * @param + * Type of the entity which references the value. + */ + @RequiredArgsConstructor + @Getter + class Expression implements FilterableString, FilterExpression + { + @Delegate + @Nonnull + private final FilterExpression delegate; + + @Nonnull + private final Class entityType; + } + + /** + * + * Filter by expression "matchesPattern". + * + * @param operand + * String expression to match the string against. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean matches( @Nonnull final String operand ) + { + final ValueString thisString = this::getExpression; + final ValueString value = (ValueString) Expressions.createOperand(operand); + final ValueBoolean.Expression expression = FilterExpressionString.matchesPattern(thisString, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "matchesPattern". + * + * @param operand + * String expression to match the string against. + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean matches( @Nonnull final FilterableString operand ) + { + final ValueString thisString = this::getExpression; + final ValueString value = operand::getExpression; + final ValueBoolean.Expression expression = FilterExpressionString.matchesPattern(thisString, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "tolower". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableString toLower() + { + final ValueString.Expression expression = FilterExpressionString.toLower(this::getExpression); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "toupper". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableString toUpper() + { + final ValueString.Expression expression = FilterExpressionString.toUpper(this::getExpression); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "trim". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableString trim() + { + final ValueString.Expression expression = FilterExpressionString.trim(this::getExpression); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "length". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger length() + { + final ValueNumeric.Expression expression = FilterExpressionString.length(this::getExpression); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "concat". + * + * @param operand + * The string to concatenate with. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableString concat( @Nonnull final FilterableString operand ) + { + final ValueString value = operand::getExpression; + final ValueString.Expression expression = FilterExpressionString.concat(this::getExpression, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "concat". + * + * @param operand + * The string to concatenate with. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableString concat( @Nonnull final String operand ) + { + final ValueString value = (ValueString) Expressions.createOperand(operand); + final ValueString.Expression expression = FilterExpressionString.concat(this::getExpression, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "startswith". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean startsWith( @Nonnull final FilterableString operand ) + { + final ValueString thisString = this::getExpression; + final ValueString value = operand::getExpression; + final ValueBoolean.Expression expression = FilterExpressionString.startsWith(thisString, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "startswith". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean startsWith( @Nonnull final String operand ) + { + final ValueString thisString = this::getExpression; + final ValueString value = (ValueString) Expressions.createOperand(operand); + final ValueBoolean.Expression expression = FilterExpressionString.startsWith(thisString, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "endswith". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean endsWith( @Nonnull final FilterableString operand ) + { + final ValueString thisString = this::getExpression; + final ValueString value = operand::getExpression; + final ValueBoolean.Expression expression = FilterExpressionString.endsWith(thisString, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "endswith". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean endsWith( @Nonnull final String operand ) + { + final ValueString thisString = this::getExpression; + final ValueString value = (ValueString) Expressions.createOperand(operand); + final ValueBoolean.Expression expression = FilterExpressionString.endsWith(thisString, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "contain". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean contains( @Nonnull final FilterableString operand ) + { + final ValueString thisString = this::getExpression; + final ValueString value = operand::getExpression; + final ValueBoolean.Expression expression = FilterExpressionString.contains(thisString, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "contain". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean contains( @Nonnull final String operand ) + { + final ValueString thisString = this::getExpression; + final ValueString value = (ValueString) Expressions.createOperand(operand); + final ValueBoolean.Expression expression = FilterExpressionString.contains(thisString, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "indexof". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger indexOf( @Nonnull final FilterableString operand ) + { + final ValueString thisString = this::getExpression; + final ValueString value = operand::getExpression; + final ValueNumeric.Expression expression = FilterExpressionString.indexOf(thisString, value); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "indexof". + * + * @param operand + * The substring which is checked for. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger indexOf( @Nonnull final String operand ) + { + final ValueString thisString = this::getExpression; + final ValueString value = (ValueString) Expressions.createOperand(operand); + final ValueNumeric.Expression expression = FilterExpressionString.indexOf(thisString, value); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "substring". + * + * @param operand + * The number of characters to cut off. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableString substring( @Nonnull final Integer operand ) + { + final ValueString thisString = this::getExpression; + final ValueNumeric value = (ValueNumeric) Expressions.createOperand(operand); + final ValueString.Expression expression = FilterExpressionString.substring(thisString, value); + return new Expression<>(expression, getEntityType()); + } + + /** + * + * 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 + FilterableString + substring( @Nonnull final Integer operandIndex, @Nonnull final Integer operandLength ) + { + final ValueString thisString = this::getExpression; + final ValueNumeric index = (ValueNumeric) Expressions.createOperand(operandIndex); + final ValueNumeric length = (ValueNumeric) Expressions.createOperand(operandLength); + final ValueString.Expression expression = FilterExpressionString.substring(thisString, index, length); + return new Expression<>(expression, getEntityType()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableTime.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableTime.java new file mode 100644 index 0000000000..57fc39c9ae --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableTime.java @@ -0,0 +1,96 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import java.time.LocalTime; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpression; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionTemporal; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueTimeOfDay; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +/** + * Fluent helper class to provide filter functions to OData expressions referenced by Time. + * + * @param + * Type of the entity which references the value. + */ +public interface FilterableTime extends FilterableValue +{ + /** + * Wrapper expression class, which delegates to another operation. + * + * @param + * Type of the entity which references the value. + */ + @RequiredArgsConstructor + @Getter + class Expression implements FilterableTime, FilterExpression + { + @Delegate + @Nonnull + private final FilterExpression delegate; + + @Nonnull + private final Class entityType; + } + + /** + * + * Filter by expression "fractionalseconds". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericDecimal timeFractionalSeconds() + { + final ValueTimeOfDay thisTime = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.fractionalSeconds(thisTime); + return new FilterableNumericDecimal.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "second". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger timeSecond() + { + final ValueTimeOfDay thisTime = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.second(thisTime); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "minute". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger timeMinute() + { + final ValueTimeOfDay thisTime = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.minute(thisTime); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "hour". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableNumericInteger timeHour() + { + final ValueTimeOfDay thisTime = this::getExpression; + final FilterExpression expression = FilterExpressionTemporal.hour(thisTime); + return new FilterableNumericInteger.Expression<>(expression, getEntityType()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableValue.java b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableValue.java new file mode 100644 index 0000000000..b28bf441ce --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableValue.java @@ -0,0 +1,284 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import java.io.Serializable; +import java.util.Arrays; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.Expressions; +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpressionLogical; +import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueBoolean; + +/** + * Fluent helper class to provide filter functions to OData expressions referenced by all value types. + * + * @param + * Type of the entity which references the value. + * @param + * Type of the value the filterable field holds. + */ +public interface FilterableValue + extends + Expressions.Operand, + EntityReference +{ + /** + * + * Filter by expression "eq null". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean equalToNull() + { + final ValueBoolean.Expression expression = FilterExpressionLogical.equalTo(this, Expressions.Operand.NULL); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "ne null". + * + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean notEqualToNull() + { + final ValueBoolean.Expression expression = FilterExpressionLogical.notEqualTo(this, Expressions.Operand.NULL); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "eq". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean equalTo( @Nonnull final FilterableValue operand ) + { + final ValueBoolean.Expression expression = FilterExpressionLogical.equalTo(this, operand); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "eq". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + * @throws IllegalArgumentException + * When there is no mapping found for the provided Java literal. + */ + @Nonnull + default FilterableBoolean equalTo( @Nullable final PrimitiveT operand ) + { + final Expressions.Operand value = Expressions.createOperand(operand); + final ValueBoolean.Expression expression = FilterExpressionLogical.equalTo(this, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "ne". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean notEqualTo( @Nonnull final FilterableValue operand ) + { + final ValueBoolean.Expression expression = FilterExpressionLogical.notEqualTo(this, operand); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "ne". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + * @throws IllegalArgumentException + * When there is no mapping found for the provided Java literal. + */ + @Nonnull + default FilterableBoolean notEqualTo( @Nullable final PrimitiveT operand ) + { + final Expressions.Operand value = Expressions.createOperand(operand); + final ValueBoolean.Expression expression = FilterExpressionLogical.notEqualTo(this, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "lt". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean lessThan( @Nonnull final FilterableValue operand ) + { + final ValueBoolean.Expression expression = FilterExpressionLogical.lessThan(this, operand); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "lt". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + * @throws IllegalArgumentException + * When there is no mapping found for the provided Java literal. + */ + @Nonnull + default FilterableBoolean lessThan( @Nonnull final PrimitiveT operand ) + { + final Expressions.Operand value = Expressions.createOperand(operand); + final ValueBoolean.Expression expression = FilterExpressionLogical.lessThan(this, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "le". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean lessThanEqual( @Nonnull final FilterableValue operand ) + { + final ValueBoolean.Expression expression = FilterExpressionLogical.lessThanEquals(this, operand); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "le". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + * @throws IllegalArgumentException + * When there is no mapping found for the provided Java literal. + */ + @Nonnull + default FilterableBoolean lessThanEqual( @Nonnull final PrimitiveT operand ) + { + final Expressions.Operand value = Expressions.createOperand(operand); + final ValueBoolean.Expression expression = FilterExpressionLogical.lessThanEquals(this, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "gt". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean greaterThan( @Nonnull final FilterableValue operand ) + { + final ValueBoolean.Expression expression = FilterExpressionLogical.greaterThan(this, operand); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "gt". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + * @throws IllegalArgumentException + * When there is no mapping found for the provided Java literal. + */ + @Nonnull + default FilterableBoolean greaterThan( @Nonnull final PrimitiveT operand ) + { + final Expressions.Operand value = Expressions.createOperand(operand); + final ValueBoolean.Expression expression = FilterExpressionLogical.greaterThan(this, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "ge". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean greaterThanEqual( @Nonnull final FilterableValue operand ) + { + final ValueBoolean.Expression expression = FilterExpressionLogical.greaterThanEquals(this, operand); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "ge". + * + * @param operand + * The generic operand to compare with. + * @return The FluentHelper filter. + * @throws IllegalArgumentException + * When there is no mapping found for the provided Java literal. + */ + @Nonnull + default FilterableBoolean greaterThanEqual( @Nonnull final PrimitiveT operand ) + { + final Expressions.Operand value = Expressions.createOperand(operand); + final ValueBoolean.Expression expression = FilterExpressionLogical.greaterThanEquals(this, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "in". + * + * @param operands + * The generic operands to compare with. + * @return The FluentHelper filter. + * @throws IllegalArgumentException + * When there is no mapping found for any of the provided Java literals. + */ + @SuppressWarnings( "unchecked" ) + @Nonnull + default FilterableBoolean in( @Nonnull final PrimitiveT... operands ) + { + final Expressions.Operand[] value = + Arrays.stream(operands).map(Expressions::createOperand).toArray(Expressions.Operand[]::new); + final ValueBoolean.Expression expression = FilterExpressionLogical.in(this, value); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } + + /** + * + * Filter by expression "in". + * + * @param operand + * The generic operands to compare with. + * @return The FluentHelper filter. + */ + @Nonnull + default FilterableBoolean in( @Nonnull final FilterableCollection operand ) + { + final ValueBoolean.Expression expression = FilterExpressionLogical.in(this, operand); + return new FilterableBoolean.Expression<>(expression, getEntityType()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/TestUtility.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/TestUtility.java new file mode 100644 index 0000000000..352e18c032 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/TestUtility.java @@ -0,0 +1,39 @@ +package com.sap.cloud.sdk.datamodel.odatav4; + +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import com.google.common.io.Resources; +import com.sap.cloud.sdk.testutil.TestConfigurationError; + +public class TestUtility +{ + public static String readResourceFile( final Class cls, final String resourceFileName ) + { + try { + final URL resourceUrl = getResourceUrl(cls, resourceFileName); + + return Resources.toString(resourceUrl, StandardCharsets.UTF_8); + } + catch( final IOException e ) { + throw new TestConfigurationError(e); + } + } + + public static String readResourceFileCrlf( final Class cls, final String resourceFileName ) + { + return readResourceFile(cls, resourceFileName).replaceAll("(? cls, final String resourceFileName ) + { + final URL resourceUrl = cls.getClassLoader().getResource(cls.getSimpleName() + "/" + resourceFileName); + + if( resourceUrl == null ) { + throw new TestConfigurationError("Cannot find resource file with name \"" + resourceFileName + "\"."); + } + + return resourceUrl; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/BigDecimalSerialisationTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/BigDecimalSerialisationTest.java new file mode 100644 index 0000000000..8782869949 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/BigDecimalSerialisationTest.java @@ -0,0 +1,65 @@ +package com.sap.cloud.sdk.datamodel.odatav4.adapter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.gson.Gson; +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEntity; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +class BigDecimalSerialisationTest +{ + private static final String TEST_DEFAULT_SERVICE_PATH = "/odata/default"; + + @NoArgsConstructor + @JsonAdapter( GsonVdmAdapterFactory.class ) + @JsonSerialize( using = JacksonVdmObjectSerializer.class ) + @JsonDeserialize( using = JacksonVdmObjectDeserializer.class ) + @EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) + @Data + private class TestEntity extends VdmEntity + { + @Getter + private final String odataType = "TestEntity"; + + @Getter + private final String entityCollection = "EntityCollection"; + + @Getter + private final Class type = TestEntity.class; + + @Getter + private final String defaultServicePath = TEST_DEFAULT_SERVICE_PATH; + + @ElementName( "BigDecimalField" ) + BigDecimal bigDecimalField; + + } + + @Test + void testSerialization() + throws JsonProcessingException + { + final TestEntity testEntity = new TestEntity(); + testEntity.setBigDecimalField(new BigDecimal("0.000000003")); + // GSON + final String jsonGson = new Gson().toJson(testEntity); + assertThat(jsonGson).doesNotContain("3E-9").contains("0.000000003"); + // Jackson + final String jsonJackson = new ObjectMapper().writeValueAsString(testEntity); + assertThat(jsonJackson).doesNotContain("3E-9").contains("0.000000003"); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/SerializerAdapterTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/SerializerAdapterTest.java new file mode 100644 index 0000000000..8200f547b5 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/adapter/SerializerAdapterTest.java @@ -0,0 +1,79 @@ +package com.sap.cloud.sdk.datamodel.odatav4.adapter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.OffsetDateTime; +import java.util.Collections; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.City; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Feature; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Location; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Trip; + +class SerializerAdapterTest +{ + private static final City CITY = + City.builder().name("Potsdam").countryRegion("Brandenburg").region("Deutschland").build(); + + private static final Trip TRIP_A = + Trip.builder().budget(100.0f).endsAt(OffsetDateTime.now()).shareId(UUID.randomUUID()).name("a").build(); + + private static final Trip TRIP_B = + Trip.builder().budget(20.0f).endsAt(OffsetDateTime.now()).shareId(UUID.randomUUID()).name("b").build(); + + private static final Person PERSON = + Person + .builder() + .lastName("Bar") + .emails(Collections.singletonList("foo@bar.com")) + .favoriteFeature(Feature.FEATURE1) + .trips(TRIP_A, TRIP_B) + .addressInfo(Collections.singletonList(new Location("Sesamstr.1", CITY))) + .build(); + + @Test + void testCompatibleNonNullGsonAndJackson() + throws JsonProcessingException + { + // GSON + final Gson gson = new Gson(); + final String jsonGson = gson.toJson(PERSON); + final Person personGson = gson.fromJson(jsonGson, Person.class); + + // Jackson + final ObjectMapper mapper = new ObjectMapper(); + mapper.setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL); + final String jsonJackson = mapper.writeValueAsString(PERSON); + final Person personJackson = mapper.readValue(jsonJackson, Person.class); + + // test + assertThat(personJackson).isEqualTo(personGson); + } + + @Test + void testCompatibleNullableGsonAndJackson() + throws JsonProcessingException + { + // GSON + final Gson gson = new GsonBuilder().serializeNulls().create(); + final String jsonGson = gson.toJson(PERSON); + final Person personGson = gson.fromJson(jsonGson, Person.class); + + // Jackson + final ObjectMapper mapper = new ObjectMapper(); + final String jsonJackson = mapper.writeValueAsString(PERSON); + final Person personJackson = mapper.readValue(jsonJackson, Person.class); + + // test + assertThat(personJackson).isEqualTo(personGson); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/BoundActionTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/BoundActionTest.java new file mode 100644 index 0000000000..8837a419e6 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/BoundActionTest.java @@ -0,0 +1,126 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; + +import org.apache.hc.core5.http.HttpHeaders; +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Location; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; + +class BoundActionTest +{ + private static final DefaultTrippinService service = new DefaultTrippinService().withServicePath("service-root"); + private static final Person person = Person.builder().userName("Fridolin").build(); + + @Test + void testActionOnEntityWithEtag() + { + final String targetUrl = "/service-root/People('Fridolin')/Trippin.MakeHappy"; + + person.setVersionIdentifier("some-etag"); + final SingleValueActionRequestBuilder action = service.forEntity(person).applyAction(Person.makeHappy()); + + assertThat(action.toRequest().getRelativeUri()).hasToString(targetUrl); + assertThat(action.toRequest().getActionParameters()).hasToString("{}"); + assertThat(action.toRequest().getHeaders()) + .containsEntry(HttpHeaders.IF_MATCH, Collections.singletonList("some-etag")); + } + + @Test + void testActionOnEntityNoEtag() + { + final String targetUrl = "/service-root/People('Fridolin')/Trippin.MakeHappy"; + + person.setVersionIdentifier(null); + final SingleValueActionRequestBuilder action = service.forEntity(person).applyAction(Person.makeHappy()); + + assertThat(action.toRequest().getRelativeUri()).hasToString(targetUrl); + assertThat(action.toRequest().getActionParameters()).hasToString("{}"); + assertThat(action.toRequest().getHeaders()).doesNotContainKey(HttpHeaders.IF_MATCH); + } + + @Test + void testActionWithParameters() + { + final String targetUrl = "/service-root/People('Fridolin')/Trippin.MakeUnhappy"; + + final SingleValueActionRequestBuilder action = + service.forEntity(person).applyAction(Person.makeUnhappy(true)); + + assertThat(action.toRequest().getRelativeUri()).hasToString(targetUrl); + assertThat(action.toRequest().getActionParameters()).hasToString("{\"very\":true}"); + } + + @Test + void testActionSingleToCollection() + { + final String targetUrl = "/service-root/People/Trippin.MakeAllHappy"; + + final SingleValueActionRequestBuilder action = service.applyAction(Person.makeAllHappy()); + + assertThat(action.toRequest().getRelativeUri()).hasToString(targetUrl); + } + + @Test + void testActionCollectionToCollection() + { + final String targetUrl = "/service-root/People('Fridolin')/Trippin.sendMail"; + + final CollectionValueActionRequestBuilder action = + service.forEntity(person).applyAction(Person.sendMail("foo")); + + assertThat(action.toRequest().getActionParameters()).isEqualTo("{\"subject\":\"foo\"}"); + assertThat(action.toRequest().getRelativeUri()).hasToString(targetUrl); + } + + @Test + void testActionOnNestedCollection() + { + final String targetUrl = "/service-root/People('Fridolin')/Friends/Trippin.MakeAllHappy"; + + final SingleValueActionRequestBuilder action = + service.forEntity(person).navigateTo(Person.TO_FRIENDS).applyAction(Person.makeAllHappy()); + + assertThat(action.toRequest().getRelativeUri()).hasToString(targetUrl); + } + + @Test + void testSuccessfulCompilation() + { + // test to ensure that all the combinations at least compile + + // 1 - 1 + final SingleValueActionRequestBuilder action1 = + service + .forEntity(person) + .applyAction( + new BoundAction.SingleToSingle<>(Person.class, Location.class, "Stuff", Collections.emptyMap())); + + // 1 - n + final CollectionValueActionRequestBuilder action2 = + service + .forEntity(person) + .applyAction( + new BoundAction.SingleToCollection<>(Person.class, String.class, "Stuff", Collections.emptyMap())); + + // n - 1 + final SingleValueActionRequestBuilder action3 = + service + .applyAction( + new BoundAction.CollectionToSingle<>(Person.class, String.class, "Stuff", Collections.emptyMap())); + + // n - n + final CollectionValueActionRequestBuilder action4 = + service + .applyAction( + new BoundAction.CollectionToCollection<>( + Person.class, + String.class, + "Stuff", + Collections.emptyMap())); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/BoundFunctionTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/BoundFunctionTest.java new file mode 100644 index 0000000000..3d48926d42 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/BoundFunctionTest.java @@ -0,0 +1,331 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Location; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; + +class BoundFunctionTest +{ + private static final DefaultTrippinService service = new DefaultTrippinService().withServicePath("service-root"); + private static final Person person = Person.builder().userName("Fridolin").build(); + + /* From OData V4 ABNF + + ; boundOperation segments can only be composed if the type of the previous segment + ; matches the type of the first parameter of the action or function being called. + ; Note that the rule name reflects the return type of the function. + boundOperation = "/" ( boundActionCall + / boundEntityColFunctionCall [ collectionNavigation ] + / boundEntityFunctionCall [ singleNavigation ] + / boundComplexColFunctionCall [ complexColPath ] + / boundComplexFunctionCall [ complexPath ] + / boundPrimitiveColFunctionCall [ primitiveColPath ] + / boundPrimitiveFunctionCall [ primitivePath ] + / boundFunctionCallNoParens + ) + */ + + // Problem: We don't have a good abstraction for entity sets + // Consequence: forEntity(..) doesn't work if there is more than 1 entity set for the type + // the same problem has this approach + + @Test + void testEntityToPrimitive() + { + final String targetUrl = "/service-root/People('Fridolin')/Trippin.IsHappy()"; + + final SingleValueFunctionRequestBuilder function = + service.forEntity(person).applyFunction(Person.isHappy()); + + assertThat(function.toRequest().getRelativeUri()).hasToString(targetUrl); + } + + @Test + void testNavigationToPrimitive() + { + final String targetUrl = "/service-root/People('Fridolin')/BestFriend/Trippin.IsHappy()"; + + final SingleValueFunctionRequestBuilder function = + service.forEntity(person).navigateTo(Person.TO_BEST_FRIEND).applyFunction(Person.isHappy()); + + assertThat(function.toRequest().getRelativeUri()).hasToString(targetUrl); + } + + @Test + void testCollectionToPrimitive() + { + final String targetUrl = "/service-root/People('Fridolin')/Friends/Trippin.AreAllFriends()"; + + final SingleValueFunctionRequestBuilder function = + service.forEntity(person).navigateTo(Person.TO_FRIENDS).applyFunction(Person.areAllFriends()); + + assertThat(function.toRequest().getRelativeUri()).hasToString(targetUrl); + } + + @Test + void testCollectionToCollection() + { + final String targetUrl = "/service-root/People/Trippin.MostPopularPersons()"; + + final CollectionValueFunctionRequestBuilder function = + service.applyFunction(Person.mostPopularPersons()); + + assertThat(function.toRequest().getRelativeUri()).hasToString(targetUrl); + } + + @Test + void testCollectionOnRootEntity() + { + final String targetUrl = "/service-root/People/Trippin.AreAllFriends()"; + + final SingleValueFunctionRequestBuilder function = service.applyFunction(Person.areAllFriends()); + + assertThat(function.toRequest().getRelativeUri()).hasToString(targetUrl); + } + + @Test + void testNavigationCollectionToPrimitive() + { + final String targetUrl = "/service-root/People('Fridolin')/Friends/Trippin.AreAllFriends()"; + + final SingleValueFunctionRequestBuilder function = + service.forEntity(person).navigateTo(Person.TO_FRIENDS).applyFunction(Person.areAllFriends()); + + assertThat(function.toRequest().getRelativeUri()).hasToString(targetUrl); + } + + @Test + void testEntityToEntity() + { + final String targetUrl = "/service-root/People('Fridolin')/Trippin.WorstFriend()"; + + final SingleValueFunctionRequestBuilder function = + service.forEntity(person).applyFunction(Person.worstFriend()); + + assertThat(function.toRequest().getRelativeUri()).hasToString(targetUrl); + } + + @Test + void testCompositionOnEntity() + { + final String targetUrl = "/service-root/People('Fridolin')/Trippin.WorstFriend()/Friends?$top=5"; + + final GetAllRequestBuilder request = + service.forEntity(person).withFunction(Person.worstFriend()).navigateTo(Person.TO_FRIENDS).getAll().top(5); + + assertThat(request.toRequest().getRelativeUri()).hasToString(targetUrl); + } + + @Test + void testCompositionOnRootEntity() + { + final String targetUrl = "/service-root/People/Trippin.MostPopularPerson()/Friends?$top=5"; + + final GetAllRequestBuilder request = + service.withFunction(Person.mostPopularPerson()).navigateTo(Person.TO_FRIENDS).getAll().top(5); + + assertThat(request.toRequest().getRelativeUri()).hasToString(targetUrl); + } + + @Test + void testGetAllEntitiesAfterFunctionResult() + { + final String targetUrl = "/service-root/People/Trippin.MostPopularPersons()"; + + final GetAllRequestBuilder request = service.withFunction(Person.mostPopularPersons()).getAll(); + + assertThat(request.toRequest().getRelativeUri()).hasToString(targetUrl); + } + + @Test + void testSubsequentFunctionInvocation() + { + final String targetUrl = "/service-root/People/Trippin.MostPopularPersons()/Trippin.AreAllFriends()"; + + final SingleValueFunctionRequestBuilder request = + service.withFunction(Person.mostPopularPersons()).applyFunction(Person.areAllFriends()); + + assertThat(request.toRequest().getRelativeUri()).hasToString(targetUrl); + } + + @Test + void testAdditionalParameters() + { + final String targetUrl = "/service-root/People('Fridolin')/Trippin.IsHappy(really=true)"; + + final SingleValueFunctionRequestBuilder function = + service.forEntity(person).applyFunction(Person.isHappy(true)); + + assertThat(function.toRequest().getRelativeUri()).hasToString(targetUrl); + } + + @Test + void testSuccessfulCompilation() + { + // test to ensure that all the combinations at least compile + + // 1 - 1 + SingleValueFunctionRequestBuilder fun1; + fun1 = + service + .forEntity(person) + .applyFunction( + new BoundFunction.SingleToSingleComplex<>( + Person.class, + Location.class, + "Stuff", + Collections.emptyMap())); + fun1 = + service + .forEntity(person) + .applyFunction( + new BoundFunction.SingleToSingleEntity<>( + Person.class, + Person.class, + "Stuff", + Collections.emptyMap())); + fun1 = + service + .forEntity(person) + .applyFunction( + new BoundFunction.SingleToSinglePrimitive<>( + Person.class, + String.class, + "Stuff", + Collections.emptyMap())); + + // 1 - n + CollectionValueFunctionRequestBuilder fun2; + fun2 = + service + .forEntity(person) + .applyFunction( + new BoundFunction.SingleToCollectionPrimitive<>( + Person.class, + String.class, + "Stuff", + Collections.emptyMap())); + fun2 = + service + .forEntity(person) + .applyFunction( + new BoundFunction.SingleToCollectionComplex<>( + Person.class, + Location.class, + "Stuff", + Collections.emptyMap())); + fun2 = + service + .forEntity(person) + .applyFunction( + new BoundFunction.SingleToCollectionEntity<>( + Person.class, + Person.class, + "Stuff", + Collections.emptyMap())); + + // n - 1 + SingleValueFunctionRequestBuilder fun3; + fun3 = + service + .applyFunction( + new BoundFunction.CollectionToSinglePrimitive<>( + Person.class, + String.class, + "Stuff", + Collections.emptyMap())); + fun3 = + service + .applyFunction( + new BoundFunction.CollectionToSingleComplex<>( + Person.class, + Location.class, + "Stuff", + Collections.emptyMap())); + fun3 = + service + .applyFunction( + new BoundFunction.CollectionToSingleEntity<>( + Person.class, + Person.class, + "Stuff", + Collections.emptyMap())); + + // n - n + CollectionValueFunctionRequestBuilder fun4; + fun2 = + service + .applyFunction( + new BoundFunction.CollectionToCollectionPrimitive<>( + Person.class, + String.class, + "Stuff", + Collections.emptyMap())); + fun2 = + service + .applyFunction( + new BoundFunction.CollectionToCollectionComplex<>( + Person.class, + Location.class, + "Stuff", + Collections.emptyMap())); + fun2 = + service + .applyFunction( + new BoundFunction.CollectionToCollectionEntity<>( + Person.class, + Person.class, + "Stuff", + Collections.emptyMap())); + + } + + /* + @Test + void testBoundFunctionOnPrimitiveType() + { + // Target URI: /service-root/People('Fridolin')/FirstName/Model.String.ToUpperCase() + + SingleValueFunctionRequestBuilder function = + service + .forEntity(person) + // overload this to support both primitive and complex types + .onProperty(Person.FIRST_NAME) + // Function is of type: String -> String + .applyFunction(TrippinService.PrimitiveFunctions.toUpperCase()); + }*/ + + /* + @Test + void testBoundFunctionOnComplexType() + { + // Target URI: /service-root/People('Fridolin')/AddressInfo/Model.Address.GetClosestLocation() + + SingleValueFunctionRequestBuilder function = + service + .forEntity(person) + .onProperty(Person.ADDRESS_INFO) + // Function is of type: Collection -> Location + .applyFunction(Location.getClosestLocation()); + }*/ + + /* + @Test + void testBoundFunctionOnEachEntityInCollection() + { + // Target URI: /service-root/People/$each/Trippin.isHappy() + + SingleValueFunctionRequestBuilder function = + service + // overload this to support both functions on single entities and collections + // automatically apply $each if the function is of type: EntityT -> Any + .applyFunction(Person.isHappy()); + + }*/ +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/CollectionValueActionRequestBuilderTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/CollectionValueActionRequestBuilderTest.java new file mode 100644 index 0000000000..c00f9fb29b --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/CollectionValueActionRequestBuilderTest.java @@ -0,0 +1,236 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +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.equalToJson; +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.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.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +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.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.datamodel.odatav4.TestUtility; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@WireMockTest +class CollectionValueActionRequestBuilderTest +{ + private static final String DEFAULT_SERVICE_PATH = "/odata/default"; + private static final String ODATA_ACTION = "TestAction"; + + private DefaultHttpDestination destination; + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + stubFor(head(anyUrl()).willReturn(ok())); + } + + private static String readResourceFile( final String resourceFileName ) + { + return TestUtility.readResourceFile(CollectionValueActionRequestBuilderTest.class, resourceFileName); + } + + @Data + @EqualsAndHashCode( callSuper = true ) + @NoArgsConstructor + @AllArgsConstructor + @JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) + @JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) + @JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) + public static class TestEntity extends VdmEntity + { + @Getter + private final String odataType = "com.sap.cloud.sdk.TestEntity"; + + @Getter + private final String entityCollection = "EntityCollection"; + + @Getter + private final Class type = + SingleValueFunctionRequestBuilderTest.TestEntity.class; + + @Override + protected String getDefaultServicePath() + { + return DEFAULT_SERVICE_PATH; + } + + @ElementName( "Name" ) + @SerializedName( "Name" ) + @JsonProperty( "Name" ) + private String name; + } + + @Builder + @Data + @NoArgsConstructor + @AllArgsConstructor + @ToString( doNotUseGetters = true, callSuper = true ) + @EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) + @JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) + @JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) + @JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) + public static class ComplexType extends VdmComplex + { + + @Getter + private final String odataType = "com.sap.cloud.sdk.ComplexType"; + + @Getter + private final Class type = + SingleValueActionRequestBuilderTest.ComplexType.class; + + @ElementName( "City" ) + private String city; + + @ElementName( "Country" ) + private String country; + } + + @Test + void testActionWithPrimitiveResponse() + { + stubFor( + post(urlPathEqualTo(DEFAULT_SERVICE_PATH + '/' + ODATA_ACTION)) + .willReturn(okJson("{\"value\" : [ 3.14, 9.81 ]}"))); + + final CollectionValueActionRequestBuilder sut = + new CollectionValueActionRequestBuilder<>(DEFAULT_SERVICE_PATH, ODATA_ACTION, Float.class); + final String actionRequestBody = readResourceFile("ActionNoParametersRequestBody.json"); + + final ActionResponseCollection actualResponse = sut.execute(destination); + + verify( + postRequestedFor(urlEqualTo(DEFAULT_SERVICE_PATH + '/' + ODATA_ACTION)) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(equalToJson(actionRequestBody))); + + verify( + 1, + headRequestedFor(urlEqualTo(DEFAULT_SERVICE_PATH + "/")).withHeader("x-csrf-token", equalTo("fetch"))); + + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getResponseResult().get()).hasSize(2); + assertThat(actualResponse.getResponseResult().get()).contains(3.14f, 9.81f); + assertThat(actualResponse.getResponseStatusCode()).isEqualTo(200); + } + + @Test + void testActionWithStringResponse() + { + + stubFor( + post(urlPathEqualTo(DEFAULT_SERVICE_PATH + '/' + ODATA_ACTION)) + .willReturn(okJson("{" + "\"value\" : [ \"It\", \"works\",\"as\",\"expected\" ]" + "}"))); + + final CollectionValueActionRequestBuilder sut = + new CollectionValueActionRequestBuilder<>(DEFAULT_SERVICE_PATH, ODATA_ACTION, String.class); + final String actionRequestBody = readResourceFile("ActionNoParametersRequestBody.json"); + + final ActionResponseCollection actualResponse = sut.execute(destination); + + verify( + postRequestedFor(urlEqualTo(DEFAULT_SERVICE_PATH + '/' + ODATA_ACTION)) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(equalToJson(actionRequestBody))); + + verify( + 1, + headRequestedFor(urlEqualTo(DEFAULT_SERVICE_PATH + "/")).withHeader("x-csrf-token", equalTo("fetch"))); + + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getResponseResult().get()).hasSize(4); + assertThat(actualResponse.getResponseResult().get()).contains("It", "works", "as", "expected"); + assertThat(actualResponse.getResponseStatusCode()).isEqualTo(200); + } + + @Test + void testActionWithComplexTypeResponse() + { + + stubFor(post(urlPathEqualTo(DEFAULT_SERVICE_PATH + '/' + ODATA_ACTION)).willReturn(okJson(""" + { "value" : [\ + { "City" : "Stockholm" ,"Country" : "Sweden"},\ + { "City" : "Dubrovnik","Country" : "Croatia" }\ + ]}\ + """))); + + final CollectionValueActionRequestBuilder sut = + new CollectionValueActionRequestBuilder<>(DEFAULT_SERVICE_PATH, ODATA_ACTION, ComplexType.class); + final String actionRequestBody = readResourceFile("ActionNoParametersRequestBody.json"); + final ActionResponseCollection actualResponse = sut.execute(destination); + + verify( + postRequestedFor(urlEqualTo(DEFAULT_SERVICE_PATH + '/' + ODATA_ACTION)) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(equalToJson(actionRequestBody))); + + verify( + 1, + headRequestedFor(urlEqualTo(DEFAULT_SERVICE_PATH + "/")).withHeader("x-csrf-token", equalTo("fetch"))); + + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getResponseResult().get()).hasSize(2); + assertThat(actualResponse.getResponseResult().get()) + .contains(new ComplexType("Stockholm", "Sweden"), new ComplexType("Dubrovnik", "Croatia")); + assertThat(actualResponse.getResponseStatusCode()).isEqualTo(200); + } + + @Test + void testActionWithEntityResponse() + { + stubFor( + post(urlPathEqualTo(DEFAULT_SERVICE_PATH + '/' + ODATA_ACTION)) + .willReturn( + okJson("{ \"value\" : [" + "{ \"Name\" : \"Tester1\" }," + "{ \"Name\" : \"Tester2\" }" + "]}"))); + + final CollectionValueActionRequestBuilder sut = + new CollectionValueActionRequestBuilder<>(DEFAULT_SERVICE_PATH, ODATA_ACTION, TestEntity.class); + final String actionRequestBody = readResourceFile("ActionNoParametersRequestBody.json"); + final ActionResponseCollection actualResponse = sut.execute(destination); + + verify( + postRequestedFor(urlEqualTo(DEFAULT_SERVICE_PATH + '/' + ODATA_ACTION)) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(equalToJson(actionRequestBody))); + + verify( + 1, + headRequestedFor(urlEqualTo(DEFAULT_SERVICE_PATH + "/")).withHeader("x-csrf-token", equalTo("fetch"))); + + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getResponseResult().get()).hasSize(2); + assertThat(actualResponse.getResponseResult().get()) + .contains(new TestEntity("Tester1"), new TestEntity("Tester2")); + assertThat(actualResponse.getResponseStatusCode()).isEqualTo(200); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/CollectionValueFunctionRequestBuilderTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/CollectionValueFunctionRequestBuilderTest.java new file mode 100644 index 0000000000..320c05d77a --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/CollectionValueFunctionRequestBuilderTest.java @@ -0,0 +1,182 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +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.time.Duration; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +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.request.ODataFunctionParameters; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@WireMockTest +class CollectionValueFunctionRequestBuilderTest +{ + private static final String SERVICE_PATH = "/odata/default"; + private static final String FUNCTION_NAME = "TestFunction"; + private static final ODataFunctionParameters FUNCTION_PARAMETERS; + private static final Map FUNCTION_PARAMETER_MAP = new HashMap<>(); + + static { + FUNCTION_PARAMETER_MAP.put("stringParameter", "test"); + FUNCTION_PARAMETER_MAP.put("booleanParameter", true); + FUNCTION_PARAMETER_MAP.put("integerParameter", 9000); + FUNCTION_PARAMETER_MAP.put("decimalParameter", 3.14); + FUNCTION_PARAMETER_MAP.put("durationParameter", Duration.ofHours(8)); + FUNCTION_PARAMETER_MAP.put("dateTimeParameter", LocalDateTime.of(2019, 12, 25, 8, 0, 0)); + + FUNCTION_PARAMETERS = ODataFunctionParameters.of(FUNCTION_PARAMETER_MAP, ODataProtocol.V4); + } + + private static final String ODATA_FUNCTION_PARAMETER_WITH_SPECIAL_CHARACTER = "(stringParameter='t''est')"; + + private DefaultHttpDestination destination; + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + } + + @Data + @EqualsAndHashCode( callSuper = true ) + @NoArgsConstructor + @AllArgsConstructor + @JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) + @JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) + @JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) + public static class TestEntity extends VdmEntity + { + @Getter + private final String odataType = "com.sap.cloud.sdk.TestEntity"; + + @Getter + private final String entityCollection = "EntityCollection"; + + @Getter + private final Class type = + SingleValueFunctionRequestBuilderTest.TestEntity.class; + + @Override + protected String getDefaultServicePath() + { + return SERVICE_PATH; + } + + @ElementName( "Name" ) + @SerializedName( "Name" ) + @JsonProperty( "Name" ) + private String name; + } + + @Test + void testFunctionQueryWithoutParameters() + { + final CollectionValueFunctionRequestBuilder requestBuilder = + new CollectionValueFunctionRequestBuilder<>(SERVICE_PATH, FUNCTION_NAME, Void.class); + + assertThat(requestBuilder.toRequest().getRelativeUri()).hasToString(SERVICE_PATH + '/' + FUNCTION_NAME + "()"); + } + + @Test + void testFunctionQueryWithMethodParameters() + { + final CollectionValueFunctionRequestBuilder requestBuilder = + new CollectionValueFunctionRequestBuilder( + SERVICE_PATH, + FUNCTION_NAME, + FUNCTION_PARAMETER_MAP, + Void.class); + + assertThat(requestBuilder.toRequest().getRelativeUri()) + .hasToString(SERVICE_PATH + '/' + FUNCTION_NAME + FUNCTION_PARAMETERS.toEncodedString()); + } + + @Test + void testFunctionQueryWithSpecialCharactersInMethodParameters() + { + final ODataFunctionParameters parameters = + new ODataFunctionParameters(ODataProtocol.V4).addParameter("stringParameter", "t'est"); + final ODataResourcePath functionPath = ODataResourcePath.of(FUNCTION_NAME, parameters); + + final CollectionValueFunctionRequestBuilder requestBuilder = + new CollectionValueFunctionRequestBuilder<>(SERVICE_PATH, functionPath, Void.class); + + assertThat(requestBuilder.toRequest().getRelativeUri()) + .hasToString(SERVICE_PATH + '/' + FUNCTION_NAME + ODATA_FUNCTION_PARAMETER_WITH_SPECIAL_CHARACTER); + } + + @Test + void testFunctionQueryWithMapParameters() + { + final CollectionValueFunctionRequestBuilder requestBuilder = + new CollectionValueFunctionRequestBuilder<>( + SERVICE_PATH, + FUNCTION_NAME, + FUNCTION_PARAMETER_MAP, + Void.class); + + assertThat(requestBuilder.toRequest().getRelativeUri()) + .hasToString(SERVICE_PATH + '/' + FUNCTION_NAME + FUNCTION_PARAMETERS.toEncodedString()); + } + + @Test + void testFunctionWithPrimitiveResponse() + { + stubFor( + get(urlPathEqualTo(SERVICE_PATH + '/' + FUNCTION_NAME + "()")) + .willReturn(okJson("{" + "\"value\" : [ 3.14f, 9.81f ]" + "}"))); + + final CollectionValueFunctionRequestBuilder requestBuilder = + new CollectionValueFunctionRequestBuilder<>(SERVICE_PATH, FUNCTION_NAME, Float.class); + + final List actualResponse = requestBuilder.execute(destination); + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse).hasSize(2); + assertThat(actualResponse).contains(3.14f, 9.81f); + } + + @Test + void testFunctionWithEntityResponse() + { + stubFor( + get(urlPathEqualTo(SERVICE_PATH + '/' + FUNCTION_NAME + "()")) + .willReturn( + okJson("{ \"value\" : [" + "{ \"Name\" : \"Tester1\" }," + "{ \"Name\" : \"Tester2\" }" + "]}"))); + + final CollectionValueFunctionRequestBuilder requestBuilder = + new CollectionValueFunctionRequestBuilder<>(SERVICE_PATH, FUNCTION_NAME, TestEntity.class); + + final List actualResponse = requestBuilder.execute(destination); + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse).hasSize(2); + assertThat(actualResponse).contains(new TestEntity("Tester1"), new TestEntity("Tester2")); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/CsrfTokenOptOutTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/CsrfTokenOptOutTest.java new file mode 100644 index 0000000000..3301584cf2 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/CsrfTokenOptOutTest.java @@ -0,0 +1,145 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import static com.github.tomakehurst.wiremock.client.WireMock.absent; +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.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.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.patch; +import static com.github.tomakehurst.wiremock.client.WireMock.patchRequestedFor; +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.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; + +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.datamodel.odatav4.referenceservice.namespaces.trippin.Person; + +/** + * Verifies the backward-compatible CSRF opt-out ({@code withoutCsrfToken()}) and the deprecated no-op + * ({@code withCsrfToken()}) methods on the OData v4 request builders. + */ +@WireMockTest +@SuppressWarnings( "deprecation" ) +class CsrfTokenOptOutTest +{ + private static final String SERVICE_PATH = "/remoteService"; + private static final String ENTITY_URL = SERVICE_PATH + "/People('usr')"; + private static final String PEOPLE_URL = SERVICE_PATH + "/People"; + + private static final String X_CSRF_TOKEN_HEADER_KEY = "x-csrf-token"; + + private DefaultHttpDestination destination; + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + } + + @Test + void withoutCsrfTokenSkipsHeadProbeAndSendsNoToken() + { + stubFor(head(anyUrl()).willReturn(ok().withHeader(X_CSRF_TOKEN_HEADER_KEY, "should-not-be-used"))); + stubFor(patch(urlEqualTo(ENTITY_URL)).willReturn(ok())); + + final Person person = new Person(); + person.setUserName("usr"); + + new UpdateRequestBuilder<>(SERVICE_PATH, person, "People").withoutCsrfToken().execute(destination); + + // no CSRF HEAD probe was fired + verify(0, headRequestedFor(anyUrl())); + // the actual write carries neither a CSRF token nor the internal skip marker + verify( + patchRequestedFor(urlEqualTo(ENTITY_URL)) + .withHeader(X_CSRF_TOKEN_HEADER_KEY, absent()) + .withHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER, absent())); + } + + @Test + void createWithoutCsrfTokenSkipsHeadProbe() + { + stubFor(head(anyUrl()).willReturn(ok().withHeader(X_CSRF_TOKEN_HEADER_KEY, "should-not-be-used"))); + stubFor(post(urlPathEqualTo(PEOPLE_URL)).willReturn(ok().withHeader("Content-Type", "application/json"))); + + final Person person = new Person(); + person.setUserName("usr"); + + new CreateRequestBuilder<>(SERVICE_PATH, person, "People").withoutCsrfToken().execute(destination); + + verify(0, headRequestedFor(anyUrl())); + verify( + postRequestedFor(urlPathEqualTo(PEOPLE_URL)) + .withHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER, absent())); + } + + @Test + void batchWithoutCsrfTokenSkipsHeadProbe() + { + stubFor(head(anyUrl()).willReturn(ok().withHeader(X_CSRF_TOKEN_HEADER_KEY, "should-not-be-used"))); + stubFor(post(urlPathEqualTo(SERVICE_PATH + "/$batch")).willReturn(ok())); + + final Person person = new Person(); + person.setUserName("usr"); + + new BatchRequestBuilder(SERVICE_PATH) + .withoutCsrfToken() + .addChangeset(new CreateRequestBuilder<>(SERVICE_PATH, person, "People")) + .execute(destination); + + verify(0, headRequestedFor(anyUrl())); + verify( + postRequestedFor(urlPathEqualTo(SERVICE_PATH + "/$batch")) + .withHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER, absent())); + } + + @Test + void actionWithoutCsrfTokenSkipsHeadProbe() + { + final String actionUrl = SERVICE_PATH + "/TestAction"; + stubFor(head(anyUrl()).willReturn(ok().withHeader(X_CSRF_TOKEN_HEADER_KEY, "should-not-be-used"))); + stubFor(post(urlPathEqualTo(actionUrl)).willReturn(noContent())); + + new SingleValueActionRequestBuilder<>(SERVICE_PATH, "TestAction", Void.class) + .withoutCsrfToken() + .execute(destination); + + verify(0, headRequestedFor(anyUrl())); + verify( + postRequestedFor(urlPathEqualTo(actionUrl)) + .withHeader(X_CSRF_TOKEN_HEADER_KEY, absent()) + .withHeader(ApacheHttpClient5Accessor.SKIP_CSRF_TOKEN_HEADER, absent())); + } + + @Test + void withCsrfTokenIsNoOpAndReturnsConcreteType() + { + stubFor(head(anyUrl()).willReturn(ok().withHeader(X_CSRF_TOKEN_HEADER_KEY, "should-not-be-used"))); + + // Compile-time assertion: withCsrfToken() returns the concrete builder type. + final GetAllRequestBuilder builder = + new GetAllRequestBuilder<>(SERVICE_PATH, Person.class, "People").withCsrfToken(); + + // A read request never triggers a CSRF HEAD probe. + stubFor( + get(urlPathEqualTo(PEOPLE_URL)) + .willReturn(ok().withHeader("Content-Type", "application/json").withBody("{\"value\":[]}"))); + + builder.execute(destination); + + verify(0, headRequestedFor(anyUrl())); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/CustomFilterExpressionTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/CustomFilterExpressionTest.java new file mode 100644 index 0000000000..8d3fa9181a --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/CustomFilterExpressionTest.java @@ -0,0 +1,67 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +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.ValueBoolean; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableBoolean; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; + +class CustomFilterExpressionTest +{ + @Test + void requestBuilderHasExpectedParameters() + { + final ValueBoolean untypedExpression = FieldReference.of("MiddleName").equalTo("SomeName"); + final FilterableBoolean customFilterExpression = + FilterableBoolean.fromCustomFilter(untypedExpression, Person.class); + + GetAllRequestBuilder requestBuilder = + new DefaultTrippinService().getAllPeople().filter(customFilterExpression); + assertThat(requestBuilder.toRequest().getRelativeUri()).hasParameter("$filter", "(MiddleName eq 'SomeName')"); + } + + @Test + void requestBuilderHasExpectedComplexParameters() + { + final ValueBoolean untypedMultiExpression = + FieldReference + .of("MiddleName") + .equalTo("SomeName") + .and(FieldReference.of("YearsOfExperience").greaterThan(5)); + final FilterableBoolean customFilterExpression = + FilterableBoolean.fromCustomFilter(untypedMultiExpression, Person.class); + + GetAllRequestBuilder requestBuilder = + new DefaultTrippinService().getAllPeople().filter(customFilterExpression); + assertThat(requestBuilder.toRequest().getRelativeUri()) + .hasParameter("$filter", "((MiddleName eq 'SomeName') and (YearsOfExperience gt 5))"); + + } + + @Test + void entityTypeIsCorrect() + { + final ValueBoolean untypedExpression = FieldReference.of("MiddleName").equalTo("SomeName"); + final FilterableBoolean customFilterExpression = + FilterableBoolean.fromCustomFilter(untypedExpression, Person.class); + + assertThat(customFilterExpression.getEntityType()).isEqualTo(Person.class); + } + + @Test + void expressionDelegatedSuccessfully() + { + final ValueBoolean untypedExpression = FieldReference.of("MiddleName").equalTo("SomeName"); + final FilterableBoolean customFilterExpression = + FilterableBoolean.fromCustomFilter(untypedExpression, Person.class); + + assertThat(customFilterExpression.getExpression(ODataProtocol.V4)) + .contains(untypedExpression.getExpression(ODataProtocol.V4)); + } + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/FieldSerializationTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/FieldSerializationTest.java new file mode 100644 index 0000000000..ca571e03ef --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/FieldSerializationTest.java @@ -0,0 +1,239 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHeaders; +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.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; +import com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory; +import com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer; +import com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.SneakyThrows; + +class FieldSerializationTest +{ + @Data + @NoArgsConstructor + @AllArgsConstructor + @EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) + @JsonAdapter( GsonVdmAdapterFactory.class ) + @JsonSerialize( using = JacksonVdmObjectSerializer.class ) + @JsonDeserialize( using = JacksonVdmObjectDeserializer.class ) + public static class ReferenceObject extends VdmEntity + { + @Getter + private final String odataType = "TestEntity"; + + @Getter + private final String entityCollection = "EntityCollection"; + + @Getter + private final Class type = ReferenceObject.class; + + @ElementName( "ByteValue" ) + @SerializedName( "ByteValue" ) + @JsonProperty( "ByteValue" ) + short byteValue; + + @ElementName( "SByteValue" ) + @SerializedName( "SByteValue" ) + @JsonProperty( "SByteValue" ) + Byte sByteValue; + + @ElementName( "Int16Value" ) + @SerializedName( "Int16Value" ) + @JsonProperty( "Int16Value" ) + short int16Value; + + @ElementName( "Int32Value" ) + @SerializedName( "Int32Value" ) + @JsonProperty( "Int32Value" ) + int int32Value; + + @ElementName( "Int64Value" ) + @SerializedName( "Int64Value" ) + @JsonProperty( "Int64Value" ) + long int64Value; + + @ElementName( "SingleValue" ) + @SerializedName( "SingleValue" ) + @JsonProperty( "SingleValue" ) + float singleValue; + + @ElementName( "DoubleValue" ) + @SerializedName( "DoubleValue" ) + @JsonProperty( "DoubleValue" ) + double doubleValue; + + @ElementName( "DecimalValue" ) + @SerializedName( "DecimalValue" ) + @JsonProperty( "DecimalValue" ) + BigDecimal decimalValue; + + @ElementName( "BooleanValue" ) + @SerializedName( "BooleanValue" ) + @JsonProperty( "BooleanValue" ) + boolean booleanValue; + + @ElementName( "StringValue" ) + @SerializedName( "StringValue" ) + @JsonProperty( "StringValue" ) + String stringValue; + + @ElementName( "BinaryValue" ) + @SerializedName( "BinaryValue" ) + @JsonProperty( "BinaryValue" ) + byte[] binaryValue; + + @ElementName( "GuidValue" ) + @SerializedName( "GuidValue" ) + @JsonProperty( "GuidValue" ) + UUID guidValue; + + @ElementName( "TimeOfDayValue" ) + @SerializedName( "TimeOfDayValue" ) + @JsonProperty( "TimeOfDayValue" ) + LocalTime timeOfDayValue; + + @ElementName( "DateValue" ) + @SerializedName( "DateValue" ) + @JsonProperty( "DateValue" ) + LocalDate dateValue; + + @ElementName( "DateTimeOffsetValue" ) + @SerializedName( "DateTimeOffsetValue" ) + @JsonProperty( "DateTimeOffsetValue" ) + OffsetDateTime dateTimeOffsetValue; + + // https://docs.oasis-open.org/odata/odata-json-format/v4.01/csprd06/odata-json-format-v4.01-csprd06.html#sec_PrimitiveValue + private static final String PAYLOAD_ODATA_REFERENCE = """ + {\ + "@odata.type":"#TestEntity",\ + "ByteValue":255,\ + "SByteValue":-128,\ + "Int16Value":1,\ + "Int32Value":-1234,\ + "Int64Value":1234567890,\ + "SingleValue":1234.5677,\ + "DoubleValue":1234.5678,\ + "DecimalValue":110,\ + "BooleanValue":false,\ + "StringValue":"test",\ + "BinaryValue":"AQID",\ + "GuidValue":"00000000-1111-2222-3333-444444444444",\ + "TimeOfDayValue":"12:00:00",\ + "DateValue":"1999-03-14",\ + "DateTimeOffsetValue":"1999-03-14T00:00:00Z",\ + "GeographyPoint":{"type":"Point","coordinates":[142.1,64.1]}\ + }\ + """; + + private static final String PAYLOAD_ODATA_REFERENCE_BASE64URL = + PAYLOAD_ODATA_REFERENCE.replace("\"BinaryValue\":\"AQID\"", "\"BinaryValue\":\"-__v\""); + + private static final String PAYLOAD_ODATA_REFERENCE_MIXED_BASE64_ALPHABET = + PAYLOAD_ODATA_REFERENCE.replace("\"BinaryValue\":\"AQID\"", "\"BinaryValue\":\"+__v\""); + + static final String Base_64 = "+//v"; + static final String Base_64_Url = "-__v"; + } + + @Test + void testBinaryFieldParsingFromResponsePayload() + { + final ODataRequestResultGeneric result = mockRequestResult(ReferenceObject.PAYLOAD_ODATA_REFERENCE); + final ReferenceObject referenceResult = result.as(ReferenceObject.class); + + Objects.requireNonNull(referenceResult); + assertThat(referenceResult.getBinaryValue()).isEqualTo(new byte[] { 1, 2, 3 }); + + final String ser = + new CreateRequestBuilder<>("/", referenceResult, "EntityCollection").toRequest().getSerializedEntity(); + assertThat(ser).isEqualTo(ReferenceObject.PAYLOAD_ODATA_REFERENCE); + } + + @Test + void testBinaryFieldParsingFromBase64UrlResponsePayload() + { + final ODataRequestResultGeneric result = mockRequestResult(ReferenceObject.PAYLOAD_ODATA_REFERENCE_BASE64URL); + final ReferenceObject referenceResult = result.as(ReferenceObject.class); + + Objects.requireNonNull(referenceResult); + assertThat(referenceResult.getBinaryValue()) + .isEqualTo(Base64.getUrlDecoder().decode(ReferenceObject.Base_64_Url)); + + final String ser = + new CreateRequestBuilder<>("/", referenceResult, "EntityCollection").toRequest().getSerializedEntity(); + assertThat(ser).contains("\"BinaryValue\":\"" + ReferenceObject.Base_64 + "\""); + } + + @Test + void testCustomFieldParsingFromResponsePayload() + { + final ODataRequestResultGeneric result = mockRequestResult(ReferenceObject.PAYLOAD_ODATA_REFERENCE); + + final ReferenceObject referenceResult = result.as(ReferenceObject.class); + + Objects.requireNonNull(referenceResult); + assertThat(referenceResult.getCustomFieldNames()).containsExactly("GeographyPoint"); + assertThat(referenceResult. getCustomField("GeographyPoint")).isInstanceOf(Map.class); + } + + @Test + void testBinaryFieldParsingFromMixedBase64AlphabetNormalized() + { + final ODataRequestResultGeneric result = + mockRequestResult(ReferenceObject.PAYLOAD_ODATA_REFERENCE_MIXED_BASE64_ALPHABET); + final ReferenceObject referenceResult = result.as(ReferenceObject.class); + + Objects.requireNonNull(referenceResult); + assertThat(referenceResult.getBinaryValue()).isEqualTo(Base64.getDecoder().decode(ReferenceObject.Base_64)); + + final String ser = + new CreateRequestBuilder<>("/", referenceResult, "EntityCollection").toRequest().getSerializedEntity(); + assertThat(ser).contains("\"BinaryValue\":\"" + ReferenceObject.Base_64 + "\""); + } + + @SneakyThrows + private static ODataRequestResultGeneric mockRequestResult( final String payload ) + { + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + when(request.getProtocol()).thenReturn(ODataProtocol.V4); + + final ClassicHttpResponse response = new BasicClassicHttpResponse(200, "OK"); + response.setEntity(new StringEntity(payload)); + response.setHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString()); + + return new ODataRequestResultGeneric(request, response); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/HeadersHandlingTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/HeadersHandlingTest.java new file mode 100644 index 0000000000..33f57a676e --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/HeadersHandlingTest.java @@ -0,0 +1,135 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +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.head; +import static com.github.tomakehurst.wiremock.client.WireMock.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.patch; +import static com.github.tomakehurst.wiremock.client.WireMock.patchRequestedFor; +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.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import javax.annotation.Nonnull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; + +@WireMockTest +class HeadersHandlingTest +{ + private static final WireMockConfiguration WIREMOCK_CONFIGURATION = wireMockConfig().dynamicPort(); + private static final String SERVICE_PATH = "/remoteService"; + private static final String ENTITY_URL = SERVICE_PATH + "/People('usr')"; + + private static final String X_CSRF_TOKEN_HEADER_KEY = "x-csrf-token"; + private static final String X_CSRF_TOKEN_HEADER_VALUE = "awesome-csrf-token"; + private static final String SET_COOKIE_HEADER_KEY = "Set-Cookie"; + + private DefaultHttpDestination destination; + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + } + + @Test + void testUpdateRequestContainsHeadersWithNonUniqueKeys() + { + stubFor(head(anyUrl()).willReturn(ok().withHeader(X_CSRF_TOKEN_HEADER_KEY, X_CSRF_TOKEN_HEADER_VALUE))); + + stubFor( + patch(urlEqualTo(ENTITY_URL)) + .willReturn( + ok() + .withHeader(SET_COOKIE_HEADER_KEY, "SimpleKey=SimpleValue") + .withHeader(SET_COOKIE_HEADER_KEY, "KeyWithoutAValue") + .withHeader(SET_COOKIE_HEADER_KEY, "KeyWithCommaValue=Value, which contains a comma") + .withHeader( + SET_COOKIE_HEADER_KEY, + "MultiValueKey1=Value 1, with comma; MultiValueKey2=Value 2 ; MultiValueKey3;"))); + + final Person person = new Person(); + person.setUserName("usr"); + + final UpdateRequestBuilder builder = new UpdateRequestBuilder<>(SERVICE_PATH, person, "People"); + + final ModificationResponse response = builder.execute(destination); + + final List responseHeaders = + ImmutableList.copyOf(response.getResponseHeaders().get(SET_COOKIE_HEADER_KEY)); + + assertThat(responseHeaders) + .containsExactly( + "SimpleKey=SimpleValue", + "KeyWithoutAValue", + "KeyWithCommaValue=Value, which contains a comma", + "MultiValueKey1=Value 1, with comma; MultiValueKey2=Value 2 ; MultiValueKey3;"); + } + + @Test + void testMultipleHeadersWithSameKey() + { + final Person person = new Person(); + person.setUserName("usr"); + + // patch request with 2 cookie headers with the same key + final UpdateRequestBuilder builder = + new UpdateRequestBuilder<>(SERVICE_PATH, person, "People") + .withHeader(SET_COOKIE_HEADER_KEY, "foo") + .withHeader(SET_COOKIE_HEADER_KEY, "bar"); + + executeMultipleHeadersWithSameKey(builder); + } + + @Test + void testMultipleHeadersWithSameKeyAtOnce() + { + final Person person = new Person(); + person.setUserName("usr"); + + // patch request with 2 cookie headers with the same key + final UpdateRequestBuilder builder = + new UpdateRequestBuilder<>(SERVICE_PATH, person, "People") + .withHeaders(ImmutableMap.of(SET_COOKIE_HEADER_KEY, "foo")) + .withHeaders(ImmutableMap.of(SET_COOKIE_HEADER_KEY, "bar")); + + executeMultipleHeadersWithSameKey(builder); + } + + public void executeMultipleHeadersWithSameKey( UpdateRequestBuilder builder ) + { + // With Apache HttpClient 5 the CSRF token HEAD request is issued by the client-level + // CsrfTokenInterceptor, which does not propagate the request's custom headers. The custom + // headers are only sent on the actual (PATCH) request, so that is where we assert them. + stubFor(head(anyUrl()).willReturn(ok().withHeader(X_CSRF_TOKEN_HEADER_KEY, X_CSRF_TOKEN_HEADER_VALUE))); + + // the server will check for the cookie headers when receiving the patch request + stubFor( + patch(urlEqualTo(ENTITY_URL)) + .withHeader(SET_COOKIE_HEADER_KEY, equalTo("foo")) + .withHeader(SET_COOKIE_HEADER_KEY, equalTo("bar")) + .willReturn(ok())); + + builder.execute(destination); + + // check that both header values with the same key are sent on the request + verify( + patchRequestedFor(urlEqualTo(ENTITY_URL)) + .withHeader(SET_COOKIE_HEADER_KEY, equalTo("foo")) + .withHeader(SET_COOKIE_HEADER_KEY, equalTo("bar"))); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/HttpResponseEvaluationTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/HttpResponseEvaluationTest.java new file mode 100644 index 0000000000..9bedcf186a --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/HttpResponseEvaluationTest.java @@ -0,0 +1,204 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import static org.apache.hc.core5.http.ContentType.APPLICATION_JSON; +import static org.apache.hc.core5.http.ContentType.TEXT_PLAIN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.Map; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.io.entity.InputStreamEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.api.AfterEach; +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 lombok.SneakyThrows; + +public class HttpResponseEvaluationTest +{ + private static final Destination DESTINATION = DefaultHttpDestination.builder("foo").build(); + + private HttpClient httpClient; + private BasicClassicHttpResponse httpResponse; + private InputStreamEntity httpEntity; + private InputStream inputStream; + + @SneakyThrows + void mockHttpResponse( final ContentType contentType, final String payload ) + { + httpClient = mock(HttpClient.class); + inputStream = spy(new ByteArrayInputStream(payload.getBytes(UTF_8))); + httpEntity = spy(new InputStreamEntity(inputStream, contentType)); + httpResponse = spy(new BasicClassicHttpResponse(HttpStatus.SC_OK, "OK")); + httpResponse.setEntity(httpEntity); + ApacheHttpClient5Accessor.setHttpClientFactory(destination -> httpClient); + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(httpResponse); + } + + @AfterEach + void teardown() + { + ApacheHttpClient5Accessor.setHttpClientFactory(null); + ApacheHttpClient5Accessor.setHttpClientCache(null); + } + + @SneakyThrows + @Test + void testCreate() + { + mockHttpResponse(APPLICATION_JSON, "{}"); + + final ModificationResponse result = + new CreateRequestBuilder<>("/path", new TestEntity(), "TestEntitySet").execute(DESTINATION); + + verify(httpClient, times(1)).executeOpen(isNull(), any(), isNull()); + verify(httpResponse, times(1)).getEntity(); + verify(httpEntity, times(1)).writeTo(any(OutputStream.class)); + verify(inputStream, times(2)).close(); + + assertThat(result.getResponseStatusCode()).isEqualTo(200); + } + + @SneakyThrows + @Test + void testUpdate() + { + mockHttpResponse(APPLICATION_JSON, "{}"); + + final TestEntity testEntity = new TestEntity(); + testEntity.setId("id"); + + final ModificationResponse result = + new UpdateRequestBuilder<>("/path", testEntity, "TestEntitySet").execute(DESTINATION); + + verify(httpClient, times(1)).executeOpen(isNull(), any(), isNull()); + verify(httpResponse, times(1)).getEntity(); + verify(httpEntity, times(1)).writeTo(any(OutputStream.class)); + verify(inputStream, times(2)).close(); + + assertThat(result.getResponseStatusCode()).isEqualTo(200); + } + + @SneakyThrows + @Test + void testDelete() + { + mockHttpResponse(APPLICATION_JSON, "{}"); + + final ModificationResponse result = + new DeleteRequestBuilder<>("/path", new TestEntity(), "TestEntitySet").execute(DESTINATION); + + verify(httpClient, times(1)).executeOpen(isNull(), any(), isNull()); + verify(httpResponse, times(1)).getEntity(); + verify(httpEntity, times(1)).writeTo(any(OutputStream.class)); + verify(inputStream, times(2)).close(); + + assertThat(result.getResponseStatusCode()).isEqualTo(200); + } + + @SneakyThrows + @Test + void testReadAll() + { + mockHttpResponse(APPLICATION_JSON, "{\"value\": []}"); + + final List result = + new GetAllRequestBuilder<>("/path", TestEntity.class, "TestEntitySet").execute(DESTINATION); + + assertThat(result).isNotNull(); + + verify(httpClient, times(1)).executeOpen(isNull(), any(), isNull()); + verify(httpResponse, times(1)).getEntity(); + verify(httpEntity, times(1)).writeTo(any(OutputStream.class)); + verify(inputStream, times(2)).close(); + } + + @SneakyThrows + @Test + void testReadByKey() + { + mockHttpResponse(APPLICATION_JSON, "{}"); + + final TestEntity result = + new GetByKeyRequestBuilder<>("/path", TestEntity.class, Map.of("id", "foo"), "TestEntitySet") + .execute(DESTINATION); + + assertThat(result).isNotNull(); + + verify(httpClient, times(1)).executeOpen(isNull(), any(), isNull()); + verify(httpResponse, times(1)).getEntity(); + verify(httpEntity, times(1)).writeTo(any(OutputStream.class)); + verify(inputStream, times(2)).close(); + } + + @SneakyThrows + @Test + void testReadCount() + { + mockHttpResponse(TEXT_PLAIN, "42"); + + final Long result = new CountRequestBuilder<>("/path", TestEntity.class, "TestEntitySet").execute(DESTINATION); + + assertThat(result).isNotNull(); + + verify(httpClient, times(1)).executeOpen(isNull(), any(), isNull()); + verify(httpResponse, times(1)).getEntity(); + verify(httpEntity, times(1)).writeTo(any(OutputStream.class)); + verify(inputStream, times(2)).close(); + } + + @SneakyThrows + @Test + void testAction() + { + mockHttpResponse(APPLICATION_JSON, "{}"); + + final ActionResponseSingle result = + new SingleValueActionRequestBuilder<>("/path", "ActionName", TestEntity.class).execute(DESTINATION); + + assertThat(result).isNotNull(); + + verify(httpClient, times(1)).executeOpen(isNull(), any(), isNull()); + verify(httpResponse, times(1)).getEntity(); + verify(httpEntity, times(1)).writeTo(any(OutputStream.class)); + verify(inputStream, times(2)).close(); + } + + @SneakyThrows + @Test + void testFunction() + { + mockHttpResponse(APPLICATION_JSON, "{\"value\":[]}"); + + final List result = + new CollectionValueFunctionRequestBuilder<>("/path", "FunctionName", TestEntity.class).execute(DESTINATION); + + assertThat(result).isNotNull(); + + verify(httpClient, times(1)).executeOpen(isNull(), any(), isNull()); + verify(httpResponse, times(1)).getEntity(); + verify(httpEntity, times(1)).writeTo(any(OutputStream.class)); + verify(inputStream, times(2)).close(); + } + + // count function action +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/ModificationResponseTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/ModificationResponseTest.java new file mode 100644 index 0000000000..d6d26218c7 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/ModificationResponseTest.java @@ -0,0 +1,164 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import static com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol.V4; +import static org.assertj.core.api.Assertions.assertThat; +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 static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.Header; +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.apache.hc.core5.http.message.BasicHeader; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultGeneric; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestUpdate; +import com.sap.cloud.sdk.datamodel.odata.client.request.UpdateStrategy; +import com.sap.cloud.sdk.result.ElementName; + +import io.vavr.control.Option; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.SneakyThrows; +import lombok.ToString; + +class ModificationResponseTest +{ + @Data + @NoArgsConstructor + @AllArgsConstructor + @ToString( doNotUseGetters = true, callSuper = true ) + @EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) + @JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) + @JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) + @JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) + public static class TestObject extends VdmEntity + { + @Getter + private final String odataType = "TestObject"; + + @Getter + private final Class type = TestObject.class; + + @ElementName( "foo" ) + private String name; + + @Nonnull + @Override + protected String getEntityCollection() + { + return odataType; + } + } + + @Test + void testEntityResponse() + { + final TestObject inputObject = new TestObject(); + + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + when(request.getProtocol()).thenReturn(V4); + + final Header[] responseHeaders = { new BasicHeader("fizz", "buzz"), new BasicHeader("fizz", "fuzz, bizz=1") }; + + final ClassicHttpResponse response = mock(ClassicHttpResponse.class); + doReturn(responseHeaders).when(response).getHeaders(); + doReturn(responseHeaders).when(response).getHeaders("ETag"); + doReturn(new StringEntity("{\"foo\":\"bar\"}", UTF_8)).when(response).getEntity(); + doReturn(HttpStatus.SC_OK).when(response).getCode(); + + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, response); + final ModificationResponse modification = ModificationResponse.of(result, inputObject); + + assertThat(modification).isNotNull(); + assertThat(modification.getResponseStatusCode()).isEqualTo(HttpStatus.SC_OK); + assertThat(modification.getRequestEntity()).isSameAs(inputObject); + + assertThat(modification.getResponseEntity().get()).isNotSameAs(inputObject); + assertThat(modification.getResponseEntity().get()).isEqualTo(new TestObject("bar")); + assertThat(modification.getModifiedEntity()).isEqualTo(new TestObject("bar")); + + assertThat(modification.getResponseHeaders()).containsOnlyKeys("fizz"); + assertThat(modification.getResponseHeaders().get("fizz")).containsExactly("buzz", "fuzz, bizz=1"); + } + + @Test + void testEmptyResponse() + { + final TestObject inputObject = new TestObject(); + + final ODataRequestGeneric request = mock(ODataRequestGeneric.class); + when(request.getProtocol()).thenReturn(V4); + + final ClassicHttpResponse response = mock(ClassicHttpResponse.class); + doReturn(new Header[0]).when(response).getHeaders(); + doReturn(new Header[0]).when(response).getHeaders("ETag"); + doReturn(new StringEntity("", UTF_8)).when(response).getEntity(); + doReturn(HttpStatus.SC_NO_CONTENT).when(response).getCode(); + + final ODataRequestResultGeneric result = new ODataRequestResultGeneric(request, response); + final ModificationResponse modification = ModificationResponse.of(result, inputObject); + + assertThat(modification).isNotNull(); + assertThat(modification.getResponseStatusCode()).isEqualTo(HttpStatus.SC_NO_CONTENT); + assertThat(modification.getRequestEntity()).isSameAs(inputObject); + assertThat(modification.getModifiedEntity()).isNotSameAs(inputObject); + assertThat(modification.getModifiedEntity()).isEqualTo(inputObject); + assertThat(modification.getResponseHeaders()).isEmpty(); + } + + @SneakyThrows + @Test + void testResponseIsOnlyEvaluatedOnce() + { + final TestObject inputObject = new TestObject(); + + final ClassicHttpResponse response = spy(new BasicClassicHttpResponse(HttpStatus.SC_OK, "OK")); + response.setHeaders(new Header[0]); + response.setEntity(new StringEntity("{\"foo\":\"bar\"}", UTF_8)); + + final ODataEntityKey key = ODataEntityKey.of(Map.of("id", 42), V4); + final ODataRequestUpdate request = + new ODataRequestUpdate("service/path", "EntitySet", key, "{}", UpdateStrategy.REPLACE_WITH_PUT, null, V4); + + final HttpClient httpClient = mock(HttpClient.class); + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response); + + final ODataRequestResultGeneric result = request.execute(httpClient); + final ModificationResponse modification = ModificationResponse.of(result, inputObject); + + modification.getResponseEntity(); + final Option responseEntity = modification.getResponseEntity(); + assertThat(responseEntity).isNotNull(); + + modification.getModifiedEntity(); + final TestObject modifiedEntity = modification.getModifiedEntity(); + assertThat(modifiedEntity).isNotNull(); + + verify(response, times(1)).getEntity(); + verify(httpClient, times(1)).executeOpen(isNull(), any(), isNull()); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/NestedEntityTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/NestedEntityTest.java new file mode 100644 index 0000000000..8edbdbb9a3 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/NestedEntityTest.java @@ -0,0 +1,413 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +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.head; +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.patch; +import static com.github.tomakehurst.wiremock.client.WireMock.patchRequestedFor; +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.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +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.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.assertThatNoException; + +import javax.annotation.Nonnull; + +import org.apache.hc.core5.http.HttpHeaders; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestCount; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestCreate; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestDelete; +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.ODataRequestUpdate; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.PlanItem; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Trip; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; + +@WireMockTest +class NestedEntityTest +{ + private static final WireMockConfiguration WIREMOCK_CONFIGURATION = wireMockConfig().dynamicPort(); + + private DefaultHttpDestination destination; + private DefaultTrippinService service; + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + service = new DefaultTrippinService().withServicePath("/TripPinServiceRW"); + } + + @Test + void testGetSingleNestedFriend() + { + final Person personByKey = Person.builder().userName("russellwhyte").build(); + + final ODataRequestReadByKey getAll = + service.forEntity(personByKey).navigateTo(Person.TO_BEST_FRIEND).get().toRequest(); + + assertThat(getAll.getRelativeUri()).hasToString("/TripPinServiceRW/People('russellwhyte')/BestFriend"); + } + + @Test + void testGetSingleNestedNestedNestedFriend() + { + final Person personByKey = Person.builder().userName("russellwhyte").build(); + + final ODataRequestReadByKey getAll = + service + .forEntity(personByKey) + .navigateTo(Person.TO_BEST_FRIEND) + .navigateTo(Person.TO_BEST_FRIEND) + .navigateTo(Person.TO_BEST_FRIEND) + .get() + .toRequest(); + + assertThat(getAll.getRelativeUri()) + .hasToString("/TripPinServiceRW/People('russellwhyte')/BestFriend/BestFriend/BestFriend"); + } + + @Test + void testDeleteSingleNestedFriend() + { + final Person personByKey = Person.builder().userName("russellwhyte").build(); + + final ODataRequestDelete delete = + service.forEntity(personByKey).navigateTo(Person.TO_BEST_FRIEND).delete().toRequest(); + + assertThat(delete.getRelativeUri()).hasToString("/TripPinServiceRW/People('russellwhyte')/BestFriend"); + } + + @Test + void testUpdateSingleNestedFriend() + { + final Person friend = new Person(); + friend.setLastName("Jobs"); + + final Person personByKey = Person.builder().userName("russellwhyte").build(); + + final ODataRequestUpdate update = + service.forEntity(personByKey).navigateTo(Person.TO_BEST_FRIEND).update(friend).toRequest(); + + assertThat(update.getRelativeUri()).hasToString("/TripPinServiceRW/People('russellwhyte')/BestFriend"); + } + + @Test + void testCreateNestedTrip() + { + final Trip customTrip = new Trip(); + final Person personByKey = Person.builder().userName("russellwhyte").build(); + + final ODataRequestCreate create = + service.forEntity(personByKey).navigateTo(Person.TO_TRIPS).create(customTrip).toRequest(); + + assertThat(create.getRelativeUri()).hasToString("/TripPinServiceRW/People('russellwhyte')/Trips"); + } + + @Test + void testGetAllNestedTrips() + { + final Person personByKey = Person.builder().userName("russellwhyte").build(); + + final ODataRequestRead getAll = service.forEntity(personByKey).navigateTo(Person.TO_TRIPS).getAll().toRequest(); + + assertThat(getAll.getRelativeUri()).hasToString("/TripPinServiceRW/People('russellwhyte')/Trips"); + } + + @Test + void testDeleteBestFriend() + { + final Person personByKey = Person.builder().userName("russellwhyte").build(); + + final ODataRequestDelete delete = + service + .forEntity(personByKey) + .navigateTo(Person.TO_BEST_FRIEND) + .delete() + .matchAnyVersionIdentifier() + .toRequest(); + + assertThat(delete.getRelativeUri()).hasToString("/TripPinServiceRW/People('russellwhyte')/BestFriend"); + } + + @Test + void testUpdateBestFriend() + { + final Person oldPerson = Person.builder().userName("oldMan").build(); + final Person newPerson = Person.builder().userName("youngMan").build(); + + final ODataRequestUpdate update = + service + .forEntity(oldPerson) + .navigateTo(Person.TO_BEST_FRIEND) + .update(newPerson) + .matchAnyVersionIdentifier() + .toRequest(); + + assertThat(update.getRelativeUri()).hasToString("/TripPinServiceRW/People('oldMan')/BestFriend"); + } + + @Test + void testCountNestedTrip() + { + final Person personByKey = Person.builder().userName("russellwhyte").build(); + + final ODataRequestCount count = service.forEntity(personByKey).navigateTo(Person.TO_TRIPS).count().toRequest(); + + assertThat(count.getRelativeUri()).hasToString("/TripPinServiceRW/People('russellwhyte')/Trips/$count"); + } + + @Test + void testCountNestedTripToRequestIsStable() + { + final Person personByKey = Person.builder().userName("russellwhyte").build(); + final CountRequestBuilder countBuilder = + service.forEntity(personByKey).navigateTo(Person.TO_TRIPS).count(); + + assertThat(countBuilder.toRequest().getRelativeUri()) + .hasToString("/TripPinServiceRW/People('russellwhyte')/Trips/$count"); + assertThat(countBuilder.toRequest().getRelativeUri()) + .hasToString("/TripPinServiceRW/People('russellwhyte')/Trips/$count"); + } + + @Test + void testGetByKeyNestedPlanItem() + { + final Person personByKey = Person.builder().userName("russellwhyte").build(); + final Trip tripByKey = Trip.builder().tripId(1003).build(); + + final GetAllRequestBuilder builder = + service + .forEntity(personByKey) + .navigateTo(Person.TO_TRIPS) + .forEntity(tripByKey) + .navigateTo(Trip.TO_PLAN_ITEMS) + .getAll(); + + final ODataRequestRead getAll = builder.toRequest(); + + assertThat(getAll.getRelativeUri()) + .hasToString("/TripPinServiceRW/People('russellwhyte')/Trips(1003)/PlanItems"); + } + + @Test + void testCreateNestedPlanItem() + { + final PlanItem customPlanItem = new PlanItem(); + final Person personByKey = Person.builder().userName("russellwhyte").build(); + final Trip tripByKey = Trip.builder().tripId(1003).build(); + + final CreateRequestBuilder createdPlanItem = + service + .forEntity(personByKey) + .navigateTo(Person.TO_TRIPS) + .forEntity(tripByKey) + .navigateTo(Trip.TO_PLAN_ITEMS) + .create(customPlanItem) + .withHeader("header", "value") + .withQueryParameter("query", "parameter"); + + // check client + final ODataRequestCreate requestCreate = createdPlanItem.toRequest(); + assertThat(requestCreate.getRelativeUri()).hasQuery("query=parameter"); + assertThat(requestCreate.getRelativeUri()) + .hasPath("/TripPinServiceRW/People('russellwhyte')/Trips(1003)/PlanItems"); + + // check VDM execution + final String headPath = "/TripPinServiceRW"; + final String postPath = "/TripPinServiceRW/People('russellwhyte')/Trips(1003)/PlanItems"; + + stubFor(head(urlPathEqualTo(headPath)).willReturn(ok().withHeader("x-csrf-token", "foo"))); + stubFor( + post(urlPathEqualTo(postPath)) + .withQueryParam("query", equalTo("parameter")) + .withHeader("header", equalTo("value")) + .willReturn(okJson("{}"))); + + final ModificationResponse createResponse = createdPlanItem.execute(destination); + assertThat(createResponse).isNotNull(); + assertThat(createResponse.getResponseStatusCode()).isEqualTo(200); + verify(1, postRequestedFor(urlPathEqualTo(postPath))); + } + + @Test + void testDeleteEntityWithEtag() + { + final Person personByKey = Person.builder().userName("russellwhyte").build(); + + personByKey.setVersionIdentifier("foobar"); + + final String headPath = service.getServicePath(); + final String deletePath = headPath + "/People('russellwhyte')"; + + stubFor(head(urlPathEqualTo(headPath)).willReturn(ok().withHeader("x-csrf-token", "foo"))); + stubFor(delete(urlPathEqualTo(deletePath)).willReturn(okJson("{}"))); + + final DeleteRequestBuilder delete = service.forEntity(personByKey).delete(); + + assertThat(delete.toRequest().getRelativeUri()).hasToString(deletePath); + + delete.execute(destination); + + verify(deleteRequestedFor(urlEqualTo(deletePath)).withHeader(HttpHeaders.IF_MATCH, equalTo("foobar"))); + } + + @Test + void testUpdateEntityWithEtag() + { + final Person personByKey = Person.builder().userName("russellwhyte").build(); + + personByKey.setVersionIdentifier("foobar"); + + final String headPath = service.getServicePath(); + final String updatePath = headPath + "/People('russellwhyte')"; + + stubFor(head(urlPathEqualTo(headPath)).willReturn(ok().withHeader("x-csrf-token", "foo"))); + stubFor(patch(urlPathEqualTo(updatePath)).willReturn(okJson("{}"))); + + final UpdateRequestBuilder update = service.forEntity(personByKey).update(personByKey); + + assertThat(update.toRequest().getRelativeUri()).hasToString(updatePath); + + update.execute(destination); + + verify(patchRequestedFor(urlEqualTo(updatePath)).withHeader(HttpHeaders.IF_MATCH, equalTo("foobar"))); + } + + @Test + void testTypeRestrictionForVdmEntitySet() + { + final EntityWithoutEntitySet entityWithoutEntitySet = new EntityWithoutEntitySet(); + final EntityWithEntitySet entityWithEntitySet = new EntityWithEntitySet(); + + assertThatIllegalStateException().isThrownBy(() -> service.forEntity(entityWithoutEntitySet)); + assertThatNoException().isThrownBy(() -> service.forEntity(entityWithEntitySet)); + + assertThatNoException().isThrownBy(() -> { + service + .forEntity(entityWithEntitySet) + .navigateTo(EntityWithEntitySet.NAVIGATION_PROPERTY) + .forEntity(entityWithoutEntitySet); + }); + } + + private static class EntityWithoutEntitySet extends VdmEntity + { + @Nonnull + @Override + protected String getEntityCollection() + { + return "outside-of-entity-set"; + } + + @Nonnull + @Override + public String getOdataType() + { + return getEntityCollection(); + } + + @Nonnull + @Override + public Class getType() + { + return EntityWithoutEntitySet.class; + } + } + + private static class EntityWithEntitySet extends VdmEntity implements VdmEntitySet + { + public static final NavigationProperty.Collection NAVIGATION_PROPERTY = + new NavigationProperty.Collection<>(EntityWithEntitySet.class, "Navigations", EntityWithoutEntitySet.class); + + @Nonnull + @Override + protected String getEntityCollection() + { + return "outside-of-entity-set"; + } + + @Nonnull + @Override + public String getOdataType() + { + return getEntityCollection(); + } + + @Nonnull + @Override + public Class getType() + { + return EntityWithEntitySet.class; + } + } + + /* Commented out since otherwise this leads to a traceability mapping failure + @Disabled( "Use this to run and check against the reference OData service." ) + @Test + void integrationTestCreateNestedEntity() + throws IOException + { + final HttpDestination destination = TripPinUtility.getDestinationRW(); + + final String RANDOM_CONFIRMATION_CODE = "" + new Random().nextLong(); + + final PlanItem somePlanItem = + PlanItem + .builder() + .confirmationCode(RANDOM_CONFIRMATION_CODE) + .duration(Duration.ofHours(3)) + .endsAt(LocalDate.of(2014, Month.JUNE, 1).atStartOfDay().atOffset(ZoneOffset.UTC)) + .startsAt(LocalDate.of(2014, Month.MAY, 1).atStartOfDay().atOffset(ZoneOffset.UTC)) + .build(); + + final Person personByKey = Person.builder().userName("russellwhyte").build(); + final Trip tripByKey = Trip.builder().tripId(1003L).build(); + + // Create new item + final ModificationResponse createResult = + service + .forEntity(personByKey) + .navigateTo(Person.TO_TRIPS) + .forEntity(tripByKey) + .navigateTo(Trip.PLAN_ITEMS) + .create(somePlanItem) + .execute(destination); + + // Assert on positive feedback + assertThat(createResult).isNotNull(); + + // Query newly created item + final Person personFound = + service + .getPersonsByKey("russellwhyte") + .select( + Person.TO_TRIPS.filter(Trip.TRIP_ID.equalTo(1003L)).select( + Trip.PLAN_ITEMS.select(PlanItem.CONFIRMATION_CODE))) + .execute(destination); + + // Find item in response + assertThat(personFound).isNotNull(); + assertThat(personFound.getTrips()).isNotEmpty().anySatisfy( + trip -> assertThat(trip.getPlanItems()) + .anySatisfy(item -> assertThat(item.getConfirmationCode()).isEqualTo(RANDOM_CONFIRMATION_CODE))); + } + */ +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/ODataRequestImplTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/ODataRequestImplTest.java new file mode 100644 index 0000000000..ae3b3b20e8 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/ODataRequestImplTest.java @@ -0,0 +1,54 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Trip; + +class ODataRequestImplTest +{ + @Test + void testRequestWithoutNestedRequests() + { + final NavigationPropertyCollectionQuery request = + NavigationPropertyCollectionQuery.ofSubQuery("ASDF"); + + request + .select(Person.FIRST_NAME, Person.LAST_NAME) + .filter(Person.FIRST_NAME.contains("foo")) + .top(5) + .skip(3) + .orderBy(Person.USER_NAME.desc()); + + final String expected = + "$select=FirstName,LastName;$filter=contains(FirstName,'foo');$top=5;$skip=3;$orderby=UserName%20desc"; + final String unencodedExpected = + "$select=FirstName,LastName;$filter=contains(FirstName,'foo');$top=5;$skip=3;$orderby=UserName desc"; + + assertThat(request.getEncodedQueryString()).isEqualTo(expected); + assertThat(request.getQueryString()).isEqualTo(unencodedExpected); + } + + @Test + void testRequestWithNestedRequests() + { + final NavigationPropertyCollectionQuery request = + NavigationPropertyCollectionQuery.ofSubQuery("ASDF"); + + request + .select(Person.FIRST_NAME, Person.LAST_NAME) + .select(Person.TO_BEST_FRIEND.select(Person.TO_TRIPS.select(Trip.DESCRIPTION).top(10))) + .filter(Person.FIRST_NAME.contains("foo")); + + final String expected = + "$select=FirstName,LastName;$expand=BestFriend($expand=Trips($select=Description;$top=10));$filter=contains(FirstName,'foo')"; + final String unencodedExpected = + "$select=FirstName,LastName;$expand=BestFriend($expand=Trips($select=Description;$top=10));$filter=contains(FirstName,'foo')"; + + assertThat(request.getEncodedQueryString()).isEqualTo(expected); + assertThat(request.getQueryString()).isEqualTo(unencodedExpected); + } + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/ODataV4BatchRequestUnitTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/ODataV4BatchRequestUnitTest.java new file mode 100644 index 0000000000..229d4f2d88 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/ODataV4BatchRequestUnitTest.java @@ -0,0 +1,300 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +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.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.okForContentType; +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.stubFor; +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 com.sap.cloud.sdk.datamodel.odatav4.TestUtility.readResourceFileCrlf; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import javax.annotation.Nonnull; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.InputStreamEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +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.ApacheHttpClient5FactoryBuilder; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestBatch; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultMultipartGeneric; + +import lombok.SneakyThrows; + +@WireMockTest +class ODataV4BatchRequestUnitTest +{ + private static final TestEntityService SERVICE = new TestEntityService() + { + }; + + private static final WireMockConfiguration WIREMOCK_CONFIGURATION = wireMockConfig().dynamicPort(); + private static final String X_CSRF_TOKEN_HEADER_KEY = "x-csrf-token"; + private static final String X_CSRF_TOKEN_HEADER_FETCH_VALUE = "fetch"; + private static final String X_CSRF_TOKEN_HEADER_VALUE = "awesome-csrf-token"; + private static final String REQUEST_URL_BATCH = "/$batch"; + + private static final String REQUEST_BODY = + readResourceFileCrlf(ODataV4BatchRequestUnitTest.class, "BatchReadsAndWritesSuccessRequest.txt"); + private static final String RESPONSE_BODY = + readResourceFileCrlf(ODataV4BatchRequestUnitTest.class, "BatchReadsAndWritesSuccessResponse.txt"); + + private static final TestEntity ENTITY_CREATE = new TestEntity(); + private static final TestEntity ENTITY_UPDATE = TestEntity.builder().id("upd").build(); + private static final TestEntity ENTITY_DELETE = TestEntity.builder().id("del").build(); + + private static final GetAllRequestBuilder READ_ALL = SERVICE.getTestEntities(); + private static final GetByKeyRequestBuilder READ_BY_KEY = SERVICE.getTestEntitiesByKey("foobar"); + private static final CreateRequestBuilder CREATE = SERVICE.createTestEntity(ENTITY_CREATE); + private static final UpdateRequestBuilder UPDATE = SERVICE.updateTestEntity(ENTITY_UPDATE); + private static final DeleteRequestBuilder DELETE = SERVICE.deleteTestEntity(ENTITY_DELETE); + private static final SingleValueFunctionRequestBuilder FUNC_SINGLE = SERVICE.functionSingleResult(); + private static final CollectionValueFunctionRequestBuilder FUNC_MULTIPLE = SERVICE.functionMultipleResult(); + private static final SingleValueActionRequestBuilder ACT_SINGLE = SERVICE.actionSingleResult(); + private static final CollectionValueActionRequestBuilder ACT_MULTIPLE = SERVICE.actionMultipleResult(); + private static final int MAX_PARALLEL_CONNECTIONS = 10; + + private Destination destination; + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + // Mock CSRF token handling + + stubFor( + head(urlEqualTo("/")) + .withHeader(X_CSRF_TOKEN_HEADER_KEY, equalTo(X_CSRF_TOKEN_HEADER_FETCH_VALUE)) + .willReturn(ok().withHeader(X_CSRF_TOKEN_HEADER_KEY, X_CSRF_TOKEN_HEADER_VALUE))); + + // Mock OData Batch response + final String contentType = "multipart/mixed; boundary=batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef"; + stubFor(post(urlEqualTo(REQUEST_URL_BATCH)).willReturn(okForContentType(contentType, RESPONSE_BODY))); + + ApacheHttpClient5Accessor + .setHttpClientFactory( + new ApacheHttpClient5FactoryBuilder() + .maxConnectionsTotal(MAX_PARALLEL_CONNECTIONS) + .maxConnectionsPerRoute(MAX_PARALLEL_CONNECTIONS) + .build()); + } + + @AfterEach + void teardown() + { + ApacheHttpClient5Accessor.setHttpClientFactory(null); + } + + @Test + void testAllOperations() + { + for( int i = 0; i < MAX_PARALLEL_CONNECTIONS * 2; i++ ) { + // execute batched requests + final BatchResponse response = + SERVICE + .batch() + .addReadOperations(READ_ALL, READ_BY_KEY) + .addChangeset(CREATE, UPDATE, DELETE) + .addReadOperations(FUNC_SINGLE, FUNC_MULTIPLE) + .addChangeset(ACT_SINGLE, ACT_MULTIPLE) + .execute(destination); + + // assertion on response parsing + final List readAllResult = response.getReadResult(READ_ALL); + assertThat(readAllResult).isNotEmpty(); + + final TestEntity readByKeyResult = response.getReadResult(READ_BY_KEY); + assertThat(readByKeyResult).isNotNull(); + + final ModificationResponse createResult = response.getModificationResult(CREATE); + assertThat(createResult).isNotNull(); + assertThat(createResult.getResponseEntity()).isNotNull().isNotEqualTo(ENTITY_CREATE); + assertThat(createResult.getResponseHeaders().get("Location")) + .containsExactly("https://localhost/service/TestEntities('new')"); + + final ModificationResponse updateResult = response.getModificationResult(UPDATE); + assertThat(updateResult).isNotNull(); + assertThat(updateResult.getResponseEntity()).isNotNull().isNotEqualTo(ENTITY_CREATE); + + final ModificationResponse deleteResult = response.getModificationResult(DELETE); + assertThat(readByKeyResult).isNotNull(); + assertThat(deleteResult.getResponseEntity()).isEmpty(); + + final Integer functionSingleResult = response.getReadResult(FUNC_SINGLE); + assertThat(functionSingleResult).isNotNull(); + + final List functionMultipleResult = response.getReadResult(FUNC_MULTIPLE); + assertThat(functionMultipleResult).isNotEmpty(); + + final ActionResponseSingle actionSingleResult = response.getModificationResult(ACT_SINGLE); + assertThat(actionSingleResult).isNotNull(); + assertThat(actionSingleResult.getResponseResult()).isNotEmpty(); + + final ActionResponseCollection actionMultipleResult = response.getModificationResult(ACT_MULTIPLE); + assertThat(actionMultipleResult).isNotNull(); + assertThat(actionMultipleResult.getResponseResult()).isNotEmpty(); + assertThat(actionMultipleResult.getResponseResult().get()).isNotEmpty(); + + } + // Verify request body + final String requestContentType = "multipart/mixed;boundary=batch_00000000-0000-0000-0000-000000000001"; + verify( + postRequestedFor(urlEqualTo(REQUEST_URL_BATCH)) + .withHeader("Content-Type", equalTo(requestContentType)) + .withHeader(X_CSRF_TOKEN_HEADER_KEY, equalTo(X_CSRF_TOKEN_HEADER_VALUE)) + .withRequestBody(equalTo(REQUEST_BODY))); + + verify( + MAX_PARALLEL_CONNECTIONS * 2, + headRequestedFor(urlEqualTo("/")) + .withHeader(X_CSRF_TOKEN_HEADER_KEY, equalTo(X_CSRF_TOKEN_HEADER_FETCH_VALUE))); + } + + @Test + void testIdenticalOperations() + { + final CreateRequestBuilder identicalCreate = SERVICE.createTestEntity(ENTITY_CREATE); + + final BatchResponse response = + SERVICE + .batch() + .addReadOperations(READ_ALL, READ_BY_KEY) + .addChangeset(CREATE, identicalCreate) + .execute(destination); + + final ModificationResponse result1 = response.getModificationResult(CREATE); + final ModificationResponse result2 = response.getModificationResult(identicalCreate); + + assertThat(result1.getModifiedEntity().getId()).isEqualTo("new"); + assertThat(result2.getModifiedEntity().getId()).isEqualTo("updated"); + + verify( + 1, + headRequestedFor(urlEqualTo("/")) + .withHeader(X_CSRF_TOKEN_HEADER_KEY, equalTo(X_CSRF_TOKEN_HEADER_FETCH_VALUE))); + } + + @Test + void testLowLevelToHighLevel() + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(destination); + + final BatchRequestBuilder batchBuilder = + SERVICE + .batch() + .addReadOperations(READ_ALL, READ_BY_KEY) + .addChangeset(CREATE, UPDATE, DELETE) + .addReadOperations(FUNC_SINGLE, FUNC_MULTIPLE) + .addChangeset(ACT_SINGLE, ACT_MULTIPLE); + + final ODataRequestBatch lowLevelRequest = batchBuilder.toRequest(); + + final ODataRequestResultMultipartGeneric lowLevelResult = lowLevelRequest.execute(httpClient); + + //Consumer transition from generic result object to typed result + final BatchResponse convertedHighLevelResult = BatchResponse.of(lowLevelResult, batchBuilder); + final BatchResponse expectedHighLevelResult = batchBuilder.execute(destination); + + assertThat(expectedHighLevelResult.getReadResult(READ_ALL)) + .isEqualTo(convertedHighLevelResult.getReadResult(READ_ALL)); + assertThat(expectedHighLevelResult.getReadResult(READ_BY_KEY)) + .isEqualTo(convertedHighLevelResult.getReadResult(READ_BY_KEY)); + assertThat(expectedHighLevelResult.getModificationResult(CREATE).getModifiedEntity()) + .isEqualTo(convertedHighLevelResult.getModificationResult(CREATE).getModifiedEntity()); + assertThat(expectedHighLevelResult.getModificationResult(CREATE).getResponseHeaders()) + .isEqualTo(convertedHighLevelResult.getModificationResult(CREATE).getResponseHeaders()); + assertThat(expectedHighLevelResult.getModificationResult(UPDATE).getModifiedEntity()) + .isEqualTo(convertedHighLevelResult.getModificationResult(UPDATE).getModifiedEntity()); + assertThat(expectedHighLevelResult.getModificationResult(UPDATE).getResponseHeaders()) + .isEqualTo(convertedHighLevelResult.getModificationResult(UPDATE).getResponseHeaders()); + assertThat(expectedHighLevelResult.getModificationResult(DELETE).getModifiedEntity()) + .isEqualTo(convertedHighLevelResult.getModificationResult(DELETE).getModifiedEntity()); + assertThat(expectedHighLevelResult.getModificationResult(DELETE).getResponseHeaders()) + .isEqualTo(convertedHighLevelResult.getModificationResult(DELETE).getResponseHeaders()); + assertThat(expectedHighLevelResult.getReadResult(FUNC_SINGLE)) + .isEqualTo(convertedHighLevelResult.getReadResult(FUNC_SINGLE)); + assertThat(expectedHighLevelResult.getReadResult(FUNC_MULTIPLE)) + .isEqualTo(convertedHighLevelResult.getReadResult(FUNC_MULTIPLE)); + assertThat(expectedHighLevelResult.getModificationResult(ACT_SINGLE).getResponseResult()) + .isEqualTo(convertedHighLevelResult.getModificationResult(ACT_SINGLE).getResponseResult()); + assertThat(expectedHighLevelResult.getModificationResult(ACT_SINGLE).getResponseHeaders()) + .isEqualTo(convertedHighLevelResult.getModificationResult(ACT_SINGLE).getResponseHeaders()); + assertThat(expectedHighLevelResult.getModificationResult(ACT_MULTIPLE).getResponseResult()) + .isEqualTo(convertedHighLevelResult.getModificationResult(ACT_MULTIPLE).getResponseResult()); + assertThat(expectedHighLevelResult.getModificationResult(ACT_MULTIPLE).getResponseHeaders()) + .isEqualTo(convertedHighLevelResult.getModificationResult(ACT_MULTIPLE).getResponseHeaders()); + } + + @Test + @SneakyThrows + void testBatchOpenConnection() + { + final int N = MAX_PARALLEL_CONNECTIONS * 2; + final List inputStreams = new ArrayList<>(); + + final HttpDestination dest = DefaultHttpDestination.builder("").build(); + final HttpClient httpClient = mock(HttpClient.class); + when(httpClient.executeOpen(isNull(), argThat(req -> req instanceof HttpPost), isNull())).thenAnswer(args -> { + final BasicClassicHttpResponse response = new BasicClassicHttpResponse(200, "ok"); + final InputStream inStream = mock(InputStream.class); + inputStreams.add(inStream); + response.setEntity(new InputStreamEntity(inStream, ContentType.APPLICATION_JSON)); + return response; + }); + + // configure test setup + ApacheHttpClient5Accessor.setHttpClientFactory(( anyDestination ) -> httpClient); + + // TEST: invoke many batch request each spawning an InputStream + for( int i = 0; i < N; i++ ) { + SERVICE.batch().addReadOperations(READ_ALL).execute(dest); + } + + // ASSERTION: input streams are loaded but never fully consumed + assertThat(inputStreams).hasSize(N); + for( final InputStream inStream : inputStreams ) { + Mockito.verify(inStream, never()).close(); + } + + // TEST: invoke one batch request using try-with-resource + try( BatchResponse result = SERVICE.batch().addReadOperations(READ_ALL).execute(dest) ) { + + assertThat(result).isNotNull(); + } + + // ASSERTION: input stream is fully consumed + Mockito.verify(inputStreams.get(N), times(1)).close(); + + // reset test setup + ApacheHttpClient5Accessor.setHttpClientFactory(null); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/ODatav4BatchConnectionTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/ODatav4BatchConnectionTest.java new file mode 100644 index 0000000000..ac3768fc7a --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/ODatav4BatchConnectionTest.java @@ -0,0 +1,177 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +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.okForContentType; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.sap.cloud.sdk.datamodel.odatav4.TestUtility.readResourceFileCrlf; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import javax.annotation.Nonnull; + +import org.apache.hc.core5.http.ConnectionRequestTimeoutException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.github.tomakehurst.wiremock.matching.UrlPattern; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5FactoryBuilder; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataConnectionException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataServiceErrorException; + +@WireMockTest +class ODatav4BatchConnectionTest +{ + private static final String RESPONSE_CONTENT_TYPE = + "multipart/mixed; boundary=batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef"; + private static final String RESPONSE_WITH_CHANGESET = + readResourceFileCrlf(ODatav4BatchConnectionTest.class, "BatchResponseWithChangeset.txt"); + private static final String RESPONSE_WITHOUT_CHANGESET = + readResourceFileCrlf(ODatav4BatchConnectionTest.class, "BatchResponseWithoutChangeset.txt"); + private static final String RESPONSE_WITH_ERROR = + readResourceFileCrlf(ODatav4BatchConnectionTest.class, "BatchResponseWithError.txt"); + private static final TestEntityService SERVICE = new TestEntityService() + { + }; + private static final int MAX_PARALLEL_CONNECTIONS = 10; + + private DefaultHttpDestination destination; + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + stubFor(head(UrlPattern.ANY).willReturn(noContent())); + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + ApacheHttpClient5Accessor + .setHttpClientFactory( + new ApacheHttpClient5FactoryBuilder() + .maxConnectionsTotal(MAX_PARALLEL_CONNECTIONS) + .maxConnectionsPerRoute(MAX_PARALLEL_CONNECTIONS) + .build()); + } + + @AfterEach + void teardown() + { + ApacheHttpClient5Accessor.setHttpClientFactory(null); + } + + @Test + void testNoConnectionTimeoutWhenBatchResponseContainsNoChangeset() + { + stubFor(post(UrlPattern.ANY).willReturn(okForContentType(RESPONSE_CONTENT_TYPE, RESPONSE_WITHOUT_CHANGESET))); + + for( int i = 0; i < MAX_PARALLEL_CONNECTIONS * 2; i++ ) { + final GetAllRequestBuilder READ_ALL = SERVICE.getTestEntities(); + final GetByKeyRequestBuilder READ_BY_KEY = SERVICE.getTestEntitiesByKey("foobar"); + final BatchResponse batchResponse = + SERVICE.batch().addReadOperations(READ_ALL, READ_BY_KEY).execute(destination); + + final List readAllResult = batchResponse.getReadResult(READ_ALL); + assertThat(readAllResult).isNotEmpty(); + + final TestEntity readByKeyResult = batchResponse.getReadResult(READ_BY_KEY); + assertThat(readByKeyResult).isNotNull(); + } + } + + @Test + void testNoConnectionTimeoutWhenBatchResponseContainsChangeset() + { + stubFor(post(UrlPattern.ANY).willReturn(okForContentType(RESPONSE_CONTENT_TYPE, RESPONSE_WITH_CHANGESET))); + + for( int i = 0; i < MAX_PARALLEL_CONNECTIONS * 2; i++ ) { + final GetAllRequestBuilder READ_ALL = SERVICE.getTestEntities(); + final GetByKeyRequestBuilder READ_BY_KEY = SERVICE.getTestEntitiesByKey("foobar"); + final CreateRequestBuilder CREATE = SERVICE.createTestEntity(new TestEntity()); + final UpdateRequestBuilder UPDATE = + SERVICE.updateTestEntity(TestEntity.builder().id("upd").build()); + final DeleteRequestBuilder DELETE = + SERVICE.deleteTestEntity(TestEntity.builder().id("del").build()); + final BatchResponse batchResponse = + SERVICE + .batch() + .addReadOperations(READ_ALL, READ_BY_KEY) + .addChangeset(CREATE, UPDATE, DELETE) + .execute(destination); + + final List readAllResult = batchResponse.getReadResult(READ_ALL); + assertThat(readAllResult).isNotEmpty(); + + final TestEntity readByKeyResult = batchResponse.getReadResult(READ_BY_KEY); + assertThat(readByKeyResult).isNotNull(); + + final ModificationResponse createResult = batchResponse.getModificationResult(CREATE); + assertThat(createResult).isNotNull(); + + final ModificationResponse updateResult = batchResponse.getModificationResult(UPDATE); + assertThat(updateResult).isNotNull(); + assertThat(updateResult.getResponseEntity()).isNotNull(); + + final ModificationResponse deleteResult = batchResponse.getModificationResult(DELETE); + assertThat(deleteResult).isNotNull(); + assertThat(deleteResult.getResponseEntity()).isEmpty(); + } + } + + @Test + void testNoConnectionTimeoutWhenBatchResponseContainsError() + { + stubFor(post(UrlPattern.ANY).willReturn(okForContentType(RESPONSE_CONTENT_TYPE, RESPONSE_WITH_ERROR))); + + for( int i = 0; i < MAX_PARALLEL_CONNECTIONS * 2; i++ ) { + final GetAllRequestBuilder READ_ALL = SERVICE.getTestEntities(); + final GetByKeyRequestBuilder READ_BY_KEY = SERVICE.getTestEntitiesByKey("foobar"); + final DeleteRequestBuilder DELETE = + SERVICE.deleteTestEntity(TestEntity.builder().id("del").build()); + final BatchResponse batchResponse = + SERVICE.batch().addReadOperations(READ_ALL, READ_BY_KEY).addChangeset(DELETE).execute(destination); + + final List readAllResult = batchResponse.getReadResult(READ_ALL); + assertThat(readAllResult).isNotEmpty(); + + final TestEntity readByKeyResult = batchResponse.getReadResult(READ_BY_KEY); + assertThat(readByKeyResult).isNotNull(); + + assertThatExceptionOfType(ODataServiceErrorException.class) + .isThrownBy(() -> batchResponse.getModificationResult(DELETE)) + .satisfies(e -> assertThat(e.getHttpCode()).isEqualTo(400)); + } + } + + @Test + @Timeout( value = 300_000L, unit = TimeUnit.MILLISECONDS ) + @Disabled( "Test triggers a ConnectionPoolTimeoutException. Use it only to manually verify behaviour." ) + void testConnectionTimeoutWhenBatchResponseIsNotConsumedFully() + { + stubFor(post(UrlPattern.ANY).willReturn(okForContentType(RESPONSE_CONTENT_TYPE, RESPONSE_WITHOUT_CHANGESET))); + + final GetAllRequestBuilder READ_ALL = SERVICE.getTestEntities(); + final GetByKeyRequestBuilder READ_BY_KEY = SERVICE.getTestEntitiesByKey("foobar"); + + for( int i = 0; i < MAX_PARALLEL_CONNECTIONS; ++i ) { + assertThatNoException() + .isThrownBy(() -> SERVICE.batch().addReadOperations(READ_ALL, READ_BY_KEY).execute(destination)); + } + + assertThatThrownBy(() -> SERVICE.batch().addReadOperations(READ_ALL, READ_BY_KEY).execute(destination)) + .isInstanceOf(ODataConnectionException.class) + .hasRootCauseExactlyInstanceOf(ConnectionRequestTimeoutException.class) + .hasMessageContaining( + "Please execute your request with try-with-resources to ensure resources are properly closed."); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/RequestBuilderEtagParsingTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/RequestBuilderEtagParsingTest.java new file mode 100644 index 0000000000..7184b8ddc2 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/RequestBuilderEtagParsingTest.java @@ -0,0 +1,371 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +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.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5CacheBuilder; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestinationProperties; + +import lombok.Getter; +import lombok.SneakyThrows; + +class RequestBuilderEtagParsingTest +{ + private static final String SERVICE_PATH = "/service"; + private static final String ENTITY_COLLECTION = "/EntityCollection"; + private static final HttpDestinationProperties DESTINATION = DefaultHttpDestination.builder("http://1").build(); + private static final String FUNCTION_NAME = "FUNCTION_NAME"; + private static final String ETAG_HEAD = "foo"; + private static final String ETAG_BODY = "bar"; + private static final Map HEADERS_NONE = Collections.emptyMap(); + private static final Map HEADERS_WITH_ETAG = Collections.singletonMap("Etag", ETAG_HEAD); + + private HttpClient httpClient; + + @BeforeEach + void setupConnectivity() + { + httpClient = mock(HttpClient.class); + ApacheHttpClient5Accessor + .setHttpClientCache(new ApacheHttpClient5CacheBuilder().durationInMilliseconds(0).build()); + ApacheHttpClient5Accessor.setHttpClientFactory(dest -> { + assertThat(dest).isSameAs(DESTINATION); + return httpClient; + }); + } + + @AfterEach + void teardownConnectivity() + { + ApacheHttpClient5Accessor + .setHttpClientCache(new ApacheHttpClient5CacheBuilder().duration(Duration.ofMinutes(5)).build()); + ApacheHttpClient5Accessor.setHttpClientFactory(null); + } + + /** + * ETag via HTTP header. + */ + @SneakyThrows + @Test + void testParseNoEtagGetByKey() + { + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response("{}", HEADERS_NONE)); + + final TestEntity entity = + new GetByKeyRequestBuilder<>( + SERVICE_PATH, + TestEntity.class, + Collections.singletonMap("key", "val"), + ENTITY_COLLECTION).execute(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).isEmpty(); + } + + @SneakyThrows + @Test + void testParseNoEtagFunctionAction() + { + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response(String.format("{%s:{}}", FUNCTION_NAME), HEADERS_NONE)); + + // GET + TestEntity entity = + new SingleValueFunctionRequestBuilder<>(SERVICE_PATH, FUNCTION_NAME, TestEntity.class).execute(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).isEmpty(); + + // POST + entity = + new SingleValueActionRequestBuilder<>(SERVICE_PATH, FUNCTION_NAME, TestEntity.class) + .execute(DESTINATION) + .getResponseResult() + .get(); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).isEmpty(); + } + + @SneakyThrows + @Test + void testParseNoEtagUpdate() + { + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response("{}", HEADERS_NONE)); + + final TestEntity requestEntity = new TestEntity(); + + final ModificationResponse result = + new UpdateRequestBuilder<>(SERVICE_PATH, requestEntity, ENTITY_COLLECTION).execute(DESTINATION); + + assertThat(result).isNotNull(); + assertThat(result.getModifiedEntity()).isNotNull(); + assertThat(result.getResponseEntity()).isNotNull().isNotEmpty(); + assertThat(result.getRequestEntity()).isNotNull().isSameAs(requestEntity); + + assertThat(result.getModifiedEntity().getVersionIdentifier()).isEmpty(); + assertThat(result.getResponseEntity().get().getVersionIdentifier()).isEmpty(); + assertThat(result.getRequestEntity().getVersionIdentifier()).isEmpty(); + } + + @SneakyThrows + @Test + void testParseHeaderEtagGetByKey() + { + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response("{}", HEADERS_WITH_ETAG)); + + final TestEntity entity = + new GetByKeyRequestBuilder<>( + SERVICE_PATH, + TestEntity.class, + Collections.singletonMap("key", "val"), + ENTITY_COLLECTION).execute(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_HEAD); + } + + @SneakyThrows + @Test + void testParseHeaderEtagFunctionAction() + { + when(httpClient.executeOpen(isNull(), any(), isNull())) + .thenReturn(response(String.format("{%s:{}}", FUNCTION_NAME), HEADERS_WITH_ETAG)); + + // GET + TestEntity entity = + new SingleValueFunctionRequestBuilder<>(SERVICE_PATH, FUNCTION_NAME, TestEntity.class).execute(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_HEAD); + + // POST + entity = + new SingleValueActionRequestBuilder<>(SERVICE_PATH, FUNCTION_NAME, TestEntity.class) + .execute(DESTINATION) + .getResponseResult() + .get(); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_HEAD); + } + + @SneakyThrows + @Test + void testParseHeaderEtagUpdate() + { + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response("{}", HEADERS_WITH_ETAG)); + + final TestEntity requestEntity = new TestEntity(); + + final ModificationResponse result = + new UpdateRequestBuilder<>(SERVICE_PATH, requestEntity, ENTITY_COLLECTION).execute(DESTINATION); + + assertThat(result).isNotNull(); + assertThat(result.getModifiedEntity()).isNotNull(); + assertThat(result.getResponseEntity()).isNotNull().isNotEmpty(); + assertThat(result.getRequestEntity()).isNotNull().isSameAs(requestEntity); + + assertThat(result.getModifiedEntity().getVersionIdentifier()).containsExactly(ETAG_HEAD); + assertThat(result.getResponseEntity().get().getVersionIdentifier()).containsExactly(ETAG_HEAD); + assertThat(result.getRequestEntity().getVersionIdentifier()).isEmpty(); + } + + /** + * ETag via HTTP payload. + */ + @SneakyThrows + @Test + void testParsePayloadEtagGetAll() + { + final String payload = String.format("{value:[{@etag:\"%s\"}]}", ETAG_BODY); + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response(payload, HEADERS_NONE)); + + final List entities = + new GetAllRequestBuilder<>(SERVICE_PATH, TestEntity.class, ENTITY_COLLECTION).execute(DESTINATION); + + assertThat(entities).isNotNull().hasSize(1); + assertThat(entities.get(0).getVersionIdentifier()).containsExactly(ETAG_BODY); + } + + @SneakyThrows + @Test + void testParsePayloadEtagGetByKey() + { + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response(String.format("{@etag:\"%s\"}", ETAG_BODY), HEADERS_NONE)); + + final TestEntity entity = + new GetByKeyRequestBuilder<>( + SERVICE_PATH, + TestEntity.class, + Collections.singletonMap("key", "val"), + ENTITY_COLLECTION).execute(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_BODY); + } + + @SneakyThrows + @Test + void testParsePayloadEtagFunctionAction() + { + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response(String.format("{@etag:\"%s\"}", ETAG_BODY), HEADERS_NONE)); + + // GET + TestEntity entity = + new SingleValueFunctionRequestBuilder<>(SERVICE_PATH, FUNCTION_NAME, TestEntity.class).execute(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_BODY); + + // POST + entity = + new SingleValueActionRequestBuilder<>(SERVICE_PATH, FUNCTION_NAME, TestEntity.class) + .execute(DESTINATION) + .getResponseResult() + .get(); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_BODY); + } + + @SneakyThrows + @Test + void testParsePayloadEtagUpdate() + { + when(httpClient.executeOpen(isNull(), any(), isNull())).thenReturn(response(String.format("{@etag:\"%s\"}", ETAG_BODY), HEADERS_NONE)); + + final TestEntity requestEntity = new TestEntity(); + + final ModificationResponse result = + new UpdateRequestBuilder<>(SERVICE_PATH, requestEntity, ENTITY_COLLECTION).execute(DESTINATION); + + assertThat(result).isNotNull(); + assertThat(result.getModifiedEntity()).isNotNull(); + assertThat(result.getResponseEntity()).isNotNull().isNotEmpty(); + assertThat(result.getRequestEntity()).isNotNull().isSameAs(requestEntity); + + assertThat(result.getModifiedEntity().getVersionIdentifier()).containsExactly(ETAG_BODY); + assertThat(result.getResponseEntity().get().getVersionIdentifier()).containsExactly(ETAG_BODY); + assertThat(result.getRequestEntity().getVersionIdentifier()).isEmpty(); + } + + /** + * ETag via HTTP header + HTTP payload. + */ + @SneakyThrows + @Test + void testParseHeaderAndPayloadEtagGetByKey() + { + when(httpClient.executeOpen(isNull(), any(), isNull())) + .thenReturn(response(String.format("{@etag:\"%s\"}", ETAG_BODY), HEADERS_WITH_ETAG)); + + final TestEntity entity = + new GetByKeyRequestBuilder<>( + SERVICE_PATH, + TestEntity.class, + Collections.singletonMap("key", "val"), + ENTITY_COLLECTION).execute(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_HEAD); + } + + @SneakyThrows + @Test + void testParseHeaderAndPayloadEtagFunctionAction() + { + when(httpClient.executeOpen(isNull(), any(), isNull())) + .thenReturn(response(String.format("{%s:{@etag:\"%s\"}}", FUNCTION_NAME, ETAG_BODY), HEADERS_WITH_ETAG)); + + // GET + TestEntity entity = + new SingleValueFunctionRequestBuilder<>(SERVICE_PATH, FUNCTION_NAME, TestEntity.class).execute(DESTINATION); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_HEAD); + + // POST + entity = + new SingleValueActionRequestBuilder<>(SERVICE_PATH, FUNCTION_NAME, TestEntity.class) + .execute(DESTINATION) + .getResponseResult() + .get(); + + assertThat(entity).isNotNull(); + assertThat(entity.getVersionIdentifier()).containsExactly(ETAG_HEAD); + } + + @SneakyThrows + @Test + void testParseHeaderAndPayloadEtagUpdate() + { + when(httpClient.executeOpen(isNull(), any(), isNull())) + .thenReturn(response(String.format("{@etag:\"%s\"}", ETAG_BODY), HEADERS_WITH_ETAG)); + + final TestEntity requestEntity = new TestEntity(); + + final ModificationResponse result = + new UpdateRequestBuilder<>(SERVICE_PATH, requestEntity, ENTITY_COLLECTION).execute(DESTINATION); + + assertThat(result).isNotNull(); + assertThat(result.getModifiedEntity()).isNotNull(); + assertThat(result.getResponseEntity()).isNotNull().isNotEmpty(); + assertThat(result.getRequestEntity()).isNotNull().isSameAs(requestEntity); + + assertThat(result.getModifiedEntity().getVersionIdentifier()).containsExactly(ETAG_HEAD); + assertThat(result.getResponseEntity().get().getVersionIdentifier()).containsExactly(ETAG_HEAD); + assertThat(result.getRequestEntity().getVersionIdentifier()).isEmpty(); + } + + /** + * HELPER METHODS. + */ + @JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) + @JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) + @JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) + public static class TestEntity extends VdmEntity + { + @Getter + private final String entityCollection = ENTITY_COLLECTION; + @Getter + private final Class type = TestEntity.class; + @Getter + private final String odataType = "Example.TestEntity"; + } + + @Nonnull + static ClassicHttpResponse response( @Nonnull final String payload, @Nonnull final Map headers ) + { + final BasicClassicHttpResponse result = new BasicClassicHttpResponse(200, "Ok"); + result.setEntity(new StringEntity(payload, StandardCharsets.UTF_8)); + result.setHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString()); + headers.forEach(result::setHeader); + return result; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/SingleValueActionRequestBuilderTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/SingleValueActionRequestBuilderTest.java new file mode 100644 index 0000000000..ec7ea3ff62 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/SingleValueActionRequestBuilderTest.java @@ -0,0 +1,316 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +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.equalToJson; +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.ok; +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.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.datamodel.odatav4.TestUtility; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@WireMockTest +class SingleValueActionRequestBuilderTest +{ + private static final String DEFAULT_SERVICE_PATH = "/odata/default"; + private static final String ODATA_ACTION = "TestAction"; + + private DefaultHttpDestination destination; + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + stubFor(head(anyUrl()).willReturn(ok())); + } + + private static String readResourceFile( final String resourceFileName ) + { + return TestUtility.readResourceFile(SingleValueActionRequestBuilderTest.class, resourceFileName); + } + + @Data + @EqualsAndHashCode( callSuper = true ) + @NoArgsConstructor + @AllArgsConstructor + @JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) + @JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) + @JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) + public static class TestEntity extends VdmEntity + { + @Getter + private final String odataType = "com.sap.cloud.sdk.TestEntity"; + + @Getter + private final String entityCollection = "EntityCollection"; + + @Getter + private final Class type = TestEntity.class; + + @Override + protected String getDefaultServicePath() + { + return DEFAULT_SERVICE_PATH; + } + + @ElementName( "Name" ) + private String name; + } + + @Builder + @Data + @NoArgsConstructor + @AllArgsConstructor + @ToString( doNotUseGetters = true, callSuper = true ) + @EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) + @JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) + @JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) + @JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) + public static class ComplexType extends VdmComplex + { + + @Getter + private final String odataType = "com.sap.cloud.sdk.ComplexType"; + + @Getter + private final Class type = ComplexType.class; + + @ElementName( "City" ) + private String city; + + @ElementName( "Country" ) + private String country; + } + + @Test + void testFunctionQueryWithParametersAndNoResponse() + { + final String actionRequestUrl = String.format("%s/%s", DEFAULT_SERVICE_PATH, ODATA_ACTION); + final String actionRequestBody = readResourceFile("ActionParametersRequestBody.json"); + + stubFor(post(urlPathEqualTo(actionRequestUrl)).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("durationParameter", Duration.ofHours(8)); + actionParameters + .put("dateTimeOffsetParameter", OffsetDateTime.of(2020, 3, 12, 5, 2, 30, 0, ZoneOffset.of("Z"))); + actionParameters.put("timeOfDayParameter", LocalTime.of(13, 03, 39, 999000000)); + + final SingleValueActionRequestBuilder sut = + new SingleValueActionRequestBuilder<>(DEFAULT_SERVICE_PATH, ODATA_ACTION, actionParameters, Void.class); + assertThat(sut.toRequest().getRelativeUri()).hasToString(actionRequestUrl); + final ActionResponseSingle actualResponse = sut.execute(destination); + + verify( + postRequestedFor(urlEqualTo(DEFAULT_SERVICE_PATH + '/' + ODATA_ACTION)) + + .withHeader("Content-Type", equalTo("application/json")) + + .withRequestBody(equalToJson(actionRequestBody))); + + verify( + 1, + headRequestedFor(urlEqualTo(DEFAULT_SERVICE_PATH + "/")).withHeader("x-csrf-token", equalTo("fetch"))); + + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getResponseResult()).isEmpty(); + assertThat(actualResponse.getResponseStatusCode()).isEqualTo(204); + } + + @Test + void testActionWithNoParameterAndPrimitiveResponse() + { + final String actionRequestUrl = String.format("%s/%s", DEFAULT_SERVICE_PATH, ODATA_ACTION); + final String actionRequestBody = readResourceFile("ActionNoParametersRequestBody.json"); + + stubFor(post(urlPathEqualTo(actionRequestUrl)).willReturn(okJson("{" + "\"value\" : 3.14" + "}"))); + + final SingleValueActionRequestBuilder sut = + new SingleValueActionRequestBuilder<>(DEFAULT_SERVICE_PATH, ODATA_ACTION, Float.class); + final ActionResponseSingle actualResponse = sut.execute(destination); + + verify( + postRequestedFor(urlEqualTo(actionRequestUrl)) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(equalToJson(actionRequestBody))); + + verify( + 1, + headRequestedFor(urlEqualTo(DEFAULT_SERVICE_PATH + "/")).withHeader("x-csrf-token", equalTo("fetch"))); + + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getResponseResult().get()).isEqualTo(3.14f); + assertThat(actualResponse.getResponseStatusCode()).isEqualTo(200); + } + + @Test + void testActionWithNoParameterAndStringResponse() + { + final String actionRequestUrl = String.format("%s/%s", DEFAULT_SERVICE_PATH, ODATA_ACTION); + final String actionRequestBody = readResourceFile("ActionNoParametersRequestBody.json"); + + stubFor(post(urlPathEqualTo(actionRequestUrl)).willReturn(okJson("{" + "\"value\" : \"Works\"" + "}"))); + + final SingleValueActionRequestBuilder sut = + new SingleValueActionRequestBuilder<>(DEFAULT_SERVICE_PATH, ODATA_ACTION, String.class); + final ActionResponseSingle actualResponse = sut.execute(destination); + + verify( + postRequestedFor(urlEqualTo(DEFAULT_SERVICE_PATH + '/' + ODATA_ACTION)) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(equalToJson(actionRequestBody))); + + verify( + 1, + headRequestedFor(urlEqualTo(DEFAULT_SERVICE_PATH + "/")).withHeader("x-csrf-token", equalTo("fetch"))); + + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getResponseResult().get()).isEqualTo("Works"); + assertThat(actualResponse.getResponseStatusCode()).isEqualTo(200); + + } + + @Test + void testActionWithEntityParameterAndEntityResponse() + { + final String actionRequestUrl = String.format("%s/%s", DEFAULT_SERVICE_PATH, ODATA_ACTION); + final String actionRequestBody = readResourceFile("ActionEntityRequestBody.json"); + + stubFor(post(urlPathEqualTo(actionRequestUrl)).willReturn(okJson("{ \"Name\" : \"Tester\" }"))); + + final Map actionParameters = new LinkedHashMap<>(); + final TestEntity testEntity = new TestEntity("Tester"); + actionParameters.put("entityParameter", testEntity); + + final SingleValueActionRequestBuilder sut = + new SingleValueActionRequestBuilder<>( + DEFAULT_SERVICE_PATH, + ODATA_ACTION, + actionParameters, + TestEntity.class); + final ActionResponseSingle actualResponse = sut.execute(destination); + + verify( + postRequestedFor(urlEqualTo(actionRequestUrl)) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(equalToJson(actionRequestBody))); + + verify( + 1, + headRequestedFor(urlEqualTo(DEFAULT_SERVICE_PATH + "/")).withHeader("x-csrf-token", equalTo("fetch"))); + + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getResponseResult().get().getName()).isEqualTo("Tester"); + assertThat(actualResponse.getResponseStatusCode()).isEqualTo(200); + } + + @Test + void testActionWithEntityParameterNull() + { + final String actionRequestUrl = String.format("%s/%s", DEFAULT_SERVICE_PATH, ODATA_ACTION); + final String actionRequestBody = readResourceFile("ActionNullEntityRequestBody.json"); + + stubFor(post(urlPathEqualTo(actionRequestUrl)).willReturn(okJson("{ \"Name\" : \"Tester\" }"))); + + final Map actionParameters = new LinkedHashMap<>(); + actionParameters.put("entityParameter", null); + + final SingleValueActionRequestBuilder sut = + new SingleValueActionRequestBuilder<>( + DEFAULT_SERVICE_PATH, + ODATA_ACTION, + actionParameters, + TestEntity.class); + final ActionResponseSingle actualResponse = sut.execute(destination); + + verify( + postRequestedFor(urlEqualTo(actionRequestUrl)) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(equalToJson(actionRequestBody))); + + verify( + 1, + headRequestedFor(urlEqualTo(DEFAULT_SERVICE_PATH + "/")).withHeader("x-csrf-token", equalTo("fetch"))); + + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getResponseResult().get().getName()).isEqualTo("Tester"); + assertThat(actualResponse.getResponseStatusCode()).isEqualTo(200); + } + + @Test + void testActionWithComplexTypeParameterComplexTypeResponse() + { + final String actionRequestUrl = String.format("%s/%s", DEFAULT_SERVICE_PATH, ODATA_ACTION); + final String actionRequestBody = readResourceFile("ActionComplexTypeRequestBody.json"); + + stubFor( + post(urlPathEqualTo(actionRequestUrl)) + .willReturn(okJson("{\"City\": \"Stockholm\",\"Country\": \"Sweden\"}"))); + + final Map actionParameters = new LinkedHashMap<>(); + final ComplexType testComplexType = new ComplexType("Stockholm", "Sweden"); + actionParameters.put("complexEntity", testComplexType); + + final SingleValueActionRequestBuilder sut = + new SingleValueActionRequestBuilder<>( + DEFAULT_SERVICE_PATH, + ODATA_ACTION, + actionParameters, + ComplexType.class); + final ActionResponseSingle actualResponse = sut.execute(destination); + + verify( + postRequestedFor(urlEqualTo(actionRequestUrl)) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(equalToJson(actionRequestBody))); + + verify( + 1, + headRequestedFor(urlEqualTo(DEFAULT_SERVICE_PATH + "/")).withHeader("x-csrf-token", equalTo("fetch"))); + + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getResponseResult().get().getCity()).isEqualTo("Stockholm"); + assertThat(actualResponse.getResponseResult().get().getCountry()).isEqualTo("Sweden"); + assertThat(actualResponse.getResponseStatusCode()).isEqualTo(200); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/SingleValueFunctionRequestBuilderTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/SingleValueFunctionRequestBuilderTest.java new file mode 100644 index 0000000000..64419a3c6a --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/SingleValueFunctionRequestBuilderTest.java @@ -0,0 +1,192 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +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.time.Duration; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +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.request.ODataFunctionParameters; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@WireMockTest +class SingleValueFunctionRequestBuilderTest +{ + private static final String DEFAULT_SERVICE_PATH = "/odata/default"; + private static final String ODATA_FUNCTION = "TestFunction"; + private static final ODataFunctionParameters FUNCTION_PARAMETERS; + private static final Map FUNCTION_PARAMETER_MAP = new HashMap<>(); + + static { + FUNCTION_PARAMETER_MAP.put("stringParameter", "test"); + FUNCTION_PARAMETER_MAP.put("booleanParameter", true); + FUNCTION_PARAMETER_MAP.put("integerParameter", 9000); + FUNCTION_PARAMETER_MAP.put("decimalParameter", 3.14); + FUNCTION_PARAMETER_MAP.put("durationParameter", Duration.ofHours(8)); + FUNCTION_PARAMETER_MAP.put("dateTimeParameter", LocalDateTime.of(2019, 12, 25, 8, 0, 0)); + + FUNCTION_PARAMETERS = ODataFunctionParameters.of(FUNCTION_PARAMETER_MAP, ODataProtocol.V4); + } + + private static final String ODATA_FUNCTION_PARAMETER_WITH_SPECIAL_CHARACTER = "(stringParameter='t''est')"; + + private DefaultHttpDestination destination; + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + } + + @Data + @EqualsAndHashCode( callSuper = true ) + @NoArgsConstructor + @AllArgsConstructor + @JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) + @JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) + @JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) + public static class TestEntity extends VdmEntity + { + @Getter + private final String odataType = "com.sap.cloud.sdk.TestEntity"; + + @Getter + private final String entityCollection = "EntityCollection"; + + @Getter + private final Class type = TestEntity.class; + + @Override + protected String getDefaultServicePath() + { + return DEFAULT_SERVICE_PATH; + } + + @ElementName( "Name" ) + @SerializedName( "Name" ) + @JsonProperty( "Name" ) + private String name; + } + + @Test + void testFunctionQueryWithoutParameters() + { + final SingleValueFunctionRequestBuilder requestBuilder = + new SingleValueFunctionRequestBuilder<>(DEFAULT_SERVICE_PATH, ODATA_FUNCTION, Void.class); + + assertThat(requestBuilder.toRequest().getRelativeUri()) + .hasToString(DEFAULT_SERVICE_PATH + '/' + ODATA_FUNCTION + "()"); + } + + @Test + void testFunctionQueryWithMethodParameters() + { + final SingleValueFunctionRequestBuilder requestBuilder = + new SingleValueFunctionRequestBuilder<>( + DEFAULT_SERVICE_PATH, + ODATA_FUNCTION, + FUNCTION_PARAMETER_MAP, + Void.class); + + assertThat(requestBuilder.toRequest().getRelativeUri()) + .hasToString(DEFAULT_SERVICE_PATH + '/' + ODATA_FUNCTION + FUNCTION_PARAMETERS.toEncodedString()); + } + + @Test + void testFunctionQueryWithSpecialCharactersInMethodParameters() + { + final ODataFunctionParameters parameters = + new ODataFunctionParameters(ODataProtocol.V4).addParameter("stringParameter", "t'est"); + final ODataResourcePath functionPath = ODataResourcePath.of(ODATA_FUNCTION, parameters); + + final SingleValueFunctionRequestBuilder requestBuilder = + new SingleValueFunctionRequestBuilder<>(DEFAULT_SERVICE_PATH, functionPath, Void.class); + + assertThat(requestBuilder.toRequest().getRelativeUri()) + .hasToString(DEFAULT_SERVICE_PATH + '/' + ODATA_FUNCTION + ODATA_FUNCTION_PARAMETER_WITH_SPECIAL_CHARACTER); + } + + @Test + void testFunctionQueryWithMapParameters() + { + final SingleValueFunctionRequestBuilder requestBuilder = + new SingleValueFunctionRequestBuilder<>( + DEFAULT_SERVICE_PATH, + ODATA_FUNCTION, + FUNCTION_PARAMETER_MAP, + Void.class); + assertThat(requestBuilder.toRequest().getRelativeUri()) + .hasToString(DEFAULT_SERVICE_PATH + '/' + ODATA_FUNCTION + FUNCTION_PARAMETERS.toEncodedString()); + } + + @Test + void testFunctionWithPrimitiveResponse() + { + stubFor( + get(urlPathEqualTo(DEFAULT_SERVICE_PATH + '/' + ODATA_FUNCTION + "()")) + .willReturn(okJson("{" + "\"value\" : 3.14" + "}"))); + + final SingleValueFunctionRequestBuilder requestBuilder = + new SingleValueFunctionRequestBuilder<>(DEFAULT_SERVICE_PATH, ODATA_FUNCTION, Float.class); + + final Float actualResponse = requestBuilder.execute(destination); + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse).isEqualTo(3.14f); + } + + @Test + void testFunctionWithStringResponse() + { + stubFor( + get(urlPathEqualTo(DEFAULT_SERVICE_PATH + '/' + ODATA_FUNCTION + "()")) + .willReturn(okJson("{" + "\"value\" : \"Works\"" + "}"))); + + final SingleValueFunctionRequestBuilder requestBuilder = + new SingleValueFunctionRequestBuilder<>(DEFAULT_SERVICE_PATH, ODATA_FUNCTION, String.class); + + final String actualResponse = requestBuilder.execute(destination); + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse).isEqualTo("Works"); + } + + @Test + void testFunctionWithEntityResponse() + { + stubFor( + get(urlPathEqualTo(DEFAULT_SERVICE_PATH + '/' + ODATA_FUNCTION + "()")) + .willReturn(okJson("{" + "\"Name\" : \"Tester\"" + "}"))); + + final SingleValueFunctionRequestBuilder requestBuilder = + new SingleValueFunctionRequestBuilder<>(DEFAULT_SERVICE_PATH, ODATA_FUNCTION, TestEntity.class); + + final TestEntity actualResponse = requestBuilder.execute(destination); + assertThat(actualResponse).isNotNull(); + assertThat(actualResponse.getName()).isEqualTo("Tester"); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/TestEntity.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/TestEntity.java new file mode 100644 index 0000000000..6510520482 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/TestEntity.java @@ -0,0 +1,86 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.common.collect.ImmutableMap; +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@ToString( doNotUseGetters = true, callSuper = true ) +@EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) +@JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) +@JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) +@JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) +public class TestEntity extends VdmEntity +{ + static final String SERVICE_PATH = "/odata/default"; + + @Getter + private final String odataType = "TestEntity"; + + @Nonnull + @Override + protected String getEntityCollection() + { + return "EntityCollection"; + } + + @Nonnull + @Override + public Class getType() + { + return TestEntity.class; + } + + @Override + protected String getDefaultServicePath() + { + return SERVICE_PATH; + } + + @Nullable + @ElementName( "id" ) + private String id; + + public final static SimpleProperty.String ID = new SimpleProperty.String<>(TestEntity.class, "id"); + + @Nonnull + @Override + protected ODataEntityKey getKey() + { + final ODataEntityKey key = super.getKey(); + key.addKeyProperty("id", getId()); + return key; + } + + public void setId( @Nullable final String id ) + { + rememberChangedField("id", this.id); + this.id = id; + } + + @Nonnull + @Override + protected Map toMapOfFields() + { + return ImmutableMap. builder().putAll(super.toMapOfFields()).put("id", getId()).build(); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/TestEntityService.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/TestEntityService.java new file mode 100644 index 0000000000..c297bdf5a4 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/TestEntityService.java @@ -0,0 +1,108 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import javax.annotation.Nonnull; + +import com.google.common.collect.ImmutableMap; + +interface TestEntityService +{ + String SERVICE_PATH = "/"; + + @Nonnull + default GetAllRequestBuilder getTestEntities() + { + return new GetAllRequestBuilder<>(SERVICE_PATH, TestEntity.class, "EntityCollection"); + } + + @Nonnull + default CountRequestBuilder countTestEntities() + { + return new CountRequestBuilder<>(SERVICE_PATH, TestEntity.class, "EntityCollection"); + } + + @Nonnull + default GetByKeyRequestBuilder getTestEntitiesByKey( @Nonnull final String key ) + { + return new GetByKeyRequestBuilder<>( + SERVICE_PATH, + TestEntity.class, + ImmutableMap.of("key", key), + "EntityCollection"); + } + + @Nonnull + default CreateRequestBuilder createTestEntity( @Nonnull final TestEntity testEntity ) + { + return new CreateRequestBuilder<>(SERVICE_PATH, testEntity, "EntityCollection"); + } + + @Nonnull + default DeleteRequestBuilder deleteTestEntity( @Nonnull final TestEntity testEntity ) + { + return new DeleteRequestBuilder<>(SERVICE_PATH, testEntity, "EntityCollection"); + } + + @Nonnull + default UpdateRequestBuilder updateTestEntity( @Nonnull final TestEntity testEntity ) + { + return new UpdateRequestBuilder<>(SERVICE_PATH, testEntity, "EntityCollection"); + } + + @Nonnull + default SingleValueActionRequestBuilder actionSingleResult() + { + return new SingleValueActionRequestBuilder<>( + SERVICE_PATH, + "action-single", + ImmutableMap.of("secret", "pass"), + Integer.class); + } + + @Nonnull + default CollectionValueActionRequestBuilder actionMultipleResult() + { + return new CollectionValueActionRequestBuilder<>( + SERVICE_PATH, + "action-multiple", + ImmutableMap.of("secret", "pass"), + String.class); + } + + @Nonnull + default SingleValueFunctionRequestBuilder functionSingleResult() + { + return new SingleValueFunctionRequestBuilder<>( + SERVICE_PATH, + "function-single", + ImmutableMap.of("secret", "pass"), + Integer.class); + } + + @Nonnull + default CollectionValueFunctionRequestBuilder functionMultipleResult() + { + return new CollectionValueFunctionRequestBuilder<>( + SERVICE_PATH, + "function-multiple", + ImmutableMap.of("secret", "pass"), + String.class); + } + + @Nonnull + default BatchRequestBuilder batch() + { + final AtomicInteger uuidCounter = new AtomicInteger(); + return new BatchRequestBuilder(SERVICE_PATH) + { + @Override + protected Supplier getUuidProvider() + { + return () -> new UUID(0, uuidCounter.incrementAndGet()); + } + }; + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/UpdateRequestHelperPatchTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/UpdateRequestHelperPatchTest.java new file mode 100644 index 0000000000..e69b2a8294 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/UpdateRequestHelperPatchTest.java @@ -0,0 +1,633 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.skyscreamer.jsonassert.JSONAssert.assertEquals; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.assertj.core.util.Lists; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONCompareMode; + +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +class UpdateRequestHelperPatchTest +{ + private static final String TEST_DEFAULT_SERVICE_PATH = "/odata/default"; + + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) + private static class TestEntity extends VdmEntity + { + @Getter + private final String odataType = "TestEntity"; + + @Getter + private final String entityCollection = "EntityParentCollection"; + + @Getter + private final String defaultServicePath = TEST_DEFAULT_SERVICE_PATH; + + @Getter + private final Class type = TestEntity.class; + + @Getter + @ElementName( "Neighbor" ) + private TestEntity neighbor; + + @Getter + @ElementName( "Value" ) + private BigDecimal value; + + @Getter + @ElementName( "Emails" ) + private List emails; + + @Getter + @ElementName( "Name" ) + private String name; + + @Getter + @ElementName( "EffortValue" ) + private Double effortValue; + + @Getter + @ElementName( "ShoeSize" ) + private Integer shoeSize; + + @Getter + @ElementName( "IsRetired" ) + private Boolean isRetired; + + @Getter + @ElementName( "BirthDate" ) + private LocalDate birthDate; + + @Getter + @ElementName( "FavouriteCharacter" ) + private Character favouriteCharacter; + + @Getter + @ElementName( "ComplexProperty" ) + private TestComplex complexProperty; + + @Getter + @ElementName( "ComplexProperties" ) + private Collection complexProperties; + + @Nonnull + @Override + protected ODataEntityKey getKey() + { + final ODataEntityKey key = new ODataEntityKey(ODataProtocol.V4); + key.addKeyProperty("Name", name); + return key; + } + + public void setNeighbor( TestEntity neighbor ) + { + rememberChangedField("Neighbor", this.neighbor); + this.neighbor = neighbor; + } + + public void setName( String name ) + { + rememberChangedField("Name", this.name); + this.name = name; + } + + public void setEffortValue( Double effortValue ) + { + rememberChangedField("EffortValue", this.effortValue); + this.effortValue = effortValue; + } + + public void setValue( BigDecimal value ) + { + rememberChangedField("Value", this.value); + this.value = value; + } + + public void setEmails( List emails ) + { + rememberChangedField("Emails", this.emails); + this.emails = emails; + } + + public void setShoeSize( Integer shoeSize ) + { + rememberChangedField("ShoeSize", this.shoeSize); + this.shoeSize = shoeSize; + } + + public void setIsRetired( Boolean isRetired ) + { + rememberChangedField("IsRetired", this.isRetired); + this.isRetired = isRetired; + } + + public void setBirthDate( LocalDate birthDate ) + { + rememberChangedField("BirthDate", this.birthDate); + this.birthDate = birthDate; + } + + public void setFavouriteCharacter( Character favouriteCharacter ) + { + rememberChangedField("FavouriteCharacter", this.favouriteCharacter); + this.favouriteCharacter = favouriteCharacter; + } + + public void setComplexProperty( TestComplex complexProperty ) + { + rememberChangedField("ComplexProperty", this.complexProperty); + this.complexProperty = complexProperty; + } + + public void setComplexProperties( Collection complexProperties ) + { + rememberChangedField("ComplexProperties", this.complexProperties); + this.complexProperties = complexProperties; + } + + @Nonnull + @Override + protected Map toMapOfFields() + { + final Map result = new HashMap<>(); + result.put("Name", name); + result.put("Value", value); + result.put("Neighbor", neighbor); + result.put("Emails", emails); + result.put("EffortValue", effortValue); + result.put("ShoeSize", shoeSize); + result.put("IsRetired", isRetired); + result.put("BirthDate", birthDate); + result.put("FavouriteCharacter", favouriteCharacter); + result.put("ComplexProperty", complexProperty); + result.put("ComplexProperties", complexProperties); + return result; + } + } + + @Builder + @NoArgsConstructor + @AllArgsConstructor + private static class TestComplex extends VdmComplex + { + @Getter + private final String odataType = "TestComplex"; + + @Getter + @ElementName( "StringProperty" ) + private String stringProperty; + + @Getter + @ElementName( "ComplexProperty" ) + private TestComplex complexProperty; + + @Nonnull + @Override + public Class getType() + { + return TestComplex.class; + } + + @Nonnull + @Override + protected Map toMapOfFields() + { + final Map values = super.toMapOfFields(); + values.put("StringProperty", getStringProperty()); + values.put("ComplexProperty", getComplexProperty()); + return values; + } + + public void setStringProperty( String stringProperty ) + { + rememberChangedField("StringProperty", this.stringProperty); + this.stringProperty = stringProperty; + } + + public void setComplexProperty( TestComplex complexProperty ) + { + rememberChangedField("ComplexProperty", this.complexProperty); + this.complexProperty = complexProperty; + } + } + + @Test + void testSimpleBigDecimalPropertyPatchPayload() + throws Exception + { + final TestEntity entity = TestEntity.builder().name("Foo").build(); + + entity.setValue(new BigDecimal("0.00000000001")); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals("{\"Value\":0.00000000001,\"@odata.type\":\"#TestEntity\"}", json, JSONCompareMode.LENIENT); + } + + @Test + void testSimpleDoublePropertyPatchPayload() + throws Exception + { + final TestEntity entity = TestEntity.builder().name("Foo").build(); + + entity.setEffortValue(42d); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals("{\"EffortValue\":42,\"@odata.type\":\"#TestEntity\"}", json, JSONCompareMode.LENIENT); + } + + @Test + void testSimpleIntegerPropertyPatchPayload() + throws Exception + { + final TestEntity entity = TestEntity.builder().name("Foo").build(); + + entity.setShoeSize(46); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals("{\"ShoeSize\":46,\"@odata.type\":\"#TestEntity\"}", json, JSONCompareMode.LENIENT); + } + + @Test + void testSimpleStringPropertyPatchPayload() + throws Exception + { + final TestEntity entity = TestEntity.builder().name("Foo").build(); + + entity.setName("Bar"); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals("{\"Name\":\"Bar\",\"@odata.type\":\"#TestEntity\"}", json, JSONCompareMode.LENIENT); + } + + @Test + void testSimpleLocalDatePropertyPatchPayload() + throws Exception + { + final TestEntity entity = TestEntity.builder().name("Foo").build(); + + entity.setBirthDate(LocalDate.MIN); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals( + "{\"BirthDate\":\"-999999999-01-01\",\"@odata.type\":\"#TestEntity\"}", + json, + JSONCompareMode.LENIENT); + } + + @Test + void testSimpleSingleCharacterDatePropertyPatchPayload() + throws Exception + { + final TestEntity entity = TestEntity.builder().name("Foo").build(); + + entity.setFavouriteCharacter('A'); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals("{\"FavouriteCharacter\":\"A\",\"@odata.type\":\"#TestEntity\"}", json, JSONCompareMode.LENIENT); + } + + @Test + void testSimpleBooleanPropertyPatchPayload() + throws Exception + { + final TestEntity entity = TestEntity.builder().name("Foo").build(); + + entity.setIsRetired(Boolean.FALSE); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals("{\"IsRetired\":false,\"@odata.type\":\"#TestEntity\"}", json, JSONCompareMode.LENIENT); + } + + @Test + void testDirectSimpleCollectionPropertyPatchPayload() + throws Exception + { + final TestEntity entity = TestEntity.builder().name("Foo").build(); + + entity.setEmails(Lists.newArrayList("foo@sap.com")); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals("{\"Emails\":[\"foo@sap.com\"],\"@odata.type\":\"#TestEntity\"}", json, JSONCompareMode.LENIENT); + } + + @Disabled( "Indirect changes on entity properties are not tracked. CLOUDECOSYSTEM-8217" ) + // @Test + void testIndirectCollectionPropertyViaGetPatchPayload() + throws Exception + { + final TestEntity entity = TestEntity.builder().name("Foo").emails(Lists.newArrayList()).build(); + + entity.getEmails().add("foo@sap.com"); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals("{\"Emails\":[\"foo@sap.com\"],\"@odata.type\":\"#TestEntity\"}", json, JSONCompareMode.LENIENT); + } + + @Test + void testNavigationPropertyExistingPatchPayload() + throws Exception + { + final TestEntity entityFoo = TestEntity.builder().name("Foo").value(BigDecimal.ZERO).build(); + final TestEntity entityBar = TestEntity.builder().name("Bar").value(BigDecimal.ONE).build(); + + entityFoo.setNeighbor(entityBar); + + final String json = new UpdateRequestHelperPatch().toJson(entityFoo, Collections.emptySet()); + assertEquals( + "{\"Neighbor\":{\"@id\":\"EntityParentCollection('Bar')\"},\"@odata.type\":\"#TestEntity\"}", + json, + JSONCompareMode.LENIENT); + } + + @Test + void testNavigationPropertyNewPatchPayload() + throws Exception + { + final TestEntity entityFoo = TestEntity.builder().name("Foo").value(BigDecimal.ZERO).build(); + + final TestEntity entityBar = new TestEntity(); + entityBar.setName("Bar"); + entityBar.setValue(BigDecimal.ONE); + + entityFoo.setNeighbor(entityBar); + + final String json = new UpdateRequestHelperPatch().toJson(entityFoo, Collections.emptySet()); + + assertEquals( + "{\"Neighbor\":{\"Value\":1,\"Name\":\"Bar\"},\"@odata.type\":\"#TestEntity\"}", + json, + JSONCompareMode.LENIENT); + } + + @Test + void testNavigationPropertyChangePatchPayload() + throws Exception + { + final TestEntity entityFoo = TestEntity.builder().name("Foo").value(BigDecimal.ZERO).build(); + final TestEntity entityBar = TestEntity.builder().name("Bar").build(); + entityBar.setValue(BigDecimal.ONE); + entityFoo.setNeighbor(entityBar); + + final String json = new UpdateRequestHelperPatch().toJson(entityFoo, Collections.emptySet()); + + assertEquals( + "{\"Neighbor\":{\"Value\":1,\"@id\":\"EntityParentCollection('Bar')\"},\"@odata.type\":\"#TestEntity\"}", + json, + JSONCompareMode.LENIENT); + } + + @Test + void testNavigationPropertyCyclicPatchPayload() + throws Exception + { + final TestEntity entityBar = new TestEntity(); + entityBar.setName("Bar"); + + final TestEntity entityFoo = new TestEntity(); + entityFoo.setName("Foo"); + entityFoo.setNeighbor(entityBar); + entityBar.setNeighbor(entityFoo); + + final String json = new UpdateRequestHelperPatch().toJson(entityFoo, Collections.emptySet()); + assertEquals(""" + {\ + "Neighbor":{\ + "Neighbor":{"@id":"EntityParentCollection('Foo')"},\ + "Name":"Bar"\ + },\ + "Name":"Foo",\ + "@odata.type":"#TestEntity"\ + }\ + """, json, JSONCompareMode.LENIENT); + } + + @Test + void testSetComplexMember() + throws Exception + { + final TestComplex complex = TestComplex.builder().stringProperty("Foo").build(); + complex.setCustomField("customField", "customValue"); + + final TestEntity entity = TestEntity.builder().build(); + entity.setComplexProperty(complex); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals( + "{\"ComplexProperty\":{\"StringProperty\":\"Foo\",\"customField\":\"customValue\"},\"@odata.type\":\"#TestEntity\"}", + json, + JSONCompareMode.LENIENT); + } + + @Test + void testRemoveComplexMember() + throws Exception + { + final TestEntity entity = + TestEntity.builder().complexProperty(TestComplex.builder().stringProperty("Foo").build()).build(); + entity.setComplexProperty(null); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals("{\"ComplexProperty\":null,\"@odata.type\":\"#TestEntity\"}", json, JSONCompareMode.LENIENT); + } + + @Test + void testUpdateFieldOfComplexMember() + throws Exception + { + final TestEntity entity = TestEntity.builder().complexProperty(new TestComplex()).build(); + entity.getComplexProperty().setStringProperty("Foo"); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals( + "{\"ComplexProperty\":{\"StringProperty\":\"Foo\"},\"@odata.type\":\"#TestEntity\"}", + json, + JSONCompareMode.LENIENT); + } + + @Test + void testRemoveFieldOfComplexMember() + throws Exception + { + final TestComplex complex = TestComplex.builder().stringProperty("Foo").build(); + complex.setCustomField("customField", "customValue"); + final TestEntity entity = TestEntity.builder().complexProperty(complex).build(); + entity.getComplexProperty().setStringProperty(null); + entity.getComplexProperty().setCustomField("customField", null); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals( + "{\"ComplexProperty\":{\"StringProperty\":null,\"customField\":null},\"@odata.type\":\"#TestEntity\"}", + json, + JSONCompareMode.LENIENT); + } + + @Test + void testUpdateFieldOfNestedComplexMember() + throws Exception + { + final TestComplex complexChild = new TestComplex(); + final TestComplex complexParent = + TestComplex.builder().stringProperty("Parent").complexProperty(complexChild).build(); + final TestEntity entity = TestEntity.builder().complexProperty(complexParent).build(); + complexChild.setStringProperty("Hello, World!"); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals( + "{\"ComplexProperty\":{\"StringProperty\":\"Parent\",\"ComplexProperty\":{\"StringProperty\":\"Hello, World!\"}},\"@odata.type\":\"#TestEntity\"}", + json, + JSONCompareMode.LENIENT); + } + + @Test + void testRemoveFieldOfNestedComplexMember() + throws Exception + { + final TestComplex complexChild = TestComplex.builder().stringProperty("Child").build(); + final TestComplex complexParent = + TestComplex.builder().stringProperty("Parent").complexProperty(complexChild).build(); + final TestEntity entity = TestEntity.builder().complexProperty(complexParent).build(); + entity.getComplexProperty().getComplexProperty().setStringProperty(null); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals( + "{\"ComplexProperty\":{\"StringProperty\":\"Parent\",\"ComplexProperty\":{\"StringProperty\":null}},\"@odata.type\":\"#TestEntity\"}", + json, + JSONCompareMode.LENIENT); + } + + @Test + void testSetNullableCollectionToEmpty() + throws Exception + { + final TestEntity entity = TestEntity.builder().build(); + assertThat(entity.getComplexProperties()).isNull(); + entity.setComplexProperties(new ArrayList<>()); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals("{\"ComplexProperties\":[],\"@odata.type\":\"#TestEntity\"}", json, JSONCompareMode.LENIENT); + } + + @Test + void testSetEmptyToCollectionToEmpty() + throws Exception + { + final TestEntity entity = TestEntity.builder().complexProperties(new ArrayList<>()).build(); + entity.setComplexProperties(new ArrayList<>()); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals("{\"@odata.type\":\"#TestEntity\"}", json, JSONCompareMode.LENIENT); + } + + @Test + void testRemoveCollection() + throws Exception + { + final TestEntity entity = TestEntity.builder().complexProperties(new ArrayList<>()).build(); + entity.setComplexProperties(null); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals("{\"ComplexProperties\":null,\"@odata.type\":\"#TestEntity\"}", json, JSONCompareMode.LENIENT); + } + + @Test + void testSetCollectionOfComplexProperties() + throws Exception + { + final TestComplex complex = TestComplex.builder().stringProperty("Foo").build(); + complex.setCustomField("customField", "customValue"); + + final TestEntity entity = TestEntity.builder().complexProperties(new ArrayList<>()).build(); + entity.setComplexProperties(Collections.singletonList(complex)); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals( + "{\"ComplexProperties\":[{\"StringProperty\":\"Foo\",\"ComplexProperty\":null,\"customField\":\"customValue\"}],\"@odata.type\":\"#TestEntity\"}", + json, + JSONCompareMode.LENIENT); + } + + @Test + void testUpdateComplexMemberInCollection() + throws Exception + { + final TestComplex collectionMember = new TestComplex(); + final TestEntity entity = TestEntity.builder().complexProperties(Lists.newArrayList(collectionMember)).build(); + collectionMember.setStringProperty("Foo"); + collectionMember.setCustomField("customField", "customValue"); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals( + "{\"ComplexProperties\":[{\"StringProperty\":\"Foo\",\"customField\":\"customValue\"}],\"@odata.type\":\"#TestEntity\"}", + json, + JSONCompareMode.LENIENT); + } + + @Test + void testRemoveFieldOfComplexMemberInCollection() + throws Exception + { + final TestComplex collectionMember = TestComplex.builder().stringProperty("Foo").build(); + final TestEntity entity = TestEntity.builder().complexProperties(Lists.newArrayList(collectionMember)).build(); + collectionMember.setStringProperty(null); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals( + "{\"ComplexProperties\":[{\"StringProperty\":null}],\"@odata.type\":\"#TestEntity\"}", + json, + JSONCompareMode.LENIENT); + } + + @Disabled( "Tracking changes within a collection of complex properties is not yet supported. CLOUDECOSYSTEM-8217" ) + // @Test + void testAddMemberToComplexCollection() + throws Exception + { + final TestEntity entity = TestEntity.builder().complexProperties(new ArrayList<>()).build(); + entity.getComplexProperties().add(TestComplex.builder().stringProperty("Foo").build()); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals( + "{\"ComplexProperties\":[{\"StringProperty\":\"Foo\", \"ComplexProperty\":{}}],\"@odata.type\":\"#TestEntity\"}", + json, + JSONCompareMode.LENIENT); + } + + @Disabled( "Tracking changes within a collection of complex properties is not yet supported. CLOUDECOSYSTEM-8217" ) + // @Test + void testRemoveMemberFromComplexCollection() + throws Exception + { + final TestComplex collectionMember = TestComplex.builder().stringProperty("Foo").build(); + final TestEntity entity = + TestEntity.builder().complexProperties(Collections.singletonList(collectionMember)).build(); + entity.getComplexProperties().remove(collectionMember); + + final String json = new UpdateRequestHelperPatch().toJson(entity, Collections.emptySet()); + assertEquals("{\"ComplexProperties\":[],\"@odata.type\":\"#TestEntity\"}", json, JSONCompareMode.LENIENT); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/UpdateRequestHelperPutTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/UpdateRequestHelperPutTest.java new file mode 100644 index 0000000000..3f4762c80c --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/UpdateRequestHelperPutTest.java @@ -0,0 +1,159 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import static org.skyscreamer.jsonassert.JSONAssert.assertEquals; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; + +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONCompareMode; + +import com.google.gson.annotations.JsonAdapter; +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.request.ODataEntityKey; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +class UpdateRequestHelperPutTest +{ + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) + private static class TestEntity extends VdmEntity + { + @Getter + private final String odataType = "TestEntity"; + + @Getter + private final String entityCollection = "EntityParentCollection"; + + @Getter + private final Class type = TestEntity.class; + + @Getter + @ElementName( "Value" ) + private BigDecimal value; + + final static SimpleProperty.NumericDecimal VALUE = + new SimpleProperty.NumericDecimal<>(TestEntity.class, "Value"); + + @Getter + @ElementName( "Emails" ) + private List emails; + + final static SimpleProperty.Collection EMAILS = + new SimpleProperty.Collection<>(TestEntity.class, "Emails", String.class); + + @Getter + @ElementName( "Name" ) + private String name; + + final static SimpleProperty.String NAME = new SimpleProperty.String<>(TestEntity.class, "Name"); + + @Getter + @ElementName( "IsRetired" ) + private Boolean isRetired; + + final static SimpleProperty.Boolean IS_RETIRED = + new SimpleProperty.Boolean<>(TestEntity.class, "IsRetired"); + + @Nonnull + @Override + protected ODataEntityKey getKey() + { + final ODataEntityKey key = new ODataEntityKey(ODataProtocol.V4); + key.addKeyProperty("Name", name); + return key; + } + + public void setName( String name ) + { + rememberChangedField("Name", this.name); + this.name = name; + } + + public void setIsRetired( Boolean isRetired ) + { + rememberChangedField("IsRetired", this.isRetired); + this.isRetired = isRetired; + } + + public void setValue( BigDecimal value ) + { + rememberChangedField("Value", this.value); + this.value = value; + } + + public void setEmails( List emails ) + { + rememberChangedField("Emails", this.emails); + this.emails = emails; + } + + @Nonnull + @Override + protected Map toMapOfFields() + { + final Map result = new HashMap<>(); + result.put("Name", name); + result.put("Value", value); + result.put("Emails", emails); + result.put("IsRetired", isRetired); + return result; + } + } + + @Test + void testPutPayload() + throws Exception + { + final TestEntity entity = TestEntity.builder().name("Foo").isRetired(true).build(); + entity.setValue(new BigDecimal("0.00000000001")); + entity.setEmails(Collections.singletonList("sampleEmail@sap.com")); + final String json = new UpdateRequestHelperPut().toJson(entity, null); + + assertEquals( + "{\"@odata.type\":\"#TestEntity\",\"Value\":0.00000000001,\"Emails\":[\"sampleEmail@sap.com\"],\"Name\":\"Foo\",\"IsRetired\":true}", + json, + JSONCompareMode.LENIENT); + } + + @Test + void testPutPayloadWithExcludedFields() + throws Exception + { + final TestEntity entity = TestEntity.builder().name("Foo").isRetired(true).build(); + entity.setValue(new BigDecimal("0.00000000001")); + final List excludedFields = + Arrays.asList(TestEntity.IS_RETIRED, TestEntity.NAME, TestEntity.EMAILS); + final String json = new UpdateRequestHelperPut().toJson(entity, excludedFields); + + assertEquals("{\"@odata.type\":\"#TestEntity\",\"Value\":0.00000000001}", json, JSONCompareMode.LENIENT); + } + + @Test + void testPutPayloadWithNullFields() + throws Exception + { + final TestEntity entity = TestEntity.builder().name("Foo").isRetired(true).build(); + entity.setValue(new BigDecimal("0.00000000001")); + final String json = new UpdateRequestHelperPut().toJson(entity, null); + + assertEquals( + "{\"@odata.type\":\"#TestEntity\",\"Value\":0.00000000001,\"Name\":\"Foo\",\"IsRetired\":true}", + json, + JSONCompareMode.STRICT); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmEntityTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmEntityTest.java new file mode 100644 index 0000000000..df773b7e3b --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/core/VdmEntityTest.java @@ -0,0 +1,69 @@ +package com.sap.cloud.sdk.datamodel.odatav4.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import io.vavr.control.Option; + +class VdmEntityTest +{ + @Test + void testModifications() + { + final TestEntity entity = new TestEntity(); + assertThat(entity).isEqualTo(new TestEntity()); + assertThat(entity.getVersionIdentifier()).isEqualTo(Option.none()); + + entity.setCustomField("foo", "bar"); + + final SimpleProperty customField = () -> "fizz"; + entity.setCustomField(customField, "buzz"); + + assertThat(entity.getCustomFields()).containsEntry("foo", "bar").containsEntry("fizz", "buzz"); + assertThat(entity. getCustomField("foo")).isEqualTo("bar"); + assertThat(entity. getCustomField(customField)).isEqualTo("buzz"); + assertThat(entity.hasCustomField("fizz")).isTrue(); + assertThat(entity.hasCustomField(customField)).isTrue(); + assertThat(entity.getCustomFieldNames()).containsExactly("foo", "fizz"); + + entity.fromMap(Collections.singletonMap("foo", "barbar")); + assertThat(entity. getCustomField("foo")).isEqualTo("barbar"); + + assertThat(entity.toString()).isNotNull(); + } + + @Test + void testChangedNonCustomFields() + { + final TestEntity entity = TestEntity.builder().id("old").build(); + + assertThat(entity.getChangedFields()).isEmpty(); + + entity.setId("old"); + assertThat(entity.getChangedFields()).isEmpty(); + + entity.setId("new"); + assertThat(entity.getChangedFields()).containsOnlyKeys("id"); + } + + @Test + void testChangedCustomFields() + { + final TestEntity entity = TestEntity.builder().id("id").build(); + + assertThat(entity.getChangedFields()).isEmpty(); + + entity.setCustomField("foo", "bar"); + assertThat(entity.getChangedFields()).containsOnlyKeys("foo"); + + entity.resetChangedFields(); + entity.setCustomField("foo", "bar"); + assertThat(entity.getChangedFields()).isEmpty(); + + entity.setCustomField("foo", "baz"); + assertThat(entity.getChangedFields()).containsOnlyKeys("foo"); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableOperandsTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableOperandsTest.java new file mode 100644 index 0000000000..e0d40649f3 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/expression/FilterableOperandsTest.java @@ -0,0 +1,575 @@ +package com.sap.cloud.sdk.datamodel.odatav4.expression; + +import static com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol.V4; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.UUID; +import java.util.function.BiFunction; +import java.util.function.Function; + +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.util.Lists; +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odata.client.expression.FilterExpression; +import com.sap.cloud.sdk.datamodel.odatav4.core.ComplexProperty; +import com.sap.cloud.sdk.datamodel.odatav4.core.NavigationProperty; +import com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmComplex; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEntity; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEnum; + +import lombok.Getter; + +class FilterableOperandsTest +{ + private static class TestEntity extends VdmEntity + { + private static final SimpleProperty.Boolean ALIVE = + new SimpleProperty.Boolean<>(TestEntity.class, "Alive"); + private static final SimpleProperty.Enum PROGRAMMING_LANGUAGE = + new SimpleProperty.Enum<>(TestEntity.class, "ProgrammingLanguage", "SoftwareEngineer.Programming.Language"); + + @Getter + private final String entityCollection = "TestEntities"; + @Getter + private final String odataType = "Odata.TestEntity"; + @Getter + private final Class type = TestEntity.class; + + enum Language implements VdmEnum + { + Java; + } + } + + private static class TestComputer extends VdmComplex + { + @Getter + private final String odataType = "Odata.TestComputer"; + @Getter + private final Class type = TestComputer.class; + } + + @Test + void testFilterableBoolean() + { + final SimpleProperty.Boolean f1 = new SimpleProperty.Boolean<>(TestEntity.class, "Field1"); + final String expression = f1.and(true).and(f1).not().or(true).or(f1).getExpression(V4); + assertThat(expression).isEqualTo("(((not ((Field1 and true) and Field1)) or true) or Field1)"); + } + + @Test + void testFilterableCollection() + { + final SoftAssertions softly = new SoftAssertions(); + final SimpleProperty.Collection telephone = + new SimpleProperty.Collection<>(TestEntity.class, "TelephoneNumber", Integer.class); + final SimpleProperty.Collection areaCode = + new SimpleProperty.Collection<>(TestEntity.class, "AreaCode", Integer.class); + final NavigationProperty.Collection friends = + new NavigationProperty.Collection<>(TestEntity.class, "Friends", TestEntity.class); + + { + final String expression = friends.all(TestEntity.ALIVE.equalTo(true)).getExpression(V4); + softly.assertThat(expression).isEqualTo("Friends/all(a:(a/Alive eq true))"); + } + { + final String expression = friends.any(TestEntity.ALIVE.equalTo(true)).getExpression(V4); + softly.assertThat(expression).isEqualTo("Friends/any(a:(a/Alive eq true))"); + } + { + final String expression = areaCode.concat(telephone).concat(Arrays.asList(4, 2)).getExpression(V4); + softly.assertThat(expression).isEqualTo("concat(concat(AreaCode,TelephoneNumber),[4,2])"); + } + { + final String expression = areaCode.substring(1).substring(2, 3).getExpression(V4); + softly.assertThat(expression).isEqualTo("substring(substring(AreaCode,1),2,3)"); + } + { + final String expression = telephone.contains(areaCode).getExpression(V4); + softly.assertThat(expression).isEqualTo("contains(TelephoneNumber,AreaCode)"); + } + { + final String expression = telephone.contains(Arrays.asList(0, 3, 0)).getExpression(V4); + softly.assertThat(expression).isEqualTo("contains(TelephoneNumber,[0,3,0])"); + } + { + final String expression = telephone.endsWith(Arrays.asList(4, 2)).getExpression(V4); + softly.assertThat(expression).isEqualTo("endswith(TelephoneNumber,[4,2])"); + } + { + final String expression = telephone.endsWith(telephone).getExpression(V4); + softly.assertThat(expression).isEqualTo("endswith(TelephoneNumber,TelephoneNumber)"); + } + { + final String expression = telephone.startsWith(Arrays.asList(0, 3, 0)).getExpression(V4); + softly.assertThat(expression).isEqualTo("startswith(TelephoneNumber,[0,3,0])"); + } + { + final String expression = telephone.startsWith(telephone).getExpression(V4); + softly.assertThat(expression).isEqualTo("startswith(TelephoneNumber,TelephoneNumber)"); + } + { + final String expression = telephone.hasSubSequence(areaCode).getExpression(V4); + softly.assertThat(expression).isEqualTo("hassubsequence(TelephoneNumber,AreaCode)"); + } + { + final String expression = telephone.hasSubSequence(Arrays.asList(0, 3, 0)).getExpression(V4); + softly.assertThat(expression).isEqualTo("hassubsequence(TelephoneNumber,[0,3,0])"); + } + { + final String expression = telephone.hasSubset(areaCode).getExpression(V4); + softly.assertThat(expression).isEqualTo("hassubset(TelephoneNumber,AreaCode)"); + } + { + final String expression = telephone.hasSubset(Arrays.asList(0, 3, 0)).getExpression(V4); + softly.assertThat(expression).isEqualTo("hassubset(TelephoneNumber,[0,3,0])"); + } + { + final String expression = telephone.indexOf(areaCode).getExpression(V4); + softly.assertThat(expression).isEqualTo("indexof(TelephoneNumber,AreaCode)"); + } + { + final String expression = telephone.indexOf(Arrays.asList(0, 3, 0)).getExpression(V4); + softly.assertThat(expression).isEqualTo("indexof(TelephoneNumber,[0,3,0])"); + } + { + final String expression = telephone.length().getExpression(V4); + softly.assertThat(expression).isEqualTo("length(TelephoneNumber)"); + } + softly.assertAll(); + } + + @Test + void testFilterableComplex() + { + final ComplexProperty.Single f1 = + new ComplexProperty.Single<>(TestEntity.class, "OperatingSystem", TestComputer.class); + final String expression1 = f1.has("Debian").getExpression(V4); + assertThat(expression1).isEqualTo("(OperatingSystem has 'Debian')"); + + final ComplexProperty.Single f2 = + new ComplexProperty.Single<>(TestEntity.class, "Apartment", TestComputer.class); + final SimpleProperty.Enum enumValue = + new SimpleProperty.Enum<>(TestEntity.class, "Animal", "OData.Test.AnimalType"); + final String expression2 = f2.has(enumValue).getExpression(V4); + assertThat(expression2).isEqualTo("(Apartment has Animal)"); + + assertThat(f1.equalToNull().getExpression(V4)).isEqualTo("(OperatingSystem eq null)"); + assertThat(f1.notEqualToNull().getExpression(V4)).isEqualTo("(OperatingSystem ne null)"); + } + + @Test + void testFilterableTime() + { + final SoftAssertions softly = new SoftAssertions(); + final SimpleProperty.Time time = new SimpleProperty.Time<>(TestEntity.class, "Registration"); + softly.assertThat(time.timeFractionalSeconds().getExpression(V4)).isEqualTo("fractionalseconds(Registration)"); + softly.assertThat(time.timeSecond().getExpression(V4)).isEqualTo("second(Registration)"); + softly.assertThat(time.timeMinute().getExpression(V4)).isEqualTo("minute(Registration)"); + softly.assertThat(time.timeHour().getExpression(V4)).isEqualTo("hour(Registration)"); + softly.assertAll(); + } + + @Test + void testFilterableDate() + { + final SoftAssertions softly = new SoftAssertions(); + final SimpleProperty.Date date = new SimpleProperty.Date<>(TestEntity.class, "Registration"); + final SimpleProperty.Duration duration = + new SimpleProperty.Duration<>(TestEntity.class, "ResponseTime"); + { + final String expression = + date + .add(Duration.ofDays(1)) + .subtract(Duration.ofDays(2)) + .difference(LocalDate.of(2001, 1, 1)) + .getExpression(V4); + softly + .assertThat(expression) + .isEqualTo("(((Registration add duration'PT24H') sub duration'PT48H') sub 2001-01-01)"); + } + { + final String expression = date.add(duration).subtract(duration).difference(date).getExpression(V4); + softly + .assertThat(expression) + .isEqualTo("(((Registration add ResponseTime) sub ResponseTime) sub Registration)"); + } + softly.assertThat(date.dateDay().getExpression(V4)).isEqualTo("day(Registration)"); + softly.assertThat(date.dateMonth().getExpression(V4)).isEqualTo("month(Registration)"); + softly.assertThat(date.dateYear().getExpression(V4)).isEqualTo("year(Registration)"); + softly.assertAll(); + } + + @Test + void testFilterableDateTime() + { + final SoftAssertions softly = new SoftAssertions(); + final SimpleProperty.DateTime dt = new SimpleProperty.DateTime<>(TestEntity.class, "Registration"); + final SimpleProperty.Duration duration = + new SimpleProperty.Duration<>(TestEntity.class, "ResponseTime"); + + { + final String expression = dt.add(Duration.ofDays(1)).subtract(Duration.ofDays(2)).getExpression(V4); + softly.assertThat(expression).isEqualTo("((Registration add duration'PT24H') sub duration'PT48H')"); + } + { + final String expression = dt.add(duration).subtract(duration).getExpression(V4); + softly.assertThat(expression).isEqualTo("((Registration add ResponseTime) sub ResponseTime)"); + } + softly.assertThat(dt.date().getExpression(V4)).isEqualTo("date(Registration)"); + softly.assertThat(dt.time().getExpression(V4)).isEqualTo("time(Registration)"); + softly.assertThat(dt.timeFractionalSeconds().getExpression(V4)).isEqualTo("fractionalseconds(Registration)"); + softly.assertThat(dt.timeHour().getExpression(V4)).isEqualTo("hour(Registration)"); + softly.assertThat(dt.timeMinute().getExpression(V4)).isEqualTo("minute(Registration)"); + softly.assertThat(dt.timeSecond().getExpression(V4)).isEqualTo("second(Registration)"); + softly.assertThat(dt.dateDay().getExpression(V4)).isEqualTo("day(Registration)"); + softly.assertThat(dt.dateMonth().getExpression(V4)).isEqualTo("month(Registration)"); + softly.assertThat(dt.dateYear().getExpression(V4)).isEqualTo("year(Registration)"); + softly.assertThat(dt.offsetMinutes().getExpression(V4)).isEqualTo("totaloffsetminutes(Registration)"); + softly.assertAll(); + } + + @Test + void testFilterableDuration() + { + final SoftAssertions softly = new SoftAssertions(); + final SimpleProperty.Duration duration = + new SimpleProperty.Duration<>(TestEntity.class, "ResponseTime"); + final SimpleProperty.NumericDecimal number = + new SimpleProperty.NumericDecimal<>(TestEntity.class, "Size"); + { + final String expression = duration.add(duration).subtract(duration).negate().getExpression(V4); + softly.assertThat(expression).isEqualTo("-(((ResponseTime add ResponseTime) sub ResponseTime))"); + } + { + final String expression = duration.add(Duration.ofDays(1)).subtract(Duration.ofDays(2)).getExpression(V4); + softly.assertThat(expression).isEqualTo("((ResponseTime add duration'PT24H') sub duration'PT48H')"); + } + { + final String expression = duration.divide(2).multiply(3).getExpression(V4); + softly.assertThat(expression).isEqualTo("((ResponseTime div 2) mul 3)"); + } + { + final String expression = duration.divide(number).multiply(number).getExpression(V4); + softly.assertThat(expression).isEqualTo("((ResponseTime div Size) mul Size)"); + } + softly.assertThat(duration.offsetSeconds().getExpression(V4)).isEqualTo("totaloffsetseconds(ResponseTime)"); + softly.assertAll(); + } + + @Test + void testFilterableNumberApiInteger() + { + final SimpleProperty.NumericInteger number = + new SimpleProperty.NumericInteger<>(TestEntity.class, "Age"); + final SoftAssertions softly = new SoftAssertions(); + + // internal API tests + { + final FilterExpression delegate = mock(FilterExpression.class); + final FilterableNumericInteger.Expression expression = + new FilterableNumericInteger.Expression<>(delegate, Object.class); + softly.assertThat(expression.getDelegate()).isEqualTo(delegate); + softly.assertThat(expression.getEntityType()).isEqualTo(Object.class); + } + + // type safety for NumericInteger functions + Lists + ., FilterableNumeric>> newArrayList(FilterableNumericInteger::negate + + ) + .forEach(f -> softly.assertThat(f.apply(number)).isInstanceOf(FilterableNumericInteger.class)); + + // type safety for NumericInteger functions and Long argument + Lists + ., Long, FilterableNumeric>> newArrayList( + FilterableNumericInteger::add, + FilterableNumericInteger::subtract, + FilterableNumericInteger::multiply, + FilterableNumericInteger::modulo) + .forEach( + f -> softly + .assertThat(f.apply(number, 42L)) + .describedAs(f.toString()) + .isInstanceOf(FilterableNumericInteger.class)); + + // type safety for NumericInteger functions and Integer argument + Lists + ., Integer, FilterableNumeric>> newArrayList( + FilterableNumericInteger::add, + FilterableNumericInteger::subtract, + FilterableNumericInteger::multiply, + FilterableNumericInteger::modulo) + .forEach( + f -> softly + .assertThat(f.apply(number, 42)) + .describedAs(f.toString()) + .isInstanceOf(FilterableNumericInteger.class)); + + // type safety for NumericInteger functions and Decimal argument + Lists + ., Number, FilterableNumeric>> newArrayList( + FilterableNumericInteger::add, + FilterableNumericInteger::subtract, + FilterableNumericInteger::multiply, + FilterableNumericInteger::divide, + FilterableNumericInteger::modulo) + .forEach( + f -> softly + .assertThat(f.apply(number, Math.PI)) + .describedAs(f.toString()) + .isInstanceOf(FilterableNumericDecimal.class)); + + softly.assertAll(); + } + + @Test + void testFilterableNumberApiDecimal() + { + final SimpleProperty.NumericDecimal number = + new SimpleProperty.NumericDecimal<>(TestEntity.class, "Size"); + final SoftAssertions softly = new SoftAssertions(); + + // internal API tests + { + final FilterExpression delegate = mock(FilterExpression.class); + final FilterableNumericDecimal.Expression expression = + new FilterableNumericDecimal.Expression<>(delegate, Object.class); + softly.assertThat(expression.getDelegate()).isEqualTo(delegate); + softly.assertThat(expression.getEntityType()).isEqualTo(Object.class); + } + + // type safety for NumericDecimal functions + Lists + ., FilterableNumeric>> newArrayList(FilterableNumericDecimal::negate + + ) + .forEach(f -> softly.assertThat(f.apply(number)).isInstanceOf(FilterableNumericDecimal.class)); + + // type safety for NumericDecimal functions + Lists + ., FilterableNumeric>> newArrayList( + FilterableNumericDecimal::ceil, + FilterableNumericDecimal::floor, + FilterableNumericDecimal::round + + ) + .forEach(f -> softly.assertThat(f.apply(number)).isInstanceOf(FilterableNumericInteger.class)); + + // type safety for NumericDecimal functions and Long argument + Lists + ., Long, FilterableNumeric>> newArrayList( + FilterableNumericDecimal::add, + FilterableNumericDecimal::subtract, + FilterableNumericDecimal::multiply, + FilterableNumericDecimal::modulo) + .forEach( + f -> softly + .assertThat(f.apply(number, 42L)) + .describedAs(f.toString()) + .isInstanceOf(FilterableNumericDecimal.class)); + + // type safety for NumericDecimal functions and Integer argument + Lists + ., Integer, FilterableNumeric>> newArrayList( + FilterableNumericDecimal::add, + FilterableNumericDecimal::subtract, + FilterableNumericDecimal::multiply, + FilterableNumericDecimal::modulo) + .forEach( + f -> softly + .assertThat(f.apply(number, 42)) + .describedAs(f.toString()) + .isInstanceOf(FilterableNumericDecimal.class)); + + // type safety for NumericDecimal functions and Decimal argument + Lists + ., Number, FilterableNumeric>> newArrayList( + FilterableNumericDecimal::add, + FilterableNumericDecimal::subtract, + FilterableNumericDecimal::multiply, + FilterableNumericDecimal::divide, + FilterableNumericDecimal::modulo) + .forEach( + f -> softly + .assertThat(f.apply(number, Math.PI)) + .describedAs(f.toString()) + .isInstanceOf(FilterableNumericDecimal.class)); + + softly.assertAll(); + } + + @Test + void testFilterableNumber() + { + final SoftAssertions softly = new SoftAssertions(); + final SimpleProperty.NumericInteger number = + new SimpleProperty.NumericInteger<>(TestEntity.class, "Age"); + final SimpleProperty.NumericDecimal size = + new SimpleProperty.NumericDecimal<>(TestEntity.class, "Size"); + { + final String expression = number.add(number).subtract(number).negate().getExpression(V4); + softly.assertThat(expression).isEqualTo("-(((Age add Age) sub Age))"); + } + { + final String expression = size.add(size).subtract(size).negate().getExpression(V4); + softly.assertThat(expression).isEqualTo("-(((Size add Size) sub Size))"); + } + { + final String expression = number.add(1).subtract(2).getExpression(V4); + softly.assertThat(expression).isEqualTo("((Age add 1) sub 2)"); + } + { + final String expression = number.multiply(number).divide(number).getExpression(V4); + softly.assertThat(expression).isEqualTo("((Age mul Age) divby Age)"); + } + { + final String expression = size.multiply(size).divide(size).getExpression(V4); + softly.assertThat(expression).isEqualTo("((Size mul Size) divby Size)"); + } + { + final String expression = number.multiply(1).divide(2).getExpression(V4); + softly.assertThat(expression).isEqualTo("((Age mul 1) divby 2)"); + } + { + final String expression = number.modulo(number).modulo(3).getExpression(V4); + softly.assertThat(expression).isEqualTo("((Age mod Age) mod 3)"); + } + { + final String expression = size.modulo(size).modulo(3.0).getExpression(V4); + softly.assertThat(expression).isEqualTo("((Size mod Size) mod 3.0)"); + } + { + final String expression = size.in(42, 13.37, 9000f).getExpression(V4); + softly.assertThat(expression).isEqualTo("(Size in (42,13.37,9000.0))"); + } + { + softly.assertThat(size.ceil().getExpression(V4)).isEqualTo("ceiling(Size)"); + softly.assertThat(size.floor().getExpression(V4)).isEqualTo("floor(Size)"); + softly.assertThat(size.round().getExpression(V4)).isEqualTo("round(Size)"); + } + { + final String expression = + number + .equalTo(number) + .and(number.equalTo(42)) + .and(number.notEqualTo(number)) + .and(number.notEqualTo(42)) + .getExpression(V4); + softly + .assertThat(expression) + .isEqualTo("((((Age eq Age) and (Age eq 42)) and (Age ne Age)) and (Age ne 42))"); + } + { + final String expression = + number + .greaterThan(number) + .and(number.greaterThan(42)) + .and(number.greaterThanEqual(number)) + .and(number.greaterThanEqual(42)) + .getExpression(V4); + softly + .assertThat(expression) + .isEqualTo("((((Age gt Age) and (Age gt 42)) and (Age ge Age)) and (Age ge 42))"); + } + { + final String expression = + number + .lessThan(number) + .and(number.lessThan(42)) + .and(number.lessThanEqual(number)) + .and(number.lessThanEqual(42)) + .getExpression(V4); + softly + .assertThat(expression) + .isEqualTo("((((Age lt Age) and (Age lt 42)) and (Age le Age)) and (Age le 42))"); + } + softly.assertAll(); + } + + @Test + void testGuid() + { + final SimpleProperty.Guid id = new SimpleProperty.Guid<>(TestEntity.class, "Id"); + assertThat(id.equalTo(UUID.fromString("b3e130fe-d72c-4a5b-8dcf-463b497f985c")).getExpression(V4)) + .isEqualTo("(Id eq b3e130fe-d72c-4a5b-8dcf-463b497f985c)"); + } + + @Test + void testFilterableString() + { + final SoftAssertions softly = new SoftAssertions(); + final SimpleProperty.String name = new SimpleProperty.String<>(TestEntity.class, "Name"); + + softly.assertThat(name.equalTo((String) null).getExpression(V4)).isEqualTo("(Name eq null)"); + softly.assertThat(name.equalToNull().getExpression(V4)).isEqualTo("(Name eq null)"); + softly.assertThat(name.equalToNull().not().getExpression(V4)).isEqualTo("(not (Name eq null))"); + softly.assertThat(name.notEqualToNull().getExpression(V4)).isEqualTo("(Name ne null)"); + softly.assertThat(name.indexOf(name).getExpression(V4)).isEqualTo("indexof(Name,Name)"); + softly.assertThat(name.indexOf("Foo").getExpression(V4)).isEqualTo("indexof(Name,'Foo')"); + softly.assertThat(name.length().getExpression(V4)).isEqualTo("length(Name)"); + softly.assertThat(name.matches("Foo").getExpression(V4)).isEqualTo("matchesPattern(Name,'Foo')"); + softly.assertThat(name.toUpper().toLower().getExpression(V4)).isEqualTo("tolower(toupper(Name))"); + + softly + .assertThat(name.concat(name).concat("Foo").trim().getExpression(V4)) + .isEqualTo("trim(concat(concat(Name,Name),'Foo'))"); + + softly + .assertThat(name.substring(1).substring(2, 3).getExpression(V4)) + .isEqualTo("substring(substring(Name,1),2,3)"); + + softly + .assertThat(name.startsWith(name).and(name.startsWith("Foo")).getExpression(V4)) + .isEqualTo("(startswith(Name,Name) and startswith(Name,'Foo'))"); + + softly + .assertThat(name.endsWith(name).and(name.endsWith("Foo")).getExpression(V4)) + .isEqualTo("(endswith(Name,Name) and endswith(Name,'Foo'))"); + + softly + .assertThat(name.contains("Foo").and(name.contains(name)).getExpression(V4)) + .isEqualTo("(contains(Name,'Foo') and contains(Name,Name))"); + + softly.assertThat(name.equalTo("Qu'te").getExpression(V4)).isEqualTo("(Name eq 'Qu''te')"); + + softly.assertAll(); + } + + @Test + void testFilterableEnum() + { + final SoftAssertions softly = new SoftAssertions(); + softly + .assertThat(TestEntity.PROGRAMMING_LANGUAGE.equalToNull().getExpression(V4)) + .isEqualTo("(ProgrammingLanguage eq null)"); + softly + .assertThat(TestEntity.PROGRAMMING_LANGUAGE.notEqualToNull().getExpression(V4)) + .isEqualTo("(ProgrammingLanguage ne null)"); + softly + .assertThat(TestEntity.PROGRAMMING_LANGUAGE.equalTo((TestEntity.Language) null).getExpression(V4)) + .isEqualTo("(ProgrammingLanguage eq null)"); + softly + .assertThat(TestEntity.PROGRAMMING_LANGUAGE.notEqualTo((TestEntity.Language) null).getExpression(V4)) + .isEqualTo("(ProgrammingLanguage ne null)"); + softly + .assertThat(TestEntity.PROGRAMMING_LANGUAGE.equalTo(TestEntity.Language.Java).getExpression(V4)) + .isEqualTo("(ProgrammingLanguage eq SoftwareEngineer.Programming.Language'Java')"); + softly + .assertThat(TestEntity.PROGRAMMING_LANGUAGE.notEqualTo(TestEntity.Language.Java).getExpression(V4)) + .isEqualTo("(ProgrammingLanguage ne SoftwareEngineer.Programming.Language'Java')"); + softly + .assertThat(TestEntity.PROGRAMMING_LANGUAGE.equalTo(TestEntity.PROGRAMMING_LANGUAGE).getExpression(V4)) + .isEqualTo("(ProgrammingLanguage eq ProgrammingLanguage)"); + softly + .assertThat(TestEntity.PROGRAMMING_LANGUAGE.notEqualTo(TestEntity.PROGRAMMING_LANGUAGE).getExpression(V4)) + .isEqualTo("(ProgrammingLanguage ne ProgrammingLanguage)"); + softly.assertAll(); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/BooleanQueriesTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/BooleanQueriesTest.java new file mode 100644 index 0000000000..35f2b2e9b4 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/BooleanQueriesTest.java @@ -0,0 +1,46 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odatav4.core.GetAllRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; + +class BooleanQueriesTest +{ + @Test + void testGetFilteredWithNot() + { + final GetAllRequestBuilder query = + new DefaultTrippinService().getAllPeople().filter(Person.USER_NAME.length().greaterThanEqual(8).not()); + final String expectedUnencodedFilter = "(not (length(UserName) ge 8))"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$filter", expectedUnencodedFilter); + } + + @Test + void testGetFilteredWithAnd() + { + final GetAllRequestBuilder query = + new DefaultTrippinService() + .getAllPeople() + .filter(Person.FIRST_NAME.length().lessThanEqual(8).and(Person.LAST_NAME.length().lessThanEqual(8))); + final String expectedUnencodedFilter = "((length(FirstName) le 8) and (length(LastName) le 8))"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$filter", expectedUnencodedFilter); + } + + @Test + void testGetFilteredWithOr() + { + final GetAllRequestBuilder query = + new DefaultTrippinService() + .getAllPeople() + .filter(Person.FIRST_NAME.length().lessThanEqual(8).or(Person.LAST_NAME.length().lessThanEqual(8))); + final String expectedUnencodedFilter = "((length(FirstName) le 8) or (length(LastName) le 8))"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$filter", expectedUnencodedFilter); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/CollectionQueriesTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/CollectionQueriesTest.java new file mode 100644 index 0000000000..ff4c7f4216 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/CollectionQueriesTest.java @@ -0,0 +1,51 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odatav4.core.GetAllRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.PersonGender; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; + +class CollectionQueriesTest +{ + @Test + void testGetFilteredWithAny() + { + final GetAllRequestBuilder query = + new DefaultTrippinService() + .getAllPeople() + .filter(Person.TO_FRIENDS.any(Person.FIRST_NAME.equalTo("Angel"))); + final String expectedUnencodedFilter = "Friends/any(a:(a/FirstName eq 'Angel'))"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$filter", expectedUnencodedFilter); + } + + @Test + void testGetFilteredWithAll() + { + final GetAllRequestBuilder query = + new DefaultTrippinService() + .getAllPeople() + .filter(Person.TO_FRIENDS.all(Person.FIRST_NAME.equalTo("Angel"))); + final String expectedUnencodedFilter = "Friends/all(a:(a/FirstName eq 'Angel'))"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$filter", expectedUnencodedFilter); + } + + @Test + void testEnumFilter() + { + + final GetAllRequestBuilder query = + new DefaultTrippinService() + .getAllPeople() + .filter(Person.TO_FRIENDS.all(Person.GENDER.notEqualTo(PersonGender.MALE))); + + final String expectedUnencodedFilter = "Friends/all(a:(a/Gender ne Trippin.PersonGender'Male'))"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$filter", expectedUnencodedFilter); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ComparisonQueriesTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ComparisonQueriesTest.java new file mode 100644 index 0000000000..cdaa55102d --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ComparisonQueriesTest.java @@ -0,0 +1,112 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odatav4.core.GetAllRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; + +class ComparisonQueriesTest +{ + @Test + void testGetFilteredWithEquals() + { + final GetAllRequestBuilder query = + new DefaultTrippinService().getAllPeople().filter(Person.TO_TRIPS.length().equalTo(5)); + final String expectedUnencodedFilter = "(length(Trips) eq 5)"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$filter", expectedUnencodedFilter); + } + + @Test + void testGetFilteredWithNotEquals() + { + final GetAllRequestBuilder query = + new DefaultTrippinService().getAllPeople().filter(Person.TO_TRIPS.length().notEqualTo(10)); + final String expectedUnencodedFilter = "(length(Trips) ne 10)"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$filter", expectedUnencodedFilter); + } + + @Test + void testGetFilteredWithLessThan() + { + final GetAllRequestBuilder query = + new DefaultTrippinService().getAllPeople().filter(Person.TO_TRIPS.length().lessThan(2)); + final String expectedUnencodedFilter = "(length(Trips) lt 2)"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$filter", expectedUnencodedFilter); + } + + @Test + void testGetFilteredWithGreaterThan() + { + final GetAllRequestBuilder query = + new DefaultTrippinService().getAllPeople().filter(Person.TO_TRIPS.length().greaterThan(15)); + final String expectedUnencodedFilter = "(length(Trips) gt 15)"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$filter", expectedUnencodedFilter); + } + + @Test + void testGetFilteredWithLessThanOrEquals() + { + final GetAllRequestBuilder query = + new DefaultTrippinService().getAllPeople().filter(Person.TO_TRIPS.length().lessThanEqual(1)); + final String expectedUnencodedFilter = "(length(Trips) le 1)"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$filter", expectedUnencodedFilter); + } + + @Test + void testGetFilteredWithGreaterThanOrEquals() + { + final GetAllRequestBuilder query = + new DefaultTrippinService().getAllPeople().filter(Person.TO_TRIPS.length().greaterThanEqual(20)); + final String expectedUnencodedFilter = "(length(Trips) ge 20)"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$filter", expectedUnencodedFilter); + } + + @Test + void testGetFilteredWithInLiterals() + { + final GetAllRequestBuilder query = + new DefaultTrippinService().getAllPeople().filter(Person.USER_NAME.in("scottketchum", "javieralfred")); + final String expectedUnencodedFilter = "(UserName in ('scottketchum','javieralfred'))"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$filter", expectedUnencodedFilter); + } + + @Test + void testGetFilteredWithInSimpleCollection() + { + final GetAllRequestBuilder query = + new DefaultTrippinService().getAllPeople().filter(Person.USER_NAME.in(Person.EMAILS)); + final String expectedUnencodedFilter = "(UserName in Emails)"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$filter", expectedUnencodedFilter); + } + + @Test + void testGetFilteredWithInNavigationPropertyCollection() + { + final GetAllRequestBuilder query = + new DefaultTrippinService().getAllPeople().filter(Person.TO_BEST_FRIEND.in(Person.TO_FRIENDS)); + final String expectedUnencodedFilter = "(BestFriend in Friends)"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$filter", expectedUnencodedFilter); + } + + @Test + void testGetFilteredWithInEnumCollection() + { + final GetAllRequestBuilder query = + new DefaultTrippinService().getAllPeople().filter(Person.FAVORITE_FEATURE.in(Person.FEATURES)); + final String expectedUnencodedFilter = "(FavoriteFeature in Features)"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$filter", expectedUnencodedFilter); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/CountEntityUnitTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/CountEntityUnitTest.java new file mode 100644 index 0000000000..a502c39147 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/CountEntityUnitTest.java @@ -0,0 +1,110 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +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.okForContentType; +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 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.assertj.core.api.Assertions.assertThatCode; + +import javax.annotation.Nonnull; + +import org.apache.hc.core5.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.google.gson.JsonSyntaxException; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataDeserializationException; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.TrippinService; + +@WireMockTest +class CountEntityUnitTest +{ + private static final WireMockConfiguration WIREMOCK_CONFIGURATION = wireMockConfig().dynamicPort(); + + private DefaultHttpDestination destination; + private TrippinService service; + + private static final String COUNT_REQUEST_URL = + String.format("%s/%s/$count", TrippinService.DEFAULT_SERVICE_PATH, "People"); + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + service = new DefaultTrippinService().withServicePath(TrippinService.DEFAULT_SERVICE_PATH); + } + + @Test + void testSuccessfulCount() + { + stubFor( + get(urlPathEqualTo(COUNT_REQUEST_URL)) + .withQueryParam("$filter", equalTo("contains(FirstName,'Bar')")) + .withQueryParam("$search", equalTo("\"Foo\"")) + .willReturn(okForContentType(ContentType.TEXT_PLAIN.getMimeType(), "42"))); + + final Long countResponse = + service.countPeople().search("Foo").filter(Person.FIRST_NAME.contains("Bar")).execute(destination); + + verify(1, getRequestedFor(urlPathEqualTo(COUNT_REQUEST_URL))); + + assertThat(countResponse).isEqualTo(42L); + } + + @Test + void testFailCountNull() + { + stubFor( + get(urlPathEqualTo(COUNT_REQUEST_URL)) + .willReturn(okForContentType(ContentType.TEXT_PLAIN.getMimeType(), "null"))); + + assertThatCode(() -> service.countPeople().execute(destination)) + .isInstanceOf(ODataDeserializationException.class) + .matches(e -> e.getCause().getMessage().contains("null")); + } + + @Test + void testFailCountEmpty() + { + stubFor( + get(urlPathEqualTo(COUNT_REQUEST_URL)) + .willReturn(okForContentType(ContentType.TEXT_PLAIN.getMimeType(), ""))); + + assertThatCode(() -> service.countPeople().execute(destination)) + .isInstanceOf(ODataDeserializationException.class) + .matches(e -> e.getCause().getMessage().contains("null")); + } + + @Test + void testFailCountJson() + { + stubFor(get(urlPathEqualTo(COUNT_REQUEST_URL)).willReturn(okJson("{\"foo\":\"bar\"}"))); + assertThatCode(() -> service.countPeople().execute(destination)) + .isInstanceOf(ODataDeserializationException.class); + } + + @Test + void testFailCountNoInteger() + { + stubFor( + get(urlPathEqualTo(COUNT_REQUEST_URL)) + .willReturn(okForContentType(ContentType.TEXT_PLAIN.getMimeType(), "a"))); + + assertThatCode(() -> service.countPeople().execute(destination)) + .isInstanceOf(ODataDeserializationException.class) + .hasCauseInstanceOf(JsonSyntaxException.class) + .hasRootCauseExactlyInstanceOf(NumberFormatException.class); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ETagTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ETagTest.java new file mode 100644 index 0000000000..1b1f60844e --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ETagTest.java @@ -0,0 +1,375 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static com.github.tomakehurst.wiremock.client.WireMock.any; +import static com.github.tomakehurst.wiremock.client.WireMock.anyRequestedFor; +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.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.patch; +import static com.github.tomakehurst.wiremock.client.WireMock.patchRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +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.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import javax.annotation.Nonnull; + +import org.apache.hc.core5.http.HttpHeaders; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.datamodel.odatav4.TestUtility; +import com.sap.cloud.sdk.datamodel.odatav4.core.ModificationResponse; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.TrippinService; + +/** + * Tests the proper submission of the entity version identifier, transmitted as ETag in the HTTP headers. + */ +@WireMockTest +class ETagTest +{ + private static final String SERVICE_URL = "/some/service"; + private static final String ENTITY_COLLECTION = "People"; + private static final String GET_ALL_REQUEST_URL = SERVICE_URL + "/" + ENTITY_COLLECTION; + private static final String CREATE_URL = SERVICE_URL + "/" + ENTITY_COLLECTION; + private static final String GET_BY_KEY_REQUEST_URL = SERVICE_URL + "/" + ENTITY_COLLECTION + "('russellwhyte')"; + + private static final String GET_ALL_RESPONSE_BODY = readResourceFile("GetAllResponseBody.json"); + private static final String GET_BY_KEY_RESPONSE_BODY = readResourceFile("GetSingleResponseBody.json"); + + private static final String ETAG_RUSSELL = "W/\"123\""; + // the jetty under wiremock appends a '--gzip' to the ETag, therefore we need to check this adjusted ETag + private static final String ETAG_RUSSELL_GZIPPED = "W/\"123--gzip\""; + private static final String ETAG_RUSSELL_UPDATED = "W/\"456\""; + private static final String ETAG_SCOTT = "W/\"999\""; + + private static final String X_CSRF_TOKEN_HEADER_KEY = "x-csrf-token"; + private static final String X_CSRF_TOKEN_HEADER_FETCH_VALUE = "fetch"; + private static final String X_CSRF_TOKEN_HEADER_VALUE = "awesome-csrf-token"; + + private static final WireMockConfiguration WIREMOCK_CONFIGURATION = wireMockConfig().dynamicPort(); + + private DefaultHttpDestination destination; + private final TrippinService service = new DefaultTrippinService().withServicePath(SERVICE_URL); + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + + tellWiremockToReturnCsrfToken(); + } + + private void tellWiremockToReturnCsrfToken() + { + stubFor( + head(urlEqualTo(SERVICE_URL)) + .withHeader(X_CSRF_TOKEN_HEADER_KEY, equalTo(X_CSRF_TOKEN_HEADER_FETCH_VALUE)) + .willReturn(ok().withHeader(X_CSRF_TOKEN_HEADER_KEY, X_CSRF_TOKEN_HEADER_VALUE))); + } + + private static String readResourceFile( final String resourceFileName ) + { + return TestUtility.readResourceFile(ETagTest.class, resourceFileName); + } + + @Test + void testParseETagFromGetAllResponse() + { + stubFor(get(urlEqualTo(GET_ALL_REQUEST_URL)).willReturn(okJson(GET_ALL_RESPONSE_BODY))); + + final List result = service.getAllPeople().execute(destination); + assertThat(result.get(0).getVersionIdentifier().get()).isEqualTo(ETAG_RUSSELL); + assertThat(result.get(1).getVersionIdentifier().get()).isEqualTo(ETAG_SCOTT); + } + + @Test + void testParseETagFromCreateResponse() + { + stubFor( + post(urlEqualTo(CREATE_URL)).willReturn(okJson(GET_BY_KEY_RESPONSE_BODY).withHeader("ETag", ETAG_RUSSELL))); + final Person person = Person.builder().firstName("Russel").build(); + final ModificationResponse result = service.createPeople(person).execute(destination); + + assertThat( + result + .getModifiedEntity() + .getVersionIdentifier() + .getOrElseThrow( + () -> new AssertionError("Expected version identifier to be present on Create response."))) + .isEqualTo(ETAG_RUSSELL_GZIPPED); + } + + @Test + void testParseETagFromUpdateResponse() + { + stubFor( + patch(urlEqualTo(GET_BY_KEY_REQUEST_URL)) + .willReturn(okJson(GET_BY_KEY_RESPONSE_BODY).withHeader("ETag", ETAG_RUSSELL))); + final Person person = Person.builder().userName("russellwhyte").build(); + final ModificationResponse result = service.updatePeople(person).execute(destination); + + assertThat( + result + .getModifiedEntity() + .getVersionIdentifier() + .getOrElseThrow( + () -> new AssertionError("Expected version identifier to be present on Update response."))) + .isEqualTo(ETAG_RUSSELL_GZIPPED); + } + + @Test + void testParseETagFromGetByKeyResponse() + { + stubFor( + get(urlEqualTo(GET_BY_KEY_REQUEST_URL)) + .willReturn(okJson(GET_BY_KEY_RESPONSE_BODY).withHeader("ETag", ETAG_RUSSELL))); + + final Person russel = service.getPeopleByKey("russellwhyte").execute(destination); + + assertThat( + russel + .getVersionIdentifier() + .getOrElseThrow( + () -> new AssertionError("Expected version identifier to be present on GetByKey response."))) + .isEqualTo(ETAG_RUSSELL_GZIPPED); + } + + @Test + void testUpdateWithPATCHContainsEtagByDefault() + { + stubFor( + patch(urlEqualTo(GET_BY_KEY_REQUEST_URL)) + .withHeader(HttpHeaders.IF_MATCH, equalTo(ETAG_RUSSELL)) + .willReturn(noContent().withHeader("ETag", ETAG_RUSSELL_UPDATED))); + + final Person russel = new Person(); + russel.setUserName("russellwhyte"); + russel.setVersionIdentifier(ETAG_RUSSELL); + + final ModificationResponse result = service.updatePeople(russel).modifyingEntity().execute(destination); + + assertThat(russel.getVersionIdentifier()).containsExactly(ETAG_RUSSELL); // origin entity remains unchanged + assertThat(result.getUpdatedVersionIdentifier()).containsExactly(ETAG_RUSSELL_UPDATED); + assertThat(result.getModifiedEntity().getVersionIdentifier()).containsExactly(ETAG_RUSSELL_UPDATED); + + verify( + patchRequestedFor(urlEqualTo(GET_BY_KEY_REQUEST_URL)) + .withHeader(HttpHeaders.IF_MATCH, equalTo(ETAG_RUSSELL))); + } + + @Test + void testUpdateWithPATCHLacksETagIfDisabled() + { + stubFor( + patch(urlEqualTo(GET_BY_KEY_REQUEST_URL)).willReturn(noContent().withHeader("ETag", ETAG_RUSSELL_UPDATED))); + + final Person russel = new Person(); + russel.setUserName("russellwhyte"); + russel.setVersionIdentifier(ETAG_RUSSELL); + + final ModificationResponse result = + service.updatePeople(russel).modifyingEntity().disableVersionIdentifier().execute(destination); + + assertThat(russel.getVersionIdentifier()).containsExactly(ETAG_RUSSELL); // origin entity remains unchanged + assertThat(result.getUpdatedVersionIdentifier()).containsExactly(ETAG_RUSSELL_UPDATED); + assertThat(result.getModifiedEntity().getVersionIdentifier()).containsExactly(ETAG_RUSSELL_UPDATED); + + verify(patchRequestedFor(urlEqualTo(GET_BY_KEY_REQUEST_URL)).withoutHeader(HttpHeaders.IF_MATCH)); + } + + @Test + void testUpdateWithPATCHMatchesAnyETagIfChosen() + { + stubFor( + patch(urlEqualTo(GET_BY_KEY_REQUEST_URL)).willReturn(noContent().withHeader("ETag", ETAG_RUSSELL_UPDATED))); + + final Person russel = new Person(); + russel.setUserName("russellwhyte"); + russel.setVersionIdentifier(ETAG_RUSSELL); + + final ModificationResponse result = + service.updatePeople(russel).modifyingEntity().matchAnyVersionIdentifier().execute(destination); + + assertThat(russel.getVersionIdentifier()).containsExactly(ETAG_RUSSELL); // origin entity remains unchanged + assertThat(result.getUpdatedVersionIdentifier()).containsExactly(ETAG_RUSSELL_UPDATED); + assertThat(result.getModifiedEntity().getVersionIdentifier()).containsExactly(ETAG_RUSSELL_UPDATED); + + verify(patchRequestedFor(urlEqualTo(GET_BY_KEY_REQUEST_URL)).withHeader(HttpHeaders.IF_MATCH, equalTo("*"))); + } + + @Test + void testUpdateWithPUTContainsETagByDefault() + { + stubFor( + put(urlEqualTo(GET_BY_KEY_REQUEST_URL)) + .withHeader(HttpHeaders.IF_MATCH, equalTo(ETAG_RUSSELL)) + .willReturn(noContent().withHeader("ETag", ETAG_RUSSELL_UPDATED))); + + final Person russel = new Person(); + russel.setUserName("russellwhyte"); + russel.setVersionIdentifier(ETAG_RUSSELL); + + final ModificationResponse result = service.updatePeople(russel).replacingEntity().execute(destination); + + assertThat(russel.getVersionIdentifier()).containsExactly(ETAG_RUSSELL); // origin entity remains unchanged + assertThat(result.getUpdatedVersionIdentifier()).containsExactly(ETAG_RUSSELL_UPDATED); + assertThat(result.getModifiedEntity().getVersionIdentifier()).containsExactly(ETAG_RUSSELL_UPDATED); + + verify( + putRequestedFor(urlEqualTo(GET_BY_KEY_REQUEST_URL)) + .withHeader(HttpHeaders.IF_MATCH, equalTo(ETAG_RUSSELL))); + } + + @Test + void testUpdateWithPUTLacksETagIfDisabled() + { + stubFor( + put(urlEqualTo(GET_BY_KEY_REQUEST_URL)).willReturn(noContent().withHeader("ETag", ETAG_RUSSELL_UPDATED))); + + final Person russel = new Person(); + russel.setUserName("russellwhyte"); + russel.setVersionIdentifier(ETAG_RUSSELL); + + final ModificationResponse result = + service.updatePeople(russel).replacingEntity().disableVersionIdentifier().execute(destination); + + assertThat(russel.getVersionIdentifier()).containsExactly(ETAG_RUSSELL); // origin entity remains unchanged + assertThat(result.getUpdatedVersionIdentifier()).containsExactly(ETAG_RUSSELL_UPDATED); + assertThat(result.getModifiedEntity().getVersionIdentifier()).containsExactly(ETAG_RUSSELL_UPDATED); + + verify(putRequestedFor(urlEqualTo(GET_BY_KEY_REQUEST_URL)).withoutHeader(HttpHeaders.IF_MATCH)); + } + + @Test + void testUpdateWithPUTLacksMatchesAnyETagIfChosen() + { + stubFor( + put(urlEqualTo(GET_BY_KEY_REQUEST_URL)).willReturn(noContent().withHeader("ETag", ETAG_RUSSELL_UPDATED))); + + final Person russel = new Person(); + russel.setUserName("russellwhyte"); + russel.setVersionIdentifier(ETAG_RUSSELL); + + final ModificationResponse result = + service.updatePeople(russel).replacingEntity().matchAnyVersionIdentifier().execute(destination); + + assertThat(russel.getVersionIdentifier()).containsExactly(ETAG_RUSSELL); // origin entity remains unchanged + assertThat(result.getUpdatedVersionIdentifier()).containsExactly(ETAG_RUSSELL_UPDATED); + assertThat(result.getModifiedEntity().getVersionIdentifier()).containsExactly(ETAG_RUSSELL_UPDATED); + + verify(putRequestedFor(urlEqualTo(GET_BY_KEY_REQUEST_URL)).withHeader(HttpHeaders.IF_MATCH, equalTo("*"))); + } + + @Test + void testDeleteContainsETagByDefault() + { + stubFor( + delete(urlEqualTo(GET_BY_KEY_REQUEST_URL)) + .withHeader(HttpHeaders.IF_MATCH, equalTo(ETAG_RUSSELL)) + .willReturn(noContent())); + + final Person russel = new Person(); + russel.setUserName("russellwhyte"); + russel.setVersionIdentifier(ETAG_RUSSELL); + + service.deletePeople(russel).execute(destination); + + verify( + deleteRequestedFor(urlEqualTo(GET_BY_KEY_REQUEST_URL)) + .withHeader(HttpHeaders.IF_MATCH, equalTo(ETAG_RUSSELL))); + } + + @Test + void testDeleteLacksEtagIfDisabled() + { + stubFor(delete(urlEqualTo(GET_BY_KEY_REQUEST_URL)).willReturn(noContent())); + + final Person russel = new Person(); + russel.setUserName("russellwhyte"); + russel.setVersionIdentifier(ETAG_RUSSELL); + + service.deletePeople(russel).disableVersionIdentifier().execute(destination); + + verify(deleteRequestedFor(urlEqualTo(GET_BY_KEY_REQUEST_URL)).withoutHeader(HttpHeaders.IF_MATCH)); + } + + @Test + void testDeleteMatchesAnyETagIfChosen() + { + stubFor(delete(urlEqualTo(GET_BY_KEY_REQUEST_URL)).willReturn(noContent())); + + final Person russel = new Person(); + russel.setUserName("russellwhyte"); + russel.setVersionIdentifier(ETAG_RUSSELL); + + service.deletePeople(russel).matchAnyVersionIdentifier().execute(destination); + + verify(deleteRequestedFor(urlEqualTo(GET_BY_KEY_REQUEST_URL)).withHeader(HttpHeaders.IF_MATCH, equalTo("*"))); + } + + @Test + void testNoETagReceived() + { + stubFor(get(urlEqualTo(GET_BY_KEY_REQUEST_URL)).willReturn(okJson(GET_BY_KEY_RESPONSE_BODY))); + final Person person = service.getPeopleByKey("russellwhyte").execute(destination); + + assertThat(person.getVersionIdentifier()).isEmpty(); + } + + @Test + void testNoIfMatchIsSentOnEmptyETag() + { + stubFor(any(urlEqualTo(GET_BY_KEY_REQUEST_URL)).willReturn(noContent())); + + final Person person = new Person(); + person.setUserName("russellwhyte"); + + service.updatePeople(person).execute(destination); + service.deletePeople(person).execute(destination); + + verify(2, anyRequestedFor(urlEqualTo(GET_BY_KEY_REQUEST_URL)).withoutHeader(HttpHeaders.IF_MATCH)); + } + + @Test + void testOverwritingExistingETagOnEntity() + { + stubFor( + any(urlEqualTo(GET_BY_KEY_REQUEST_URL)) + .withHeader(HttpHeaders.IF_MATCH, equalTo(ETAG_RUSSELL_UPDATED)) + .willReturn(noContent())); + + final Person person = new Person(); + person.setUserName("russellwhyte"); + person.setVersionIdentifier(ETAG_RUSSELL); + + service.deletePeople(person).withHeader(HttpHeaders.IF_MATCH, ETAG_RUSSELL_UPDATED).execute(destination); + service + .updatePeople(person) + .modifyingEntity() + .withHeader(HttpHeaders.IF_MATCH, ETAG_RUSSELL_UPDATED) + .execute(destination); + + verify( + 2, + anyRequestedFor(urlEqualTo(GET_BY_KEY_REQUEST_URL)) + .withHeader(HttpHeaders.IF_MATCH, equalTo(ETAG_RUSSELL_UPDATED))); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/EncodedParameterTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/EncodedParameterTest.java new file mode 100644 index 0000000000..dad74c4170 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/EncodedParameterTest.java @@ -0,0 +1,21 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odatav4.core.GetByKeyRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; + +class EncodedParameterTest +{ + @Test + void testGetByKeyWithSpecialCharacters() + { + final String fullQueryWithSpecialCharacters = + DefaultTrippinService.DEFAULT_SERVICE_PATH + "/People('" + "test%2F%3F%20%23&user''%25$" + "')"; + final GetByKeyRequestBuilder request = new DefaultTrippinService().getPeopleByKey("test/? #&user'%$"); + assertThat(request.toRequest().getRelativeUri()).hasToString(fullQueryWithSpecialCharacters); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/EncodedQueryTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/EncodedQueryTest.java new file mode 100644 index 0000000000..bba02668a9 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/EncodedQueryTest.java @@ -0,0 +1,139 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead; +import com.sap.cloud.sdk.datamodel.odatav4.core.GetAllRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Trip; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; + +class EncodedQueryTest +{ + @Test + void testEncodingInFilter() + { + final ODataRequestRead query = + new DefaultTrippinService() + .getAllPeople() + .filter( + Person.USER_NAME + .length() + .greaterThanEqual(8) + .not() + .and(Person.FIRST_NAME.length().greaterThanEqual(10)) + .or(Person.LAST_NAME.length().lessThanEqual(7))) + .toRequest(); + + final String expectedEncodedQuery = + "$filter=(((not%20(length(UserName)%20ge%208))%20and%20(length(FirstName)%20ge%2010))%20or%20(length(LastName)%20le%207))"; + + assertThat(query.getRequestQuery()).isEqualTo(expectedEncodedQuery); + } + + @Test + void testSafeCharsInFilter() + { + final ODataRequestRead query = + new DefaultTrippinService().getAllPeople().filter(Person.USER_NAME.contains("_*-:,/'().")).toRequest(); + + final String expectedEncodedQuery = "$filter=contains(UserName,'_*-:,/''().')"; + + assertThat(query.getRequestQuery()).isEqualTo(expectedEncodedQuery); + } + + @Test + void testSpecialCharsInFilter() + { + final ODataRequestRead query = + new DefaultTrippinService().getAllPeople().filter(Person.USER_NAME.contains("!@#$%^&=+|\\\"")).toRequest(); + + final String expectedEncodedQuery = "$filter=contains(UserName,'%21%40%23%24%25%5E%26%3D%2B%7C%5C%22')"; + + assertThat(query.getRequestQuery()).isEqualTo(expectedEncodedQuery); + } + + @Test + void testEncodingForeignCharactersInFilter() + { + final GetAllRequestBuilder query = + new DefaultTrippinService() + .getAllPeople() + .filter( + Person.USER_NAME + .equalTo("François") + .and(Person.LAST_NAME.contains("Sørina").or(Person.FIRST_NAME.matches("维基百科")))); + + final String expectedEncodedQuery = + "$filter=((UserName%20eq%20'Fran%C3%A7ois')%20and%20(contains(LastName,'S%C3%B8rina')%20or%20matchesPattern(FirstName,'%E7%BB%B4%E5%9F%BA%E7%99%BE%E7%A7%91')))"; + + assertThat(query.toRequest().getRequestQuery()).isEqualTo(expectedEncodedQuery); + } + + @Test + void testEncodingInNestedFiltersWithAllSpecialCharacters() + { + final ODataRequestRead request = + new DefaultTrippinService() + .getAllPeople() + .filter(Person.FIRST_NAME.contains("% $&#?\"\\+'")) + .select(Person.TO_BEST_FRIEND.select(Person.TO_TRIPS.filter(Trip.NAME.contains("% $&#?\"\\+'")))) + .toRequest(); + final String expected = + "$expand=BestFriend($expand=Trips($filter=contains(Name,'%25%20%24%26%23%3F%22%5C%2B''')))&$filter=contains(FirstName,'%25%20%24%26%23%3F%22%5C%2B''')"; + + assertThat(request.getRequestQuery()).isEqualTo(expected); + } + + @Test + void testEncodingInSearchQuery() + { + final ODataRequestRead search = + new DefaultTrippinService().getAllPeople().search("Hash # Quoted \"string\" Escaped \\").toRequest(); + final String query = search.getRequestQuery(); + + final String expected = "$search=%22Hash%20%23%20Quoted%20%5C%22string%5C%22%20Escaped%20%5C%5C%22"; + + assertThat(query).isEqualTo(expected); + } + + @Test + void testEncodingInOrderBy() + { + final ODataRequestRead request = + new DefaultTrippinService() + .getAllPeople() + .orderBy(Person.FIRST_NAME.asc(), Person.LAST_NAME.desc()) + .toRequest(); + + final String expected = "$orderby=FirstName%20asc,LastName%20desc"; + + assertThat(request.getRequestQuery()).isEqualTo(expected); + } + + @Test + void testEncodingInCustomQueryParametersWithAllSpecialCharacters() + { + final ODataRequestRead request = + new DefaultTrippinService() + .getAllPeople() + .withQueryParameter("foo", "hash#tag") + .withQueryParameter("param", "% $&#?\"\\+'bar") + .toRequest(); + final String expected = "foo=hash%23tag¶m=%25%20%24%26%23%3F%22%5C%2B'bar"; + + assertThat(request.getRequestQuery()).isEqualTo(expected); + } + + @Test + void testSafeCharsInCustomQueryParameters() + { + final ODataRequestRead request = + new DefaultTrippinService().getAllPeople().withQueryParameter("foo", "_*-:,/'().").toRequest(); + final String expected = "foo=_*-:,/'()."; + + assertThat(request.getRequestQuery()).isEqualTo(expected); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataClientBatchReferenceServiceIntegrationTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataClientBatchReferenceServiceIntegrationTest.java new file mode 100644 index 0000000000..4b590ba9e0 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataClientBatchReferenceServiceIntegrationTest.java @@ -0,0 +1,130 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ParseException; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import com.google.gson.Gson; +import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; +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.ODataRequestException; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestBatch; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestCreate; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultMultipartGeneric; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; + +@Disabled( "Test runs against a v4 reference service on odata.org. Use it only to manually verify behaviour." ) +class ODataClientBatchReferenceServiceIntegrationTest +{ + private Destination httpDestination; + + @BeforeEach + void configure() + throws IOException, + ParseException + { + httpDestination = TripPinUtility.getDestination(); + } + + @Test + void testEmptyBatch() + throws IOException, + ParseException + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(httpDestination); + + final ODataRequestResultMultipartGeneric batchResponse = + new ODataRequestBatch("/", ODataProtocol.V4).execute(httpClient); + + // response object not null + assertThat(batchResponse).isNotNull(); + + // response HTTP code is healthy + final ClassicHttpResponse httpResponse = batchResponse.getHttpResponse(); + assertThat(httpResponse.getCode()).isEqualTo(200); + + // response payload can be extracted + final String response = EntityUtils.toString(batchResponse.getHttpResponse().getEntity()); + assertThat(response).matches("^--batchresponse_[a-f0-9-]+--\r\n$"); + } + + @Test + void testBatchWithSingleRead() + throws IOException, + ParseException + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(httpDestination); + + final ODataRequestResultMultipartGeneric batchResponse = + new ODataRequestBatch("/", ODataProtocol.V4) + .addRead(new ODataRequestRead("/", "People", "$top=1", ODataProtocol.V4)) + .execute(httpClient); + + // response object not null + assertThat(batchResponse).isNotNull(); + + // response HTTP code is healthy + final ClassicHttpResponse httpResponse = batchResponse.getHttpResponse(); + assertThat(httpResponse.getCode()).isEqualTo(200); + + // response payload can be extracted + final String response = EntityUtils.toString(batchResponse.getHttpResponse().getEntity()); + assertThat(response).isNotEmpty(); + + // response payload contains expected JSON result + final Matcher matcher = Pattern.compile("\"value\":\\[(.*?)]}\r\n").matcher(response); + assertThat(matcher.find()).isTrue(); + + // response JSON contains a valid Person + final Person person = new Gson().fromJson(matcher.group(1), Person.class); + assertThat(person).isNotNull().matches(p -> p.getUserName() != null); + } + + @Test + void testBatchWithReadWrite() + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(httpDestination); + + final ODataRequestResultMultipartGeneric batchResponse = + new ODataRequestBatch("/", ODataProtocol.V4) + .addRead(new ODataRequestRead("/", "People", "$top=1", ODataProtocol.V4)) + .beginChangeset() + .addCreate(new ODataRequestCreate("/", "People", "{\"UserName\":\"JohnDoe1\"}", ODataProtocol.V4)) + .endChangeset() + .execute(httpClient); + + // response object not null + assertThat(batchResponse).isNotNull(); + + // response HTTP code is healthy + final ClassicHttpResponse httpResponse = batchResponse.getHttpResponse(); + assertThat(httpResponse.getCode()).isEqualTo(200); + } + + @Test + void testBatchErrorWithDifferentServicePath() + { + final HttpClient httpClient = mock(HttpClient.class); + + assertThatCode( + () -> new ODataRequestBatch("this/", ODataProtocol.V4) + .addRead(new ODataRequestRead("this/", "People", "$top=1", ODataProtocol.V4)) + .addRead(new ODataRequestRead("other/", "People", "$top=2", ODataProtocol.V4)) + .execute(httpClient)) + .isInstanceOf(ODataRequestException.class); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataClientBatchResponseParsingIntegrationTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataClientBatchResponseParsingIntegrationTest.java new file mode 100644 index 0000000000..0caf4ca4da --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataClientBatchResponseParsingIntegrationTest.java @@ -0,0 +1,142 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.io.IOException; +import java.util.List; + +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 org.junit.jupiter.api.BeforeEach; +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.Destination; +import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestBatch; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestCreate; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestRead; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestResultMultipartGeneric; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; + +@Disabled( "Test runs against a v4 reference service on odata.org. Use it only to manually verify behaviour." ) +class ODataClientBatchResponseParsingIntegrationTest +{ + private Destination httpDestination; + + @BeforeEach + void configure() + throws IOException + { + httpDestination = TripPinUtility.getDestination(); + } + + @Test + void testEmptyBatch() + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(httpDestination); + + final ODataRequestResultMultipartGeneric batchResponse = + new ODataRequestBatch("/", ODataProtocol.V4).execute(httpClient); + + // response object not null + assertThat(batchResponse).isNotNull(); + + // extract unrequested read result + final ODataRequestRead read = new ODataRequestRead("/", "People", "$top=1", ODataProtocol.V4); + assertThatCode(() -> batchResponse.getResult(read)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testBatchWithReads() + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(httpDestination); + + final ODataRequestRead read1 = new ODataRequestRead("/", "People", "$top=1", ODataProtocol.V4); + final ODataRequestRead read2 = new ODataRequestRead("/", "People", "$top=2&$skip=1", ODataProtocol.V4); + final ODataRequestResultMultipartGeneric batchResponse = + new ODataRequestBatch("/", ODataProtocol.V4).addRead(read1).addRead(read2).execute(httpClient); + + // response object not null + assertThat(batchResponse).isNotNull(); + + // response HTTP code is healthy + assertThat(batchResponse.getHttpResponse().getCode()).isEqualTo(200); + + // response payload can be extracted + final List people1 = batchResponse.getResult(read1).asList(Person.class); + assertThat(people1).hasSize(1).doesNotContainNull(); + + final List people2 = batchResponse.getResult(read2).asList(Person.class); + assertThat(people2).hasSize(2).doesNotContainNull().doesNotContainAnyElementsOf(people1); + } + + @Test + void testBatchWithReadsAndWrites() + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(httpDestination); + + final ODataRequestRead read1 = new ODataRequestRead("/", "People", "$top=1", ODataProtocol.V4); + final ODataRequestRead read2 = new ODataRequestRead("/", "People", "$top=2&$skip=1", ODataProtocol.V4); + + final ODataRequestCreate create1 = + new ODataRequestCreate( + "/", + "People", + "{\"UserName\":\"JohnDoe1\", \"FirstName\":\"John\"}", + ODataProtocol.V4); + final ODataRequestCreate create2 = + new ODataRequestCreate( + "/", + "People", + "{\"UserName\":\"JohnDoe2\", \"FirstName\":\"John\"}", + ODataProtocol.V4); + + final ODataRequestResultMultipartGeneric batchResponse = + new ODataRequestBatch("/", ODataProtocol.V4) + .addRead(read1) + .beginChangeset() + .addCreate(create1) + .addCreate(create2) + .endChangeset() + .addRead(read2) + .execute(httpClient); + + // response object not null + assertThat(batchResponse).isNotNull(); + + // response parsing + final List resultRead1 = batchResponse.getResult(read1).asList(Person.class); + final List resultRead2 = batchResponse.getResult(read2).asList(Person.class); + assertThat(resultRead1).isNotNull().hasSize(1).doesNotContainNull().doesNotContainAnyElementsOf(resultRead2); + assertThat(resultRead2).isNotNull().hasSize(2).doesNotContainNull().doesNotContainAnyElementsOf(resultRead1); + + assertThat(batchResponse.getResult(create1).as(Person.class)).isNotNull(); + assertThat(batchResponse.getResult(create2).as(Person.class)).isNotNull(); + + // response HTTP code is healthy + final ClassicHttpResponse httpResponse = batchResponse.getHttpResponse(); + assertThat(httpResponse.getCode()).isEqualTo(200); + } + + @Test + void testBatchResultWithError() + throws IOException + { + final HttpClient httpClient = ApacheHttpClient5Accessor.getHttpClient(httpDestination); + + final ODataRequestRead read = new ODataRequestRead("/", "People", "$top=1", ODataProtocol.V4); + + final ODataRequestResultMultipartGeneric batchResponse = + new ODataRequestBatch("/", ODataProtocol.V4).addRead(read).execute(httpClient); + + // consume result + EntityUtils.consume(batchResponse.getHttpResponse().getEntity()); + + // extract unrequested read result + assertThatCode(() -> batchResponse.getResult(read)).isInstanceOf(IllegalStateException.class); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataClientBatchResponseParsingUnitTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataClientBatchResponseParsingUnitTest.java new file mode 100644 index 0000000000..28ce581daa --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataClientBatchResponseParsingUnitTest.java @@ -0,0 +1,399 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol.V4; +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.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +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.client5.http.classic.methods.HttpPost; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.io.entity.EntityUtils; +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.google.common.base.Objects; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataResponseException; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataServiceErrorException; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestBatch; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestCreate; +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.datamodel.odata.client.request.ODataRequestResultMultipartGeneric; +import com.sap.cloud.sdk.datamodel.odatav4.TestUtility; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; + +import io.vavr.control.Try; +import lombok.Getter; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +class ODataClientBatchResponseParsingUnitTest +{ + private final AtomicInteger uuidCounter = new AtomicInteger(0); + private final Supplier uuidProvider = () -> new UUID(0, uuidCounter.incrementAndGet()); + + @Test + void testEmptyBatch() + { + // Read OData response json + final String requestBody = readResourceFileCrlf("BatchEmptyRequest.txt"); + final String responseBody = readResourceFileCrlf("BatchEmptyResponse.txt"); + + // Prepare test objects + final ODataRequestBatch batchRequest = new ODataRequestBatch("/", V4, uuidProvider); + + final HttpClient httpClient = MockedHttpClient.of(requestBody, responseBody); + final ODataRequestResultMultipartGeneric batchResponse = batchRequest.execute(httpClient); + + // Test assertion: response object not null and healthy + assertThat(batchResponse).isNotNull(); + assertThat(batchResponse.getHttpResponse().getCode()).isEqualTo(200); + + // extract unrequested read result + final ODataRequestRead read = new ODataRequestRead("/", "People", "$top=1", V4); + assertThatCode(() -> batchResponse.getResult(read)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testBatchWithOnlyReads() + { + // Read OData response json + final String requestBody = readResourceFileCrlf("BatchOnlyReadsRequest.txt"); + final String responseBody = readResourceFileCrlf("BatchOnlyReadsResponse.txt"); + + // Prepare test objects + final ODataRequestRead read1 = new ODataRequestRead("/", "People", "$top=1", V4); + final ODataRequestRead read2 = new ODataRequestRead("/", "People", "$top=2&$skip=1", V4); + final ODataRequestBatch batchRequest = + new ODataRequestBatch("/", V4, uuidProvider).addRead(read1).addRead(read2); + + final HttpClient httpClient = MockedHttpClient.of(requestBody, responseBody); + final ODataRequestResultMultipartGeneric batchResponse = batchRequest.execute(httpClient); + + // Test assertion: response object not null and healthy + assertThat(batchResponse).isNotNull(); + assertThat(batchResponse.getHttpResponse().getCode()).isEqualTo(200); + + // Test assertion: response payload can be extracted + final List resultRead1 = batchResponse.getResult(read1).asList(Person.class); + final List resultRead2 = batchResponse.getResult(read2).asList(Person.class); + assertThat(resultRead1).isNotNull().hasSize(1).doesNotContainNull().doesNotContainAnyElementsOf(resultRead2); + assertThat(resultRead2).isNotNull().hasSize(2).doesNotContainNull().doesNotContainAnyElementsOf(resultRead1); + } + + @Test + void testBatchWithReadsOnMissingResponse() + { + // 2 requests but only 1 response + final String requestBody = readResourceFileCrlf("BatchOnlyReadsMissingRequest.txt"); + final String responseBody = readResourceFileCrlf("BatchOnlyReadsMissingResponse.txt"); + + // Prepare test objects + final ODataEntityKey entityKey1 = new ODataEntityKey(V4).addKeyProperty("key", "one"); + final ODataRequestReadByKey readByKey1 = new ODataRequestReadByKey("/", "People", entityKey1, "", V4); + final ODataEntityKey entityKey2 = new ODataEntityKey(V4).addKeyProperty("key", "two"); + final ODataRequestReadByKey readByKey2 = new ODataRequestReadByKey("/", "People", entityKey2, "", V4); + final ODataEntityKey entityKey3 = new ODataEntityKey(V4).addKeyProperty("key", "three"); + final ODataRequestReadByKey readByKey3 = new ODataRequestReadByKey("/", "People", entityKey3, "", V4); + final ODataRequestBatch batchRequest = + new ODataRequestBatch("/", V4, uuidProvider) + .addReadByKey(readByKey1) + .addReadByKey(readByKey2) + .addReadByKey(readByKey3); + + final HttpClient httpClient = MockedHttpClient.of(requestBody, responseBody); + final ODataRequestResultMultipartGeneric batchResponse = batchRequest.execute(httpClient); + + // Test assertion: response object not null and healthy + assertThat(batchResponse).isNotNull(); + assertThat(batchResponse.getHttpResponse().getCode()).isEqualTo(200); + + // Test assertion: + // response payload1 is 200 + assertThat(batchResponse.getResult(readByKey1)).isNotNull(); + + // response payload2 is 404 + assertThatExceptionOfType(ODataServiceErrorException.class) + .isThrownBy(() -> batchResponse.getResult(readByKey2)) + .satisfies(e -> assertThat(e.getHttpCode()).isEqualTo(404)); + + // response payload3 cannot be extracted, response is missing + assertThatExceptionOfType(ODataResponseException.class) + .isThrownBy(() -> batchResponse.getResult(readByKey3)) + .withMessage("Unable to extract batch response item at position 3. The response contains only 2 items."); + } + + @Test + void testBatchWithErrorReads() + { + // Read OData response json + final String requestBody = readResourceFileCrlf("BatchOnlyReadsRequest.txt"); + final String responseBody = readResourceFileCrlf("BatchOnlyReadsErrorResponse.txt"); + + // Prepare test objects + final ODataRequestRead read1 = new ODataRequestRead("/", "People", "$top=1", V4); + final ODataRequestRead read2 = new ODataRequestRead("/", "People", "$top=2&$skip=1", V4); + final ODataRequestBatch batchRequest = + new ODataRequestBatch("/", V4, uuidProvider).addRead(read1).addRead(read2); + + final HttpClient httpClient = MockedHttpClient.of(requestBody, responseBody); + final ODataRequestResultMultipartGeneric batchResponse = batchRequest.execute(httpClient); + + // Test assertion: response object not null and healthy + assertThat(batchResponse).isNotNull(); + assertThat(batchResponse.getHttpResponse().getCode()).isEqualTo(200); + + // Test assertion: response payload can be extracted + assertThat(batchResponse.getResult(read1)).isNotNull(); + + // batchResponse.getResult(read2); // expected error: + assertThatExceptionOfType(ODataServiceErrorException.class) + .isThrownBy(() -> batchResponse.getResult(read2)) + .satisfies(e -> { + assertThat(e.getHttpCode()).isEqualTo(400); + assertThat(e.getOdataError().getODataCode()).isEqualTo("ZCU/100"); + }); + } + + @Test + void testBatchWithReadsAndWrites() + { + // Read OData response json + final String requestBody = readResourceFileCrlf("BatchReadsAndWritesSuccessRequest.txt"); + final String responseBody = readResourceFileCrlf("BatchReadsAndWritesSuccessResponse.txt"); + + // Prepare test objects + final ODataRequestCreate create1 = + new ODataRequestCreate("/", "People", "{\"UserName\":\"JohnDoe1\", \"FirstName\":\"John\"}", V4); + final ODataRequestCreate create2 = + new ODataRequestCreate("/", "People", "{\"UserName\":\"JohnDoe2\", \"FirstName\":\"John\"}", V4); + final ODataEntityKey entityKey = new ODataEntityKey(V4).addKeyProperty("key", "foo"); + final ODataRequestReadByKey readByKey = new ODataRequestReadByKey("/", "People", entityKey, "", V4); + final ODataRequestBatch batchRequest = + new ODataRequestBatch("/", V4, uuidProvider) + .beginChangeset() + .addCreate(create1) + .addCreate(create2) + .endChangeset() + .addReadByKey(readByKey); + + final HttpClient httpClient = MockedHttpClient.of(requestBody, responseBody); + final ODataRequestResultMultipartGeneric batchResponse = batchRequest.execute(httpClient); + + // Test assertion: response object not null and healthy + assertThat(batchResponse).isNotNull(); + assertThat(batchResponse.getHttpResponse().getCode()).isEqualTo(200); + + // Test assertion: response parsing + final ODataRequestResultGeneric resultCreate1 = batchResponse.getResult(create1); + assertThat(resultCreate1).isNotNull(); + assertThat(resultCreate1.as(Person.class)).isNotNull().extracting(Person::getUserName).isEqualTo("JohnDoe1"); + assertThat(resultCreate1.getHttpResponse().getCode()).isEqualTo(201); + + final ODataRequestResultGeneric resultCreate2 = batchResponse.getResult(create2); + assertThat(resultCreate2).isNotNull(); + assertThat(resultCreate2.as(Person.class)).isNotNull().extracting(Person::getUserName).isEqualTo("JohnDoe2"); + assertThat(resultCreate2.getHttpResponse().getCode()).isEqualTo(201); + + assertThatExceptionOfType(ODataServiceErrorException.class) + .isThrownBy(() -> batchResponse.getResult(readByKey)) + .satisfies(e -> assertThat(e.getHttpCode()).isEqualTo(404)); + } + + @Test + void testBatchWithErrorInChangeset() + throws IOException + { + // Read OData response json + final String requestBody = readResourceFileCrlf("BatchReadsAndWritesErrorRequest.txt"); + final String responseBody = readResourceFileCrlf("BatchReadsAndWritesErrorResponse.txt"); + + // Prepare test objects + final ODataRequestCreate create1 = + new ODataRequestCreate("/", "People", "{\"UserName\":\"JohnDoe1\", \"FirstName\":\"John\"}", V4); + final ODataRequestCreate create2 = new ODataRequestCreate("/", "People", "{\"UserName\":\"JohnDoe2\"}", V4); + final ODataEntityKey entityKey = new ODataEntityKey(V4).addKeyProperty("key", "klauskinski"); + final ODataRequestReadByKey readByKey = new ODataRequestReadByKey("/", "People", entityKey, "", V4); + final ODataRequestBatch requestBatch = + new ODataRequestBatch("/", V4, uuidProvider) + .beginChangeset() + .addCreate(create1) + .addCreate(create2) + .endChangeset() + .addReadByKey(readByKey); + + final HttpClient httpClient = MockedHttpClient.of(requestBody, responseBody); + final ODataRequestResultMultipartGeneric batchResponse = requestBatch.execute(httpClient); + + // Test assertion: response object not null and healthy + assertThat(batchResponse).isNotNull(); + assertThat(batchResponse.getHttpResponse().getCode()).isEqualTo(200); + + // Test assertion: response parsing + assertThatExceptionOfType(ODataServiceErrorException.class) + .isThrownBy(() -> batchResponse.getResult(create1)) + .satisfies(e -> { + assertThat(e.getHttpCode()).isEqualTo(400); + assertThat(e.getHttpBody().get()).contains("The FirstName field is required"); + assertThat(e.getRequest()).isSameAs(create2); + }); + + assertThatExceptionOfType(ODataServiceErrorException.class) + .isThrownBy(() -> batchResponse.getResult(create2)) + .satisfies(e -> { + assertThat(e.getHttpCode()).isEqualTo(400); + assertThat(e.getHttpBody().get()).contains("The FirstName field is required"); + assertThat(e.getRequest()).isSameAs(create2); + }); + + final ODataRequestResultGeneric resultReadByKey = batchResponse.getResult(readByKey); + assertThat(resultReadByKey).isNotNull(); + assertThat(resultReadByKey.getHttpResponse().getCode()).isEqualTo(200); + } + + @Test + void testBatchWithErrorInsteadOfChangeset() + throws IOException + { + // Read OData response json + final String requestBody = readResourceFileCrlf("BatchReadsAndWritesErrorRequest.txt"); + final String responseBody = readResourceFileCrlf("BatchReadsAndWritesErrorResponseWithoutChangeset.txt"); + + // Prepare test objects + final ODataRequestCreate create1 = + new ODataRequestCreate("/", "People", "{\"UserName\":\"JohnDoe1\", \"FirstName\":\"John\"}", V4); + final ODataRequestCreate create2 = new ODataRequestCreate("/", "People", "{\"UserName\":\"JohnDoe2\"}", V4); + final ODataEntityKey entityKey = new ODataEntityKey(V4).addKeyProperty("key", "klauskinski"); + final ODataRequestReadByKey readByKey = new ODataRequestReadByKey("/", "People", entityKey, "", V4); + final ODataRequestBatch batchRequest = + new ODataRequestBatch("/", V4, uuidProvider) + .beginChangeset() + .addCreate(create1) + .addCreate(create2) + .endChangeset() + .addReadByKey(readByKey); + + final HttpClient httpClient = MockedHttpClient.of(requestBody, responseBody); + final ODataRequestResultMultipartGeneric batchResponse = batchRequest.execute(httpClient); + + // Test assertion: response object not null and healthy + assertThat(batchResponse).isNotNull(); + assertThat(batchResponse.getHttpResponse().getCode()).isEqualTo(200); + + // Test assertion: response parsing + assertThatExceptionOfType(ODataResponseException.class) + .isThrownBy(() -> batchResponse.getResult(create1)) + .satisfies(e -> { + assertThat(e.getHttpCode()).isEqualTo(400); + assertThat(e.getHttpBody().get()).contains("The FirstName field is required"); + assertThat(e.getRequest()).isSameAs(create2); + }); + + assertThatExceptionOfType(ODataResponseException.class) + .isThrownBy(() -> batchResponse.getResult(create2)) + .satisfies(e -> { + assertThat(e.getHttpCode()).isEqualTo(400); + assertThat(e.getHttpBody().get()).contains("The FirstName field is required"); + assertThat(e.getRequest()).isSameAs(create2); + }); + + final ODataRequestResultGeneric resultReadByKey = batchResponse.getResult(readByKey); + assertThat(resultReadByKey).isNotNull(); + assertThat(resultReadByKey.getHttpResponse().getCode()).isEqualTo(200); + } + + @Test + void testBatchResultWithError() + { + // Prepare test objects + final ODataRequestRead read = new ODataRequestRead("/", "People", "$top=1", V4); + final ODataRequestBatch batchRequest = new ODataRequestBatch("/", V4, uuidProvider).addRead(read); + + final HttpClient httpClient = MockedHttpClient.of(null, null); + final ODataRequestResultMultipartGeneric batchResponse = batchRequest.execute(httpClient); + + assertThat(batchResponse.getHttpResponse().getEntity()).isNull(); + + // Extract requested read result - entity already consumed + assertThatCode(() -> batchResponse.getResult(read)).isInstanceOf(ODataResponseException.class); + } + + private static class MockedHttpClient + { + @Getter + private final HttpClient httpClient = mock(HttpClient.class); + + @SneakyThrows + static HttpClient of( @Nullable final String requestBody, @Nullable final String responseBody ) + { + final ClassicHttpResponse odataResponse = new BasicClassicHttpResponse(200, "OK"); + if( responseBody != null ) { + odataResponse.setEntity(new StringEntity(responseBody)); + final String batchDelimiter = responseBody.substring(2, responseBody.indexOf("\r")); + odataResponse.setHeader(HttpHeaders.CONTENT_TYPE, "multipart/mixed; boundary=" + batchDelimiter); + } + + final HttpClient httpClient = mock(HttpClient.class); + doReturn(odataResponse) + .when(httpClient) + .executeOpen(isNull(), argThat(req -> isCorrectODataRequest(req, requestBody)), isNull()); + + return httpClient; + } + + static private + boolean + isCorrectODataRequest( @Nonnull final ClassicHttpRequest request, @Nullable final String assertRequestBody ) + { + if( !(request instanceof HttpPost) ) { + log.error("Expected HTTP POST request."); + return false; + } + + final Header requestedContentType = request.getFirstHeader(HttpHeaders.CONTENT_TYPE); + final String expectedContentType = "multipart/mixed;boundary=batch_00000000-0000-0000-0000-000000000001"; + if( !requestedContentType.getValue().equals(expectedContentType) ) { + log.error("Expected content type: {}", expectedContentType); + return false; + } + + if( assertRequestBody == null ) { + return true; + } + + final String requestCont = Try.of(() -> EntityUtils.toString(((HttpPost) request).getEntity())).getOrNull(); + if( !Objects.equal(requestCont, assertRequestBody) ) { + log.error("Expected request content: {}, but got {}", assertRequestBody, requestCont); + return false; + } + + return true; + } + } + + private static String readResourceFileCrlf( final String file ) + { + return TestUtility.readResourceFileCrlf(ODataClientBatchResponseParsingUnitTest.class, file); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataReferenceServiceByKeyTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataReferenceServiceByKeyTest.java new file mode 100644 index 0000000000..3233416dd1 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataReferenceServiceByKeyTest.java @@ -0,0 +1,96 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataRequestReadByKey; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Trip; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; + +class ODataReferenceServiceByKeyTest +{ + private static final String testUserName = "testuser"; + private static final String keyWithSpecialCharacters = "test''user"; + private static final String fullQuery = + DefaultTrippinService.DEFAULT_SERVICE_PATH + "/People('" + testUserName + "')?%s"; + private static final String fullQueryWithSpecialCharacters = + DefaultTrippinService.DEFAULT_SERVICE_PATH + "/People('" + keyWithSpecialCharacters + "')"; + + @Test + void testSelectAndExpand() + { + final ODataRequestReadByKey request = + new DefaultTrippinService() + .getPeopleByKey(testUserName) + .select(Person.FIRST_NAME, Person.LAST_NAME) + .select(Person.TO_TRIPS) + .toRequest(); + + final String expected = "$select=FirstName,LastName&$expand=Trips"; + + assertThat(request.getQueryString()).isEqualTo(expected); + assertThat(request.getRelativeUri()).hasToString(String.format(fullQuery, expected)); + } + + @Test + void testMultipleExpandsWithFilter() + { + final ODataRequestReadByKey request = + new DefaultTrippinService() + .getPeopleByKey(testUserName) + .select(Person.TO_TRIPS.select(Trip.NAME)) + .select(Person.TO_BEST_FRIEND.select(Person.LAST_NAME)) + .toRequest(); + + final String expected = "$expand=Trips($select=Name),BestFriend($select=LastName)"; + + assertThat(request.getQueryString()).isEqualTo(expected); + assertThat(request.getRelativeUri()).hasToString(String.format(fullQuery, expected)); + } + + @Test + void testAllSelectionVariants() + { + final ODataRequestReadByKey request = + new DefaultTrippinService() + .getPeopleByKey(testUserName) + .select(Person.GENDER, Person.USER_NAME) + .select(Person.EMAILS) + .select(Person.TO_BEST_FRIEND, Person.TO_TRIPS, Person.TO_FRIENDS.top(2).skip(1)) + .toRequest(); + + final String expected = "$select=Gender,UserName,Emails&$expand=BestFriend,Trips,Friends($top=2;$skip=1)"; + + assertThat(request.getQueryString()).isEqualTo(expected); + assertThat(request.getRelativeUri()).hasToString(String.format(fullQuery, expected)); + } + + @Test + void testGetByKeyWithSpecialCharacters() + { + final ODataRequestReadByKey request = new DefaultTrippinService().getPeopleByKey("test'user").toRequest(); + assertThat(request.getRelativeUri()).hasToString(fullQueryWithSpecialCharacters); + } + + @Test + void testAllDuplicateSelections() + { + final ODataRequestReadByKey request = + new DefaultTrippinService() + .getPeopleByKey(testUserName) + .select(Person.GENDER, Person.USER_NAME) + .select(Person.GENDER, Person.USER_NAME) + .select(Person.TO_BEST_FRIEND, Person.TO_TRIPS) + .select(Person.TO_BEST_FRIEND, Person.TO_BEST_FRIEND.select(Person.TO_FRIENDS)) + .select(Person.TO_FRIENDS, Person.TO_FRIENDS.top(2).skip(1)) + .toRequest(); + + final String expected = + "$select=Gender,UserName&$expand=Trips,BestFriend($expand=Friends),Friends($top=2;$skip=1)"; + + assertThat(request.getQueryString()).isEqualTo(expected); + assertThat(request.getRelativeUri()).hasToString(String.format(fullQuery, expected)); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataReferenceServiceTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataReferenceServiceTest.java new file mode 100644 index 0000000000..480aef01cb --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataReferenceServiceTest.java @@ -0,0 +1,132 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odatav4.core.GetAllRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Trip; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; + +class ODataReferenceServiceTest +{ + @Test + void testSelectAndExpand() + { + final GetAllRequestBuilder request = + new DefaultTrippinService() + .getAllPeople() + .select(Person.FIRST_NAME, Person.LAST_NAME) + .select(Person.TO_TRIPS); + + assertThat(request.toRequest().getRelativeUri()) + .hasParameter("$select", "FirstName,LastName") + .hasParameter("$expand", "Trips"); + } + + @Test + void testMultipleExpandsWithFilter() + { + final GetAllRequestBuilder request = + new DefaultTrippinService() + .getAllPeople() + .select(Person.TO_TRIPS.select(Trip.NAME)) + .select(Person.TO_BEST_FRIEND.select(Person.LAST_NAME)); + + assertThat(request.toRequest().getRelativeUri()) + .hasParameter("$expand", "Trips($select=Name),BestFriend($select=LastName)"); + } + + @Test + void testAllSelectionVariants() + { + final GetAllRequestBuilder request = + new DefaultTrippinService() + .getAllPeople() + .select(Person.GENDER, Person.USER_NAME) + .select(Person.TO_BEST_FRIEND, Person.TO_TRIPS, Person.TO_FRIENDS.top(2).skip(1)) + .filter(Person.EMAILS.contains(Collections.singletonList("ASD"))); + + assertThat(request.toRequest().getRelativeUri()) + .hasParameter("$select", "Gender,UserName") + .hasParameter("$expand", "BestFriend,Trips,Friends($top=2;$skip=1)") + .hasParameter("$filter", "contains(Emails,['ASD'])"); + } + + @Test + void testFiltersWithSpecialCharacters() + { + final GetAllRequestBuilder request = + new DefaultTrippinService().getAllPeople().filter(Person.FIRST_NAME.contains("' +&#\\")); + + assertThat(request.toRequest().getRelativeUri()).hasParameter("$filter", "contains(FirstName,''' +&#\\')"); + } + + @Test + void testNestedFiltersWithAllSpecialCharacters() + { + final GetAllRequestBuilder request = + new DefaultTrippinService() + .getAllPeople() + .filter(Person.FIRST_NAME.contains("% $&#?\"\\+'")) + .select(Person.TO_BEST_FRIEND.select(Person.TO_TRIPS.filter(Trip.NAME.contains("% $&#?\"\\+'")))); + + assertThat(request.toRequest().getRelativeUri()) + .hasParameter("$expand", "BestFriend($expand=Trips($filter=contains(Name,'% $&#?\"\\+''')))") + .hasParameter("$filter", "contains(FirstName,'% $&#?\"\\+''')"); + } + + @Test + void testNestedFiltersWithSpecialCharacters() + { + final GetAllRequestBuilder request = + new DefaultTrippinService() + .getAllPeople() + .filter(Person.FIRST_NAME.contains("' +&#\\")) + .select(Person.TO_TRIPS.filter(Trip.NAME.equalTo("Trip in '&USA'#"))); + + assertThat(request.toRequest().getRelativeUri()) + .hasParameter("$expand", "Trips($filter=(Name eq 'Trip in ''&USA''#'))") + .hasParameter("$filter", "contains(FirstName,''' +&#\\')"); + } + + @Test + void testAllDuplicateSelections() + { + final GetAllRequestBuilder request = + new DefaultTrippinService() + .getAllPeople() + .select(Person.GENDER, Person.USER_NAME) + .select(Person.GENDER, Person.USER_NAME) + .select(Person.TO_BEST_FRIEND, Person.TO_TRIPS) + .select(Person.TO_BEST_FRIEND, Person.TO_BEST_FRIEND.select(Person.TO_FRIENDS)) + .select(Person.TO_FRIENDS, Person.TO_FRIENDS.top(2).skip(1)); + + assertThat(request.toRequest().getRelativeUri()) + .hasParameter("$select", "Gender,UserName") + .hasParameter("$expand", "Trips,BestFriend($expand=Friends),Friends($top=2;$skip=1)"); + } + + @Test + void testOrderBy() + { + final GetAllRequestBuilder request = + new DefaultTrippinService().getAllPeople().orderBy(Person.FIRST_NAME.asc(), Person.LAST_NAME.desc()); + + assertThat(request.toRequest().getRelativeUri()).hasParameter("$orderby", "FirstName asc,LastName desc"); + } + + @Test + void testBadOrderByUsage() + { + final GetAllRequestBuilder request = + new DefaultTrippinService() + .getAllPeople() + .orderBy(Person.FIRST_NAME.desc(), Person.LAST_NAME.asc(), Person.FIRST_NAME.desc()); + + assertThat(request.toRequest().getRelativeUri()).hasParameter("$orderby", "FirstName desc,LastName asc"); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataV4BatchReferenceServiceUnitTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataV4BatchReferenceServiceUnitTest.java new file mode 100644 index 0000000000..1e501349b2 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/ODataV4BatchReferenceServiceUnitTest.java @@ -0,0 +1,241 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +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.head; +import static com.github.tomakehurst.wiremock.client.WireMock.ok; +import static com.github.tomakehurst.wiremock.client.WireMock.okForContentType; +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.stubFor; +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 com.sap.cloud.sdk.datamodel.odatav4.TestUtility.readResourceFileCrlf; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import javax.annotation.Nonnull; + +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataServiceErrorException; +import com.sap.cloud.sdk.datamodel.odatav4.core.BatchRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.BatchResponse; +import com.sap.cloud.sdk.datamodel.odatav4.core.CreateRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.GetAllRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.GetByKeyRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.TrippinService; + +@WireMockTest +class ODataV4BatchReferenceServiceUnitTest +{ + private static final WireMockConfiguration WIREMOCK_CONFIGURATION = wireMockConfig().dynamicPort(); + + private static final String X_CSRF_TOKEN_HEADER_KEY = "x-csrf-token"; + private static final String X_CSRF_TOKEN_HEADER_FETCH_VALUE = "fetch"; + private static final String X_CSRF_TOKEN_HEADER_VALUE = "awesome-csrf-token"; + + private DefaultHttpDestination destination; + private TrippinService service; + private BatchRequestBuilder sut; + + @BeforeEach + void setup( @Nonnull final WireMockRuntimeInfo wm ) + { + destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build(); + service = new DefaultTrippinService(); + + final AtomicInteger uuidCounter = new AtomicInteger(); + + sut = new BatchRequestBuilder(TrippinService.DEFAULT_SERVICE_PATH) + { + @Override + protected Supplier getUuidProvider() + { + return () -> new UUID(0, uuidCounter.incrementAndGet()); + } + }; + + // mock CSRF token retrieval + stubFor(head(anyUrl()).willReturn(ok())); + stubFor( + head(urlEqualTo("/")) + .withHeader(X_CSRF_TOKEN_HEADER_KEY, equalTo(X_CSRF_TOKEN_HEADER_FETCH_VALUE)) + .willReturn(ok().withHeader(X_CSRF_TOKEN_HEADER_KEY, X_CSRF_TOKEN_HEADER_VALUE))); + } + + @Test + void testEmptyBatch() + { + // Read OData response json + final String requestBody = + readResourceFileCrlf(ODataV4BatchReferenceServiceUnitTest.class, "BatchEmptyRequest.txt"); + final String responseBody = + readResourceFileCrlf(ODataV4BatchReferenceServiceUnitTest.class, "BatchEmptyResponse.txt"); + final String batchRequestUrl = String.format("%s%s", TrippinService.DEFAULT_SERVICE_PATH, "/$batch"); + + // Mocking OData response + final String responseContentType = + "multipart/mixed; boundary=batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef"; + stubFor( + post(urlEqualTo(batchRequestUrl)) + .willReturn(okForContentType(responseContentType, responseBody).withStatus(HttpStatus.SC_ACCEPTED))); + + // Run payload + final BatchResponse batchResponse = sut.execute(destination); + + // Verify request body + final String requestContentType = "multipart/mixed;boundary=batch_00000000-0000-0000-0000-000000000001"; + verify( + postRequestedFor(urlEqualTo(batchRequestUrl)) + .withoutHeader(HttpHeaders.ACCEPT) + .withHeader(HttpHeaders.CONTENT_TYPE, equalTo(requestContentType)) + .withRequestBody(equalTo(requestBody))); + + // Test assertion: response object not null and healthy + assertThat(batchResponse.getResponseStatusCode()).isEqualTo(HttpStatus.SC_ACCEPTED); + assertThat(batchResponse.getResponseHeaders()).isNotEmpty(); + } + + @Test + void testBatchWithOnlyReads() + { + // Read OData response json + final String requestBody = + readResourceFileCrlf(ODataV4BatchReferenceServiceUnitTest.class, "BatchOnlyReadsRequest.txt"); + final String responseBody = + readResourceFileCrlf(ODataV4BatchReferenceServiceUnitTest.class, "BatchOnlyReadsResponse.txt"); + final String batchRequestUrl = String.format("%s%s", TrippinService.DEFAULT_SERVICE_PATH, "/$batch"); + + // Mocking OData response + final String responseContentType = + "multipart/mixed; boundary=batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef"; + stubFor( + post(urlEqualTo(batchRequestUrl)) + .willReturn(okForContentType(responseContentType, responseBody).withStatus(HttpStatus.SC_ACCEPTED))); + + // Prepare test objects + final GetAllRequestBuilder getAll1 = service.getAllPeople().top(1); + final GetAllRequestBuilder getAll2 = service.getAllPeople().top(2).skip(1); + + // build batch request + final BatchResponse batchResponse = sut.addReadOperations(getAll1, getAll2).execute(destination); + + // Verify request body + final String requestContentType = "multipart/mixed;boundary=batch_00000000-0000-0000-0000-000000000001"; + verify( + postRequestedFor(urlEqualTo(batchRequestUrl)) + .withoutHeader(HttpHeaders.ACCEPT) + .withHeader(HttpHeaders.CONTENT_TYPE, equalTo(requestContentType)) + .withRequestBody(equalTo(requestBody))); + + // Test assertion: response object not null and healthy + assertThat(batchResponse.getResponseStatusCode()).isEqualTo(HttpStatus.SC_ACCEPTED); + assertThat(batchResponse.getResponseHeaders()).isNotEmpty(); + } + + @Test + void testBatchWithErrorReads() + { + // Read OData response json + final String requestBody = + readResourceFileCrlf(ODataV4BatchReferenceServiceUnitTest.class, "BatchOnlyReadsRequest.txt"); + final String responseBody = + readResourceFileCrlf(ODataV4BatchReferenceServiceUnitTest.class, "BatchOnlyReadsErrorResponse.txt"); + final String batchRequestUrl = String.format("%s%s", TrippinService.DEFAULT_SERVICE_PATH, "/$batch"); + + // Mocking OData response + final String responseContentType = + "multipart/mixed; boundary=batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef"; + stubFor( + post(urlEqualTo(batchRequestUrl)) + .willReturn(okForContentType(responseContentType, responseBody).withStatus(HttpStatus.SC_ACCEPTED))); + + // Prepare test objects + final GetAllRequestBuilder getAll1 = service.getAllPeople().top(1); + final GetAllRequestBuilder getAll2 = service.getAllPeople().top(2).skip(1); + + // build batch request + final BatchResponse batchResponse = sut.addReadOperations(getAll1, getAll2).execute(destination); + + assertThat(batchResponse.getReadResult(getAll1)).isNotEmpty(); + + assertThatExceptionOfType(ODataServiceErrorException.class) + .isThrownBy(() -> batchResponse.getReadResult(getAll2)) + .satisfies(e -> { + assertThat(e.getHttpCode()).isEqualTo(400); + assertThat(e.getOdataError().getODataCode()).isEqualTo("ZCU/100"); + }); + + // Verify request body + final String requestContentType = "multipart/mixed;boundary=batch_00000000-0000-0000-0000-000000000001"; + verify( + postRequestedFor(urlEqualTo(batchRequestUrl)) + .withoutHeader(HttpHeaders.ACCEPT) + .withHeader(HttpHeaders.CONTENT_TYPE, equalTo(requestContentType)) + .withRequestBody(equalTo(requestBody))); + + // Test assertion: response object not null and healthy + assertThat(batchResponse.getResponseStatusCode()).isEqualTo(HttpStatus.SC_ACCEPTED); + assertThat(batchResponse.getResponseHeaders()).isNotEmpty(); + } + + @Test + void testBatchWithReadsAndWrites() + { + // read response json + final String serverResponse = + readResourceFileCrlf(ODataV4BatchReferenceServiceUnitTest.class, "BatchReadsAndWritesSuccessResponse.txt"); + final String batchRequestBody = + readResourceFileCrlf(ODataV4BatchReferenceServiceUnitTest.class, "BatchReadsAndWritesSuccessRequest.txt"); + final String batchRequestUrl = String.format("%s%s", TrippinService.DEFAULT_SERVICE_PATH, "/$batch"); + + final String responseContentType = + "multipart/mixed; boundary=batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef"; + + // Mocking S/4 Hana + stubFor( + post(urlEqualTo(batchRequestUrl)) + .willReturn(okForContentType(responseContentType, serverResponse).withStatus(HttpStatus.SC_ACCEPTED))); + + Person person1 = Person.builder().userName("JohnDoe1").firstName("John").build(); + Person person2 = Person.builder().userName("JohnDoe2").firstName("John").build(); + + // prepare test objects + final CreateRequestBuilder createPersonRequest1 = service.createPeople(person1); + final CreateRequestBuilder createPersonRequest2 = service.createPeople(person2); + final GetByKeyRequestBuilder getPersonByKeyRequest = service.getPeopleByKey("foo"); + + // build batch request + final BatchResponse batchResponse = + sut + .addChangeset(createPersonRequest1, createPersonRequest2) + .addReadOperations(getPersonByKeyRequest) + .execute(destination); + + assertThat(batchResponse.getResponseStatusCode()).isEqualTo(HttpStatus.SC_ACCEPTED); + assertThat(batchResponse.getResponseHeaders()).isNotEmpty(); + + // verify request body + final String requestContentType = "multipart/mixed;boundary=batch_00000000-0000-0000-0000-000000000001"; + verify( + postRequestedFor(urlEqualTo(batchRequestUrl)) + .withoutHeader(HttpHeaders.ACCEPT) + .withHeader(HttpHeaders.CONTENT_TYPE, equalTo(requestContentType)) + .withRequestBody(equalTo(batchRequestBody))); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/PropertyCustomSerializationTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/PropertyCustomSerializationTest.java new file mode 100644 index 0000000000..bde2f899d9 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/PropertyCustomSerializationTest.java @@ -0,0 +1,175 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.Month; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory; +import com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer; +import com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEntity; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +class PropertyCustomSerializationTest +{ + private static final User USER = + new User( + LocalDate.of(1960, Month.MAY, 31).atTime(18, 0).atOffset(ZoneOffset.ofHours(1)), + LocalDate.of(2020, Month.FEBRUARY, 20).atStartOfDay().atZone(ZoneOffset.UTC), + LocalDate.of(2005, Month.DECEMBER, 24).atStartOfDay()); + + @EqualsAndHashCode( callSuper = true ) + @RequiredArgsConstructor + @AllArgsConstructor + @JsonAdapter( GsonVdmAdapterFactory.class ) + @JsonSerialize( using = JacksonVdmObjectSerializer.class ) + @JsonDeserialize( using = JacksonVdmObjectDeserializer.class ) + public static class User extends VdmEntity + { + @Getter + private final String entityCollection = "People"; + + @Getter + private final String odataType = "Example.User"; + + @Getter + private final Class type = User.class; + + @ElementName( "DateOfBirth" ) + private OffsetDateTime dateOfBirth; + + @ElementName( "LocaleTime" ) + @JsonSerialize( using = CustomZonedDateSerializer.class ) + @JsonDeserialize( using = CustomZonedDateDeserializer.class ) + @JsonAdapter( ZonedDateTimeAdapter.class ) + private ZonedDateTime localeTime; + + @ElementName( "RegistrationDate" ) + @JsonSerialize( using = CustomLocalDateSerializer.class ) + @JsonDeserialize( using = CustomLocalDateDeserializer.class ) + @JsonAdapter( LocalDateTimeAdapter.class ) + private LocalDateTime registrationDate; + } + + @Test + void testSerializationWithGson() + { + final String json = new Gson().toJson(USER); + final User actual = new Gson().fromJson(json, User.class); + assertThat(actual).isEqualTo(USER); + } + + @Test + void testSerializationWithJackson() + throws JsonProcessingException + { + final String json = new ObjectMapper().writeValueAsString(USER); + final User actual = new ObjectMapper().readValue(json, User.class); + assertThat(actual).isEqualTo(USER); + } + + // Custom serializers and deserializers + + public static class CustomLocalDateSerializer extends JsonSerializer + { + @Override + public void serialize( final LocalDateTime val, final JsonGenerator gen, final SerializerProvider serializers ) + throws IOException + { + gen.writeString(val.atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + } + } + + public static class CustomZonedDateSerializer extends JsonSerializer + { + @Override + public void serialize( final ZonedDateTime val, final JsonGenerator gen, final SerializerProvider serializers ) + throws IOException + { + gen.writeString(val.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + } + } + + public static class CustomLocalDateDeserializer extends JsonDeserializer + { + @Override + public LocalDateTime deserialize( final JsonParser p, final DeserializationContext ctxt ) + throws IOException + { + return OffsetDateTime.parse(p.getValueAsString(), DateTimeFormatter.ISO_OFFSET_DATE_TIME).toLocalDateTime(); + } + } + + public static class CustomZonedDateDeserializer extends JsonDeserializer + { + @Override + public ZonedDateTime deserialize( final JsonParser p, final DeserializationContext ctxt ) + throws IOException + { + return OffsetDateTime.parse(p.getValueAsString(), DateTimeFormatter.ISO_OFFSET_DATE_TIME).toZonedDateTime(); + } + } + + public static class LocalDateTimeAdapter extends TypeAdapter + { + @Override + public void write( final JsonWriter out, final LocalDateTime value ) + throws IOException + { + out.value(value.atZone(ZoneOffset.UTC).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + } + + @Override + public LocalDateTime read( final JsonReader in ) + throws IOException + { + return OffsetDateTime.parse(in.nextString(), DateTimeFormatter.ISO_OFFSET_DATE_TIME).toLocalDateTime(); + } + } + + public static class ZonedDateTimeAdapter extends TypeAdapter + { + @Override + public void write( final JsonWriter out, final ZonedDateTime value ) + throws IOException + { + out.value(value.toOffsetDateTime().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + } + + @Override + public ZonedDateTime read( final JsonReader in ) + throws IOException + { + return OffsetDateTime.parse(in.nextString(), DateTimeFormatter.ISO_OFFSET_DATE_TIME).toZonedDateTime(); + } + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/PropertySerializationTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/PropertySerializationTest.java new file mode 100644 index 0000000000..0466994ee8 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/PropertySerializationTest.java @@ -0,0 +1,122 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Arrays; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.City; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Location; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.PersonGender; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Trip; + +class PropertySerializationTest +{ + @Test + void testEnum() + { + final Person person = Person.builder().gender(PersonGender.FEMALE).firstName("Eve").build(); + final String json = new Gson().toJson(person); + final JsonElement actual = new Gson().toJsonTree(person); + assertThat(actual) + .isEqualTo( + JsonParser + .parseString( + "{\"FirstName\":\"Eve\",\"Gender\":\"Female\",\"@odata.type\":\"#Trippin.Person\",\"Friends\":[],\"Trips\":[]}")); + } + + @Test + void testComplexCollection() + { + final City city = new City(); + city.setName("Potsdam"); + city.setRegion("Nedlitz"); + city.setCountryRegion("Brandenburg"); + + final Location location = new Location(); + location.setAddress("Konrad-Zuse-Ring 10, 14469"); + location.setCity(city); + + final Person person = Person.builder().addressInfo(Arrays.asList(location)).build(); + + final JsonElement actual = new Gson().toJsonTree(person); + assertThat(actual).isEqualTo(JsonParser.parseString(""" + {"AddressInfo":[\ + {"Address":"Konrad-Zuse-Ring 10, 14469",\ + "City":{"Name":"Potsdam","CountryRegion":"Brandenburg","Region":"Nedlitz","@odata.type":"#Trippin.City"},\ + "@odata.type":"#Trippin.Location"}\ + ],"@odata.type":"#Trippin.Person"\ + ,"Friends":[],"Trips":[]}\ + """)); + // {"@odata.type":"#Trippin.Person","AddressInfo":[{"@odata.type":"#Trippin.Location","Address":"Konrad-Zuse-Ring 10, 14469","City":{"@odata.type":"#Trippin.City","Name":"Potsdam","CountryRegion":"Brandenburg","Region":"Nedlitz"}}],"Friends":[],"Trips":[]} + } + + @Test + void testPrimitiveCollection() + { + final String email = "eve@sap.com"; + final Person person = Person.builder().emails(Arrays.asList(email)).build(); + final JsonElement actual = new Gson().toJsonTree(person); + assertThat(actual) + .isEqualTo( + JsonParser + .parseString( + "{\"Emails\":[\"eve@sap.com\"],\"@odata.type\":\"#Trippin.Person\",\"Friends\":[],\"Trips\":[]}")); + } + + @Test + void testDateTimeGson() + { + final OffsetDateTime date1 = LocalDate.of(2020, 2, 20).atStartOfDay().atOffset(ZoneOffset.UTC); + final OffsetDateTime date2 = LocalDate.of(2020, 2, 20).atTime(3, 0).atOffset(ZoneOffset.ofHours(1)); + final Trip trip = Trip.builder().name("Trip1").startsAt(date1).endsAt(date2).build(); + + final String tripJson = new Gson().toJson(trip); + final JsonObject gsonObject = JsonParser.parseString(tripJson).getAsJsonObject(); + + final String startsAt = gsonObject.getAsJsonPrimitive("StartsAt").getAsString(); + assertThat(startsAt).isEqualTo("2020-02-20T00:00:00Z"); + + final String endsAt = gsonObject.getAsJsonPrimitive("EndsAt").getAsString(); + assertThat(endsAt).isEqualTo("2020-02-20T03:00:00+01:00"); + + final Trip tripGson = new Gson().fromJson(tripJson, Trip.class); + assertThat(tripGson).isEqualTo(trip); + assertThat(tripGson.getStartsAt()).isEqualTo(date1); + assertThat(tripGson.getEndsAt()).isEqualTo(date2); + } + + @Test + void testDateTimeJackson() + throws JsonProcessingException + { + final OffsetDateTime date1 = LocalDate.of(2020, 2, 20).atStartOfDay().atOffset(ZoneOffset.UTC); + final OffsetDateTime date2 = LocalDate.of(2020, 2, 20).atTime(3, 0).atOffset(ZoneOffset.ofHours(1)); + final Trip trip = Trip.builder().name("Trip1").startsAt(date1).endsAt(date2).build(); + + final ObjectMapper mapper = new ObjectMapper(); + final String tripJson = mapper.writeValueAsString(trip); + + final String startsAt = mapper.readTree(tripJson).get("StartsAt").textValue(); + assertThat(startsAt).isEqualTo("2020-02-20T00:00:00Z"); + + final String endsAt = mapper.readTree(tripJson).get("EndsAt").textValue(); + assertThat(endsAt).isEqualTo("2020-02-20T03:00:00+01:00"); + + final Trip tripJackson = mapper.readValue(tripJson, Trip.class); + assertThat(tripJackson).isEqualTo(trip); + assertThat(tripJackson.getStartsAt()).isEqualTo(date1); + assertThat(tripJackson.getEndsAt()).isEqualTo(date2); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/README.md b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/README.md new file mode 100644 index 0000000000..453cddc5da --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/README.md @@ -0,0 +1,13 @@ +# TripPin Service + +The tests in this package are based on the TripPin service originally (24.08.2023) found here: + +The modified version can be found here: `/datamodel/odata-v4/odata-v4-core/src/test/resources/unused_trippin.edmx` + +## Regenerating the Model + +Using the latest OData v4 Generator, take the metadata linked above and use a command similar to this: + +```bash +java -jar odata-v4-generator-cli-4.21.0.jar -i /datamodel/odata-v4/odata-v4-core/src/test/resources/ -o /datamodel/odata-v4/odata-v4-core/src/test/java/ -b TripPinRESTierService -f --use-odata-names --service-methods-per-entity-set --sap-copyright-header -p com.sap.cloud.sdk.datamodel.odatav4.referenceservice +``` \ No newline at end of file diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/SearchQueriesTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/SearchQueriesTest.java new file mode 100644 index 0000000000..1e25fce5bb --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/SearchQueriesTest.java @@ -0,0 +1,81 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odatav4.core.GetAllRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.SearchExpression; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; + +class SearchQueriesTest +{ + @Test + void testGetSearched() + { + final GetAllRequestBuilder search = new DefaultTrippinService().getAllPeople().search("Portland"); + assertThat(search.toRequest().getRelativeUri()).hasParameter("$search", "\"Portland\""); + } + + @Test + void testSpecialCharacters() + { + final GetAllRequestBuilder search = new DefaultTrippinService().getAllPeople().search("Hash #"); + assertThat(search.toRequest().getRelativeUri()).hasParameter("$search", "\"Hash #\""); + } + + @Test + void testGetSearchedPhrase() + { + final GetAllRequestBuilder search = new DefaultTrippinService().getAllPeople().search("United States"); + assertThat(search.toRequest().getRelativeUri()).hasParameter("$search", "\"United States\""); + } + + @Test + void testGetSearchedWithQuotesInString() + { + final GetAllRequestBuilder search = + new DefaultTrippinService().getAllPeople().search("Quoted \"string\""); + + // double quotes are escaped with simple backslash: Quote "string" -> Quoted \"string\" + assertThat(search.toRequest().getRelativeUri()).hasParameter("$search", "\"Quoted \\\"string\\\"\""); + } + + @Test + void testGetSearchedWithBackslashInString() + { + final GetAllRequestBuilder search = new DefaultTrippinService().getAllPeople().search("Escaped \\"); + + // backslashes (=escape character) are escaped with backslashes: Escaped \ -> Escaped \\ + assertThat(search.toRequest().getRelativeUri()).hasParameter("$search", "\"Escaped \\\\\""); + } + + @Test + void testSearchBooleanExpression() + { + + final GetAllRequestBuilder search = + new DefaultTrippinService().getAllPeople().search(SearchExpression.of("termA").or("termB").and("termC")); + + // double quotes are escaped with simple backslash: Quote "string" -> Quoted \"string\" + // (("termA" OR "termB") AND "termC") + assertThat(search.toRequest().getRelativeUri()) + .hasParameter("$search", "((\"termA\" OR \"termB\") AND \"termC\")"); + } + + @Test + void testSearchBooleanNot() + { + + final GetAllRequestBuilder search = + new DefaultTrippinService() + .getAllPeople() + .search(SearchExpression.of("termA").or("termB").and(SearchExpression.of("termC").not())); + + // double quotes are escaped with simple backslash: Quote "string" -> Quoted \"string\" + // (("termA" OR "termB") AND NOT "termC") + assertThat(search.toRequest().getRelativeUri()) + .hasParameter("$search", "((\"termA\" OR \"termB\") AND NOT \"termC\")"); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/SelectNestedComplexPropertiesTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/SelectNestedComplexPropertiesTest.java new file mode 100644 index 0000000000..d4a660473f --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/SelectNestedComplexPropertiesTest.java @@ -0,0 +1,180 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.datamodel.odatav4.core.GetAllRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.expression.FilterableBoolean; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.City; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Location; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.PlanItem; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Trip; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; + +class SelectNestedComplexPropertiesTest +{ + @Test + void testSelectComplexProperty() + { + final GetAllRequestBuilder query = + new DefaultTrippinService().getAllPeople().select(Person.FIRST_NAME, Person.LAST_NAME, Person.ADDRESS_INFO); + + final String expectedUnencodedQuery = "FirstName,LastName,AddressInfo"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$select", expectedUnencodedQuery); + } + + @Test + void testSelectNestedComplexProperty() + { + final GetAllRequestBuilder query = + new DefaultTrippinService() + .getAllPeople() + .select( + Person.FIRST_NAME, + Person.LAST_NAME, + Person.ADDRESS_INFO.select(Location.ADDRESS, Location.CITY)); + + final String expectedUnencodedQuery = "FirstName,LastName,AddressInfo/Address,AddressInfo/City"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$select", expectedUnencodedQuery); + } + + @Test + void testSelectSimplePropertyInNestedComplexProperty() + { + final GetAllRequestBuilder query = + new DefaultTrippinService() + .getAllPeople() + .select( + Person.FIRST_NAME, + Person.LAST_NAME, + Person.ADDRESS_INFO.select(Location.ADDRESS, Location.CITY.select(City.NAME))); + + final String expectedUnencodedQuery = "FirstName,LastName,AddressInfo/Address,AddressInfo/City/Name"; + + assertThat(query.toRequest().getRelativeUri()).hasParameter("$select", expectedUnencodedQuery); + } + + @Test + void testSelectComplexPropertyInNavigationProperty() + { + final GetAllRequestBuilder query = + new DefaultTrippinService() + .getAllPeople() + .select( + Person.TO_BEST_FRIEND + .select(Person.FIRST_NAME, Person.ADDRESS_INFO.select(Location.ADDRESS, Location.CITY))); + + assertThat(query.toRequest().getRelativeUri()) + .hasParameter("$expand", "BestFriend($select=FirstName,AddressInfo/Address,AddressInfo/City)"); + } + + @Test + void testAnyInNestedComplexPropertyCollection() + { + final GetAllRequestBuilder query = + new DefaultTrippinService() + .getAllPeople() + .select(Person.FIRST_NAME, Person.LAST_NAME) + .filter(Person.ADDRESS_INFO.any(Location.ADDRESS.startsWith("Diagon Alley"))); + + assertThat(query.toRequest().getRelativeUri()) + .hasParameter("$select", "FirstName,LastName") + .hasParameter("$filter", "AddressInfo/any(a:startswith(a/Address,'Diagon Alley'))"); + } + + @Test + void testAnyInAnyPropertyCollection() + { + final GetAllRequestBuilder query = + new DefaultTrippinService() + .getAllPeople() + .filter(Person.TO_FRIENDS.any(Person.ADDRESS_INFO.any(Location.ADDRESS.startsWith("Diagon Alley")))); + + assertThat(query.toRequest().getRelativeUri()) + .hasParameter("$filter", "Friends/any(a:a/AddressInfo/any(b:startswith(b/Address,'Diagon Alley')))"); + } + + @Test + void testAnyBesidesAllPropertyCollection() + { + final GetAllRequestBuilder query = + new DefaultTrippinService() + .getAllPeople() + .filter( + Person.TO_FRIENDS + .any(Person.FIRST_NAME.equalTo("Adam")) + .or(Person.TO_FRIENDS.all(Person.FIRST_NAME.equalTo("Eve")))); + + assertThat(query.toRequest().getRelativeUri()) + .hasParameter( + "$filter", + "(Friends/any(a:(a/FirstName eq 'Adam')) or Friends/all(a:(a/FirstName eq 'Eve')))"); + } + + @Test + void testLambdaParameterLevelsForSameEntity() + { + final FilterableBoolean lvl3 = Person.USER_NAME.equalTo("c1").or(Person.USER_NAME.equalTo("c2")); + + final FilterableBoolean lvl2 = + Person.USER_NAME.equalTo("b1").and(Person.TO_FRIENDS.any(lvl3)).and(Person.USER_NAME.equalTo("b2")); + + final FilterableBoolean lvl1 = + Person.USER_NAME.equalTo("a1").or(Person.TO_FRIENDS.all(lvl2)).or(Person.USER_NAME.equalTo("a2")); + + final FilterableBoolean lvl0 = + Person.USER_NAME.equalTo("1").and(Person.TO_FRIENDS.any(lvl1)).or(Person.USER_NAME.equalTo("2")); + + final GetAllRequestBuilder query = new DefaultTrippinService().getAllPeople().filter(lvl0); + + // Expectation for prefix usage: + // -> 1 + // a -> a1 + // b -> b1 + // c -> c1 + // c -> c2 + // b -> b1 + // a -> b1 + // -> 2 + assertThat(query.toRequest().getRelativeUri()) + .hasParameter( + "$filter", + "(((UserName eq '1') and Friends/any(a:(((a/UserName eq 'a1') or a/Friends/all(b:(((b/UserName eq 'b1') and b/Friends/any(c:((c/UserName eq 'c1') or (c/UserName eq 'c2')))) and (b/UserName eq 'b2')))) or (a/UserName eq 'a2')))) or (UserName eq '2'))"); + } + + @Test + void testLambdaParameterLevelsForDifferentEntities() + { + final FilterableBoolean lvl3 = + PlanItem.CONFIRMATION_CODE.equalTo("c1").or(PlanItem.CONFIRMATION_CODE.equalTo("c2")); + + final FilterableBoolean lvl2 = + Trip.NAME.equalTo("b1").and(Trip.TO_PLAN_ITEMS.any(lvl3)).and(Trip.NAME.equalTo("b2")); + + final FilterableBoolean lvl1 = + Person.USER_NAME.equalTo("a1").or(Person.TO_TRIPS.all(lvl2)).or(Person.USER_NAME.equalTo("a2")); + + final FilterableBoolean lvl0 = + Person.USER_NAME.equalTo("1").and(Person.TO_FRIENDS.any(lvl1)).and(Person.USER_NAME.equalTo("2")); + + final GetAllRequestBuilder query = new DefaultTrippinService().getAllPeople().filter(lvl0); + + // Expectation for prefix usage: + // -> 1 + // a -> a1 + // b -> b1 + // c -> c1 + // c -> c2 + // b -> b1 + // a -> b1 + // -> 2 + assertThat(query.toRequest().getRelativeUri()) + .hasParameter( + "$filter", + "(((UserName eq '1') and Friends/any(a:(((a/UserName eq 'a1') or a/Trips/all(b:(((b/Name eq 'b1') and b/PlanItems/any(c:((c/ConfirmationCode eq 'c1') or (c/ConfirmationCode eq 'c2')))) and (b/Name eq 'b2')))) or (a/UserName eq 'a2')))) and (UserName eq '2'))"); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/TripPinUtility.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/TripPinUtility.java new file mode 100644 index 0000000000..4f94ad6c41 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/TripPinUtility.java @@ -0,0 +1,47 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; + +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; + +public class TripPinUtility +{ + public static HttpDestination getDestination() + throws IOException + { + final HttpGet request = new HttpGet("https://services.odata.org/TripPinRESTierService/"); + final CloseableHttpClient client = HttpClientBuilder.create().disableRedirectHandling().build(); + final String serviceUrl = client.executeOpen(null, request, null).getLastHeader("Location").getValue(); + final Matcher tokenLookup = Pattern.compile("\\(S\\((.*?)\\)\\)").matcher(serviceUrl); + + assertThat(tokenLookup.find()).isTrue(); + + return DefaultHttpDestination + .builder("https://services.odata.org/TripPinRESTierService/(S(" + tokenLookup.group(1) + "))") + .build(); + } + + public static HttpDestination getDestinationRW() + throws IOException + { + final HttpGet request = new HttpGet("https://services.odata.org/V4/TripPinServiceRW/"); + final CloseableHttpClient client = HttpClientBuilder.create().disableRedirectHandling().build(); + final String serviceUrl = client.executeOpen(null, request, null).getLastHeader("Location").getValue(); + final Matcher tokenLookup = Pattern.compile("\\(S\\((.*?)\\)\\)").matcher(serviceUrl); + + assertThat(tokenLookup.find()).isTrue(); + + return DefaultHttpDestination + .builder("https://services.odata.org/V4/(S(" + tokenLookup.group(1) + "))") + .build(); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/UnboundActionTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/UnboundActionTest.java new file mode 100644 index 0000000000..797f691de6 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/UnboundActionTest.java @@ -0,0 +1,39 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import com.sap.cloud.sdk.datamodel.odatav4.core.ActionResponseSingle; +import com.sap.cloud.sdk.datamodel.odatav4.core.SingleValueActionRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.TrippinService; + +@Disabled( "Test runs against a v4 reference service on odata.org. Use it only to manually verify behaviour." ) +class UnboundActionTest +{ + + private static final TrippinService service = new DefaultTrippinService(); + private HttpDestination httpDestination; + + @BeforeEach + void configure() + throws IOException + { + httpDestination = TripPinUtility.getDestination(); + } + + @Test + void testActionWithoutParameters() + { + final SingleValueActionRequestBuilder builder = service.resetDataSource(); + final ActionResponseSingle actionResponse = builder.execute(httpDestination); + assertThat(actionResponse).isNotNull(); + assertThat(actionResponse.getResponseStatusCode()).isEqualTo(204); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/UnboundFunctionTest.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/UnboundFunctionTest.java new file mode 100644 index 0000000000..d1c66c61f0 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/UnboundFunctionTest.java @@ -0,0 +1,47 @@ +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; +import com.sap.cloud.sdk.datamodel.odatav4.core.SingleValueFunctionRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.DefaultTrippinService; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.TrippinService; + +@Disabled( "Test runs against a v4 reference service on odata.org. Use it only to manually verify behaviour." ) +class UnboundFunctionTest +{ + + private static final TrippinService service = new DefaultTrippinService(); + private HttpDestination httpDestination; + + @BeforeEach + void configure() + { + httpDestination = DefaultHttpDestination.builder("https://services.odata.org").build(); + } + + @Test + void testFunctionWithoutParameters() + { + final SingleValueFunctionRequestBuilder builder = service.getPersonWithMostFriends(); + final String expected = DefaultTrippinService.DEFAULT_SERVICE_PATH + "/GetPersonWithMostFriends"; + assertThat(builder.toRequest().getRelativeUri()).hasToString(expected); + assertThat(builder.execute(httpDestination)).isInstanceOf(Person.class); + } + + @Test + void testFunctionWithParameters() + { + final SingleValueFunctionRequestBuilder builder = service.getNearestAirport(33.0, -118.0); + final String expected = DefaultTrippinService.DEFAULT_SERVICE_PATH + "/GetNearestAirport(lat=33.0,lon=-118.0)"; + assertThat(builder.toRequest().getRelativeUri()).hasToString(expected); + assertThat(builder.execute(httpDestination)).isInstanceOf(Airport.class); + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Airline.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Airline.java new file mode 100644 index 0000000000..5d3a46baf7 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Airline.java @@ -0,0 +1,178 @@ +/* + * Generated by OData VDM code generator of SAP Cloud SDK in version 4.21.0 + */ + +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin; + +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.common.collect.Maps; +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEntity; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEntitySet; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.TrippinService; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +/** + *

+ * Original entity name from the Odata EDM: Airline + *

+ * + */ +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@ToString( doNotUseGetters = true, callSuper = true ) +@EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) +@JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) +@JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) +@JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) +public class Airline extends VdmEntity implements VdmEntitySet +{ + + @Getter + private final java.lang.String odataType = "Trippin.Airline"; + /** + * Selector for all available fields of Airline. + * + */ + public final static SimpleProperty ALL_FIELDS = all(); + /** + * (Key Field) Constraints: Not nullable + *

+ * Original property name from the Odata EDM: AirlineCode + *

+ * + * @return The airlineCode contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "AirlineCode" ) + private java.lang.String airlineCode; + public final static SimpleProperty.String AIRLINE_CODE = + new SimpleProperty.String(Airline.class, "AirlineCode"); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Name + *

+ * + * @return The name contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "Name" ) + private java.lang.String name; + public final static SimpleProperty.String NAME = new SimpleProperty.String(Airline.class, "Name"); + + @Nonnull + @Override + public Class getType() + { + return Airline.class; + } + + /** + * (Key Field) Constraints: Not nullable + *

+ * Original property name from the Odata EDM: AirlineCode + *

+ * + * @param airlineCode + * The airlineCode to set. + */ + public void setAirlineCode( @Nullable final java.lang.String airlineCode ) + { + rememberChangedField("AirlineCode", this.airlineCode); + this.airlineCode = airlineCode; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Name + *

+ * + * @param name + * The name to set. + */ + public void setName( @Nullable final java.lang.String name ) + { + rememberChangedField("Name", this.name); + this.name = name; + } + + @Override + protected java.lang.String getEntityCollection() + { + return "Airlines"; + } + + @Nonnull + @Override + protected ODataEntityKey getKey() + { + final ODataEntityKey entityKey = super.getKey(); + entityKey.addKeyProperty("AirlineCode", getAirlineCode()); + return entityKey; + } + + @Nonnull + @Override + protected Map toMapOfFields() + { + final Map values = super.toMapOfFields(); + values.put("AirlineCode", getAirlineCode()); + values.put("Name", getName()); + return values; + } + + @Override + protected void fromMap( final Map inputValues ) + { + final Map values = Maps.newHashMap(inputValues); + // simple properties + { + if( values.containsKey("AirlineCode") ) { + final Object value = values.remove("AirlineCode"); + if( (value == null) || (!value.equals(getAirlineCode())) ) { + setAirlineCode(((java.lang.String) value)); + } + } + if( values.containsKey("Name") ) { + final Object value = values.remove("Name"); + if( (value == null) || (!value.equals(getName())) ) { + setName(((java.lang.String) value)); + } + } + } + // structured properties + { + } + // navigation properties + { + } + super.fromMap(values); + } + + @Override + protected java.lang.String getDefaultServicePath() + { + return TrippinService.DEFAULT_SERVICE_PATH; + } + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Airport.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Airport.java new file mode 100644 index 0000000000..f63591d32a --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Airport.java @@ -0,0 +1,263 @@ +/* + * Generated by OData VDM code generator of SAP Cloud SDK in version 4.21.0 + */ + +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin; + +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.common.collect.Maps; +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEntity; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEntitySet; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.TrippinService; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +/** + *

+ * Original entity name from the Odata EDM: Airport + *

+ * + */ +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@ToString( doNotUseGetters = true, callSuper = true ) +@EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) +@JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) +@JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) +@JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) +public class Airport extends VdmEntity implements VdmEntitySet +{ + + @Getter + private final java.lang.String odataType = "Trippin.Airport"; + /** + * Selector for all available fields of Airport. + * + */ + public final static SimpleProperty ALL_FIELDS = all(); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Name + *

+ * + * @return The name contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "Name" ) + private java.lang.String name; + public final static SimpleProperty.String NAME = new SimpleProperty.String(Airport.class, "Name"); + /** + * (Key Field) Constraints: Not nullable + *

+ * Original property name from the Odata EDM: IcaoCode + *

+ * + * @return The icaoCode contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "IcaoCode" ) + private java.lang.String icaoCode; + public final static SimpleProperty.String ICAO_CODE = + new SimpleProperty.String(Airport.class, "IcaoCode"); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: IataCode + *

+ * + * @return The iataCode contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "IataCode" ) + private java.lang.String iataCode; + public final static SimpleProperty.String IATA_CODE = + new SimpleProperty.String(Airport.class, "IataCode"); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Location + *

+ * + * @return The location contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "Location" ) + private AirportLocation location; + /** + * Use with available request builders to apply the Location complex property to query operations. + * + */ + public final static com.sap.cloud.sdk.datamodel.odatav4.core.ComplexProperty.Single LOCATION = + new com.sap.cloud.sdk.datamodel.odatav4.core.ComplexProperty.Single( + Airport.class, + "Location", + AirportLocation.class); + + @Nonnull + @Override + public Class getType() + { + return Airport.class; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Name + *

+ * + * @param name + * The name to set. + */ + public void setName( @Nullable final java.lang.String name ) + { + rememberChangedField("Name", this.name); + this.name = name; + } + + /** + * (Key Field) Constraints: Not nullable + *

+ * Original property name from the Odata EDM: IcaoCode + *

+ * + * @param icaoCode + * The icaoCode to set. + */ + public void setIcaoCode( @Nullable final java.lang.String icaoCode ) + { + rememberChangedField("IcaoCode", this.icaoCode); + this.icaoCode = icaoCode; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: IataCode + *

+ * + * @param iataCode + * The iataCode to set. + */ + public void setIataCode( @Nullable final java.lang.String iataCode ) + { + rememberChangedField("IataCode", this.iataCode); + this.iataCode = iataCode; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Location + *

+ * + * @param location + * The location to set. + */ + public void setLocation( @Nullable final AirportLocation location ) + { + rememberChangedField("Location", this.location); + this.location = location; + } + + @Override + protected java.lang.String getEntityCollection() + { + return "Airports"; + } + + @Nonnull + @Override + protected ODataEntityKey getKey() + { + final ODataEntityKey entityKey = super.getKey(); + entityKey.addKeyProperty("IcaoCode", getIcaoCode()); + return entityKey; + } + + @Nonnull + @Override + protected Map toMapOfFields() + { + final Map values = super.toMapOfFields(); + values.put("Name", getName()); + values.put("IcaoCode", getIcaoCode()); + values.put("IataCode", getIataCode()); + values.put("Location", getLocation()); + return values; + } + + @Override + protected void fromMap( final Map inputValues ) + { + final Map values = Maps.newHashMap(inputValues); + // simple properties + { + if( values.containsKey("Name") ) { + final Object value = values.remove("Name"); + if( (value == null) || (!value.equals(getName())) ) { + setName(((java.lang.String) value)); + } + } + if( values.containsKey("IcaoCode") ) { + final Object value = values.remove("IcaoCode"); + if( (value == null) || (!value.equals(getIcaoCode())) ) { + setIcaoCode(((java.lang.String) value)); + } + } + if( values.containsKey("IataCode") ) { + final Object value = values.remove("IataCode"); + if( (value == null) || (!value.equals(getIataCode())) ) { + setIataCode(((java.lang.String) value)); + } + } + } + // structured properties + { + if( values.containsKey("Location") ) { + final Object value = values.remove("Location"); + if( value instanceof Map ) { + if( getLocation() == null ) { + setLocation(new AirportLocation()); + } + @SuppressWarnings( "unchecked" ) + final Map inputMap = ((Map) value); + getLocation().fromMap(inputMap); + } + if( (value == null) && (getLocation() != null) ) { + setLocation(null); + } + } + } + // navigation properties + { + } + super.fromMap(values); + } + + @Override + protected java.lang.String getDefaultServicePath() + { + return TrippinService.DEFAULT_SERVICE_PATH; + } + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/AirportLocation.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/AirportLocation.java new file mode 100644 index 0000000000..13ae1e0d0c --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/AirportLocation.java @@ -0,0 +1,175 @@ +/* + * Generated by OData VDM code generator of SAP Cloud SDK in version 4.21.0 + */ + +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin; + +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.common.collect.Maps; +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmComplex; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +/** + *

+ * Original complex type name from the Odata EDM: AirportLocation + *

+ * + */ +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@ToString( doNotUseGetters = true, callSuper = true ) +@EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) +@JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) +@JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) +@JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) +public class AirportLocation extends VdmComplex +{ + + @Getter + private final java.lang.String odataType = "Trippin.AirportLocation"; + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Address + *

+ * + * @return The address contained in this {@link VdmComplex}. + */ + @Nullable + @ElementName( "Address" ) + private java.lang.String address; + public final static com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty.String ADDRESS = + new com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty.String( + AirportLocation.class, + "Address"); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: City + *

+ * + * @return The city contained in this {@link VdmComplex}. + */ + @Nullable + @ElementName( "City" ) + private City city; + /** + * Use with available request builders to apply the City complex property to query operations. + * + */ + public final static com.sap.cloud.sdk.datamodel.odatav4.core.ComplexProperty.Single CITY = + new com.sap.cloud.sdk.datamodel.odatav4.core.ComplexProperty.Single( + AirportLocation.class, + "City", + City.class); + + @Nonnull + @Override + public Class getType() + { + return AirportLocation.class; + } + + @Nonnull + @Override + protected Map toMapOfFields() + { + final Map values = super.toMapOfFields(); + values.put("Address", getAddress()); + values.put("City", getCity()); + return values; + } + + @Override + protected void fromMap( final Map inputValues ) + { + final Map values = Maps.newHashMap(inputValues); + // simple properties + { + if( values.containsKey("Address") ) { + final Object value = values.remove("Address"); + if( (value == null) || (!value.equals(getAddress())) ) { + setAddress(((java.lang.String) value)); + } + } + } + // structured properties + { + if( values.containsKey("City") ) { + final Object value = values.remove("City"); + if( value instanceof Map ) { + if( getCity() == null ) { + setCity(new City()); + } + @SuppressWarnings( "unchecked" ) + final Map inputMap = ((Map) value); + getCity().fromMap(inputMap); + } + if( (value == null) && (getCity() != null) ) { + setCity(null); + } + } + } + // navigation properties + { + } + super.fromMap(values); + } + + @Nonnull + @Override + protected ODataEntityKey getKey() + { + final ODataEntityKey entityKey = super.getKey(); + return entityKey; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Address + *

+ * + * @param address + * The address to set. + */ + public void setAddress( @Nullable final java.lang.String address ) + { + rememberChangedField("Address", this.address); + this.address = address; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: City + *

+ * + * @param city + * The city to set. + */ + public void setCity( @Nullable final City city ) + { + rememberChangedField("City", this.city); + this.city = city; + } + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Beverage.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Beverage.java new file mode 100644 index 0000000000..61ff3a4dc7 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Beverage.java @@ -0,0 +1,158 @@ +/* + * Generated by OData VDM code generator of SAP Cloud SDK in version 4.21.0 + */ + +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin; + +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.common.collect.Maps; +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmComplex; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +/** + *

+ * Original complex type name from the Odata EDM: Beverage + *

+ * + */ +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@ToString( doNotUseGetters = true, callSuper = true ) +@EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) +@JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) +@JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) +@JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) +public class Beverage extends VdmComplex +{ + + @Getter + private final java.lang.String odataType = "Trippin.Beverage"; + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Name + *

+ * + * @return The name contained in this {@link VdmComplex}. + */ + @Nullable + @ElementName( "Name" ) + private java.lang.String name; + public final static com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty.String NAME = + new com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty.String(Beverage.class, "Name"); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: IsAlcoholic + *

+ * + * @return The isAlcoholic contained in this {@link VdmComplex}. + */ + @Nullable + @ElementName( "IsAlcoholic" ) + private java.lang.Boolean isAlcoholic; + public final static com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty.Boolean IS_ALCOHOLIC = + new com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty.Boolean(Beverage.class, "IsAlcoholic"); + + @Nonnull + @Override + public Class getType() + { + return Beverage.class; + } + + @Nonnull + @Override + protected Map toMapOfFields() + { + final Map values = super.toMapOfFields(); + values.put("Name", getName()); + values.put("IsAlcoholic", getIsAlcoholic()); + return values; + } + + @Override + protected void fromMap( final Map inputValues ) + { + final Map values = Maps.newHashMap(inputValues); + // simple properties + { + if( values.containsKey("Name") ) { + final Object value = values.remove("Name"); + if( (value == null) || (!value.equals(getName())) ) { + setName(((java.lang.String) value)); + } + } + if( values.containsKey("IsAlcoholic") ) { + final Object value = values.remove("IsAlcoholic"); + if( (value == null) || (!value.equals(getIsAlcoholic())) ) { + setIsAlcoholic(((java.lang.Boolean) value)); + } + } + } + // structured properties + { + } + // navigation properties + { + } + super.fromMap(values); + } + + @Nonnull + @Override + protected ODataEntityKey getKey() + { + final ODataEntityKey entityKey = super.getKey(); + return entityKey; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Name + *

+ * + * @param name + * The name to set. + */ + public void setName( @Nullable final java.lang.String name ) + { + rememberChangedField("Name", this.name); + this.name = name; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: IsAlcoholic + *

+ * + * @param isAlcoholic + * The isAlcoholic to set. + */ + public void setIsAlcoholic( @Nullable final java.lang.Boolean isAlcoholic ) + { + rememberChangedField("IsAlcoholic", this.isAlcoholic); + this.isAlcoholic = isAlcoholic; + } + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/City.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/City.java new file mode 100644 index 0000000000..5398dafc40 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/City.java @@ -0,0 +1,243 @@ +/* + * Generated by OData VDM code generator of SAP Cloud SDK in version 4.21.0 + */ + +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin; + +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.common.collect.Maps; +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmComplex; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +/** + *

+ * Original complex type name from the Odata EDM: City + *

+ * + */ +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@ToString( doNotUseGetters = true, callSuper = true ) +@EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) +@JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) +@JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) +@JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) +public class City extends VdmComplex +{ + + @Getter + private final java.lang.String odataType = "Trippin.City"; + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Name + *

+ * + * @return The name contained in this {@link VdmComplex}. + */ + @Nullable + @ElementName( "Name" ) + private java.lang.String name; + public final static com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty.String NAME = + new com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty.String(City.class, "Name"); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: CountryRegion + *

+ * + * @return The countryRegion contained in this {@link VdmComplex}. + */ + @Nullable + @ElementName( "CountryRegion" ) + private java.lang.String countryRegion; + public final static com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty.String COUNTRY_REGION = + new com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty.String(City.class, "CountryRegion"); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Region + *

+ * + * @return The region contained in this {@link VdmComplex}. + */ + @Nullable + @ElementName( "Region" ) + private java.lang.String region; + public final static com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty.String REGION = + new com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty.String(City.class, "Region"); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: FamousBeverage + *

+ * + * @return The famousBeverage contained in this {@link VdmComplex}. + */ + @Nullable + @ElementName( "FamousBeverage" ) + private Beverage famousBeverage; + /** + * Use with available request builders to apply the FamousBeverage complex property to query operations. + * + */ + public final static com.sap.cloud.sdk.datamodel.odatav4.core.ComplexProperty.Single FAMOUS_BEVERAGE = + new com.sap.cloud.sdk.datamodel.odatav4.core.ComplexProperty.Single( + City.class, + "FamousBeverage", + Beverage.class); + + @Nonnull + @Override + public Class getType() + { + return City.class; + } + + @Nonnull + @Override + protected Map toMapOfFields() + { + final Map values = super.toMapOfFields(); + values.put("Name", getName()); + values.put("CountryRegion", getCountryRegion()); + values.put("Region", getRegion()); + values.put("FamousBeverage", getFamousBeverage()); + return values; + } + + @Override + protected void fromMap( final Map inputValues ) + { + final Map values = Maps.newHashMap(inputValues); + // simple properties + { + if( values.containsKey("Name") ) { + final Object value = values.remove("Name"); + if( (value == null) || (!value.equals(getName())) ) { + setName(((java.lang.String) value)); + } + } + if( values.containsKey("CountryRegion") ) { + final Object value = values.remove("CountryRegion"); + if( (value == null) || (!value.equals(getCountryRegion())) ) { + setCountryRegion(((java.lang.String) value)); + } + } + if( values.containsKey("Region") ) { + final Object value = values.remove("Region"); + if( (value == null) || (!value.equals(getRegion())) ) { + setRegion(((java.lang.String) value)); + } + } + } + // structured properties + { + if( values.containsKey("FamousBeverage") ) { + final Object value = values.remove("FamousBeverage"); + if( value instanceof Map ) { + if( getFamousBeverage() == null ) { + setFamousBeverage(new Beverage()); + } + @SuppressWarnings( "unchecked" ) + final Map inputMap = ((Map) value); + getFamousBeverage().fromMap(inputMap); + } + if( (value == null) && (getFamousBeverage() != null) ) { + setFamousBeverage(null); + } + } + } + // navigation properties + { + } + super.fromMap(values); + } + + @Nonnull + @Override + protected ODataEntityKey getKey() + { + final ODataEntityKey entityKey = super.getKey(); + return entityKey; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Name + *

+ * + * @param name + * The name to set. + */ + public void setName( @Nullable final java.lang.String name ) + { + rememberChangedField("Name", this.name); + this.name = name; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: CountryRegion + *

+ * + * @param countryRegion + * The countryRegion to set. + */ + public void setCountryRegion( @Nullable final java.lang.String countryRegion ) + { + rememberChangedField("CountryRegion", this.countryRegion); + this.countryRegion = countryRegion; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Region + *

+ * + * @param region + * The region to set. + */ + public void setRegion( @Nullable final java.lang.String region ) + { + rememberChangedField("Region", this.region); + this.region = region; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: FamousBeverage + *

+ * + * @param famousBeverage + * The famousBeverage to set. + */ + public void setFamousBeverage( @Nullable final Beverage famousBeverage ) + { + rememberChangedField("FamousBeverage", this.famousBeverage); + this.famousBeverage = famousBeverage; + } + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Feature.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Feature.java new file mode 100644 index 0000000000..5d80fdce0c --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Feature.java @@ -0,0 +1,72 @@ +/* + * Generated by OData VDM code generator of SAP Cloud SDK in version 4.21.0 + */ + +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory; +import com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmEnumDeserializer; +import com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmEnumSerializer; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEnum; + +/** + *

+ * Original enum type name from the Odata EDM: Feature + *

+ * + */ +@JsonAdapter( GsonVdmAdapterFactory.class ) +@JsonSerialize( using = JacksonVdmEnumSerializer.class ) +@JsonDeserialize( using = JacksonVdmEnumDeserializer.class ) +public enum Feature implements VdmEnum +{ + + /** + * Feature1 + * + */ + FEATURE1("Feature1", 0L), + + /** + * Feature2 + * + */ + FEATURE2("Feature2", 1L), + + /** + * Feature3 + * + */ + FEATURE3("Feature3", 2L), + + /** + * Feature4 + * + */ + FEATURE4("Feature4", 3L); + + private final String name; + private final Long value; + + private Feature( final String enumName, final Long enumValue ) + { + name = enumName; + value = enumValue; + } + + @Override + public String getName() + { + return name; + } + + @Override + public Long getValue() + { + return value; + } + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Location.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Location.java new file mode 100644 index 0000000000..36a0810f1a --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Location.java @@ -0,0 +1,173 @@ +/* + * Generated by OData VDM code generator of SAP Cloud SDK in version 4.21.0 + */ + +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin; + +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.common.collect.Maps; +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmComplex; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +/** + *

+ * Original complex type name from the Odata EDM: Location + *

+ * + */ +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@ToString( doNotUseGetters = true, callSuper = true ) +@EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) +@JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) +@JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) +@JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) +public class Location extends VdmComplex +{ + + @Getter + private final java.lang.String odataType = "Trippin.Location"; + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Address + *

+ * + * @return The address contained in this {@link VdmComplex}. + */ + @Nullable + @ElementName( "Address" ) + private java.lang.String address; + public final static com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty.String ADDRESS = + new com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty.String(Location.class, "Address"); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: City + *

+ * + * @return The city contained in this {@link VdmComplex}. + */ + @Nullable + @ElementName( "City" ) + private City city; + /** + * Use with available request builders to apply the City complex property to query operations. + * + */ + public final static com.sap.cloud.sdk.datamodel.odatav4.core.ComplexProperty.Single CITY = + new com.sap.cloud.sdk.datamodel.odatav4.core.ComplexProperty.Single( + Location.class, + "City", + City.class); + + @Nonnull + @Override + public Class getType() + { + return Location.class; + } + + @Nonnull + @Override + protected Map toMapOfFields() + { + final Map values = super.toMapOfFields(); + values.put("Address", getAddress()); + values.put("City", getCity()); + return values; + } + + @Override + protected void fromMap( final Map inputValues ) + { + final Map values = Maps.newHashMap(inputValues); + // simple properties + { + if( values.containsKey("Address") ) { + final Object value = values.remove("Address"); + if( (value == null) || (!value.equals(getAddress())) ) { + setAddress(((java.lang.String) value)); + } + } + } + // structured properties + { + if( values.containsKey("City") ) { + final Object value = values.remove("City"); + if( value instanceof Map ) { + if( getCity() == null ) { + setCity(new City()); + } + @SuppressWarnings( "unchecked" ) + final Map inputMap = ((Map) value); + getCity().fromMap(inputMap); + } + if( (value == null) && (getCity() != null) ) { + setCity(null); + } + } + } + // navigation properties + { + } + super.fromMap(values); + } + + @Nonnull + @Override + protected ODataEntityKey getKey() + { + final ODataEntityKey entityKey = super.getKey(); + return entityKey; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Address + *

+ * + * @param address + * The address to set. + */ + public void setAddress( @Nullable final java.lang.String address ) + { + rememberChangedField("Address", this.address); + this.address = address; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: City + *

+ * + * @param city + * The city to set. + */ + public void setCity( @Nullable final City city ) + { + rememberChangedField("City", this.city); + this.city = city; + } + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Person.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Person.java new file mode 100644 index 0000000000..d9c5b18af3 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Person.java @@ -0,0 +1,1246 @@ +/* + * Generated by OData VDM code generator of SAP Cloud SDK in version 4.21.0 + */ + +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEntity; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEntitySet; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEnum; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services.TrippinService; +import com.sap.cloud.sdk.result.ElementName; + +import io.vavr.control.Option; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +/** + *

+ * Original entity name from the Odata EDM: Person + *

+ * + */ +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@ToString( doNotUseGetters = true, callSuper = true ) +@EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) +@JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) +@JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) +@JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) +public class Person extends VdmEntity implements VdmEntitySet +{ + + @Getter + private final java.lang.String odataType = "Trippin.Person"; + /** + * Selector for all available fields of Person. + * + */ + public final static SimpleProperty ALL_FIELDS = all(); + /** + * (Key Field) Constraints: Not nullable + *

+ * Original property name from the Odata EDM: UserName + *

+ * + * @return The userName contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "UserName" ) + private java.lang.String userName; + public final static SimpleProperty.String USER_NAME = + new SimpleProperty.String(Person.class, "UserName"); + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: FirstName + *

+ * + * @return The firstName contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "FirstName" ) + private java.lang.String firstName; + public final static SimpleProperty.String FIRST_NAME = + new SimpleProperty.String(Person.class, "FirstName"); + /** + * Constraints: Nullable, Maximum length: 26 + *

+ * Original property name from the Odata EDM: LastName + *

+ * + * @return The lastName contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "LastName" ) + private java.lang.String lastName; + public final static SimpleProperty.String LAST_NAME = + new SimpleProperty.String(Person.class, "LastName"); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: MiddleName + *

+ * + * @return The middleName contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "MiddleName" ) + private java.lang.String middleName; + public final static SimpleProperty.String MIDDLE_NAME = + new SimpleProperty.String(Person.class, "MiddleName"); + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: Gender + *

+ * + * @return The gender contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "Gender" ) + private PersonGender gender; + public final static SimpleProperty.Enum GENDER = + new SimpleProperty.Enum(Person.class, "Gender", "Trippin.PersonGender"); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Age + *

+ * + * @return The age contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "Age" ) + private Long age; + public final static SimpleProperty.NumericInteger AGE = + new SimpleProperty.NumericInteger(Person.class, "Age"); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Emails + *

+ * + * @return The emails contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "Emails" ) + private java.util.Collection emails; + public final static SimpleProperty.Collection EMAILS = + new SimpleProperty.Collection(Person.class, "Emails", java.lang.String.class); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: AddressInfo + *

+ * + * @return The addressInfo contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "AddressInfo" ) + private java.util.Collection addressInfo; + /** + * Use with available request builders to apply the AddressInfo complex property to query operations. + * + */ + public final static com.sap.cloud.sdk.datamodel.odatav4.core.ComplexProperty.Collection ADDRESS_INFO = + new com.sap.cloud.sdk.datamodel.odatav4.core.ComplexProperty.Collection( + Person.class, + "AddressInfo", + Location.class); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: HomeAddress + *

+ * + * @return The homeAddress contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "HomeAddress" ) + private Location homeAddress; + /** + * Use with available request builders to apply the HomeAddress complex property to query operations. + * + */ + public final static com.sap.cloud.sdk.datamodel.odatav4.core.ComplexProperty.Single HOME_ADDRESS = + new com.sap.cloud.sdk.datamodel.odatav4.core.ComplexProperty.Single( + Person.class, + "HomeAddress", + Location.class); + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: FavoriteFeature + *

+ * + * @return The favoriteFeature contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "FavoriteFeature" ) + private Feature favoriteFeature; + public final static SimpleProperty.Enum FAVORITE_FEATURE = + new SimpleProperty.Enum(Person.class, "FavoriteFeature", "Trippin.Feature"); + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: Features + *

+ * + * @return The features contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "Features" ) + private java.util.Collection features; + public final static SimpleProperty.Collection FEATURES = + new SimpleProperty.Collection(Person.class, "Features", Feature.class); + /** + * Navigation property Friends for Person to multiple Person. + * + */ + @ElementName( "Friends" ) + @Getter( AccessLevel.NONE ) + @Setter( AccessLevel.NONE ) + private List toFriends; + /** + * Navigation property BestFriend for Person to single Person. + * + */ + @ElementName( "BestFriend" ) + @Nullable + @Getter( AccessLevel.NONE ) + @Setter( AccessLevel.NONE ) + private Person toBestFriend; + /** + * Navigation property Trips for Person to multiple Trip. + * + */ + @ElementName( "Trips" ) + @Getter( AccessLevel.NONE ) + @Setter( AccessLevel.NONE ) + private List toTrips; + /** + * Use with available request builders to apply the Friends navigation property to query operations. + * + */ + public final static com.sap.cloud.sdk.datamodel.odatav4.core.NavigationProperty.Collection TO_FRIENDS = + new com.sap.cloud.sdk.datamodel.odatav4.core.NavigationProperty.Collection( + Person.class, + "Friends", + Person.class); + /** + * Use with available request builders to apply the BestFriend navigation property to query operations. + * + */ + public final static com.sap.cloud.sdk.datamodel.odatav4.core.NavigationProperty.Single TO_BEST_FRIEND = + new com.sap.cloud.sdk.datamodel.odatav4.core.NavigationProperty.Single( + Person.class, + "BestFriend", + Person.class); + /** + * Use with available request builders to apply the Trips navigation property to query operations. + * + */ + public final static com.sap.cloud.sdk.datamodel.odatav4.core.NavigationProperty.Collection TO_TRIPS = + new com.sap.cloud.sdk.datamodel.odatav4.core.NavigationProperty.Collection( + Person.class, + "Trips", + Trip.class); + + @Nonnull + @Override + public Class getType() + { + return Person.class; + } + + /** + * (Key Field) Constraints: Not nullable + *

+ * Original property name from the Odata EDM: UserName + *

+ * + * @param userName + * The userName to set. + */ + public void setUserName( @Nullable final java.lang.String userName ) + { + rememberChangedField("UserName", this.userName); + this.userName = userName; + } + + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: FirstName + *

+ * + * @param firstName + * The firstName to set. + */ + public void setFirstName( @Nullable final java.lang.String firstName ) + { + rememberChangedField("FirstName", this.firstName); + this.firstName = firstName; + } + + /** + * Constraints: Nullable, Maximum length: 26 + *

+ * Original property name from the Odata EDM: LastName + *

+ * + * @param lastName + * The lastName to set. + */ + public void setLastName( @Nullable final java.lang.String lastName ) + { + rememberChangedField("LastName", this.lastName); + this.lastName = lastName; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: MiddleName + *

+ * + * @param middleName + * The middleName to set. + */ + public void setMiddleName( @Nullable final java.lang.String middleName ) + { + rememberChangedField("MiddleName", this.middleName); + this.middleName = middleName; + } + + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: Gender + *

+ * + * @param gender + * The gender to set. + */ + public void setGender( @Nullable final PersonGender gender ) + { + rememberChangedField("Gender", this.gender); + this.gender = gender; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Age + *

+ * + * @param age + * The age to set. + */ + public void setAge( @Nullable final Long age ) + { + rememberChangedField("Age", this.age); + this.age = age; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Emails + *

+ * + * @param emails + * The emails to set. + */ + public void setEmails( @Nullable final java.util.Collection emails ) + { + rememberChangedField("Emails", this.emails); + this.emails = emails; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: AddressInfo + *

+ * + * @param addressInfo + * The addressInfo to set. + */ + public void setAddressInfo( @Nullable final java.util.Collection addressInfo ) + { + rememberChangedField("AddressInfo", this.addressInfo); + this.addressInfo = addressInfo; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: HomeAddress + *

+ * + * @param homeAddress + * The homeAddress to set. + */ + public void setHomeAddress( @Nullable final Location homeAddress ) + { + rememberChangedField("HomeAddress", this.homeAddress); + this.homeAddress = homeAddress; + } + + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: FavoriteFeature + *

+ * + * @param favoriteFeature + * The favoriteFeature to set. + */ + public void setFavoriteFeature( @Nullable final Feature favoriteFeature ) + { + rememberChangedField("FavoriteFeature", this.favoriteFeature); + this.favoriteFeature = favoriteFeature; + } + + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: Features + *

+ * + * @param features + * The features to set. + */ + public void setFeatures( @Nullable final java.util.Collection features ) + { + rememberChangedField("Features", this.features); + this.features = features; + } + + @Override + protected java.lang.String getEntityCollection() + { + return "People"; + } + + @Nonnull + @Override + protected ODataEntityKey getKey() + { + final ODataEntityKey entityKey = super.getKey(); + entityKey.addKeyProperty("UserName", getUserName()); + return entityKey; + } + + @Nonnull + @Override + protected Map toMapOfFields() + { + final Map values = super.toMapOfFields(); + values.put("UserName", getUserName()); + values.put("FirstName", getFirstName()); + values.put("LastName", getLastName()); + values.put("MiddleName", getMiddleName()); + values.put("Gender", getGender()); + values.put("Age", getAge()); + values.put("Emails", getEmails()); + values.put("AddressInfo", getAddressInfo()); + values.put("HomeAddress", getHomeAddress()); + values.put("FavoriteFeature", getFavoriteFeature()); + values.put("Features", getFeatures()); + return values; + } + + @Override + protected void fromMap( final Map inputValues ) + { + final Map values = Maps.newHashMap(inputValues); + // simple properties + { + if( values.containsKey("UserName") ) { + final Object value = values.remove("UserName"); + if( (value == null) || (!value.equals(getUserName())) ) { + setUserName(((java.lang.String) value)); + } + } + if( values.containsKey("FirstName") ) { + final Object value = values.remove("FirstName"); + if( (value == null) || (!value.equals(getFirstName())) ) { + setFirstName(((java.lang.String) value)); + } + } + if( values.containsKey("LastName") ) { + final Object value = values.remove("LastName"); + if( (value == null) || (!value.equals(getLastName())) ) { + setLastName(((java.lang.String) value)); + } + } + if( values.containsKey("MiddleName") ) { + final Object value = values.remove("MiddleName"); + if( (value == null) || (!value.equals(getMiddleName())) ) { + setMiddleName(((java.lang.String) value)); + } + } + if( values.containsKey("Gender") ) { + final Object value = values.remove("Gender"); + if( (value instanceof java.lang.String) || (value == null) ) { + final PersonGender gender = VdmEnum.getConstant(PersonGender.class, ((java.lang.String) value)); + if( !Objects.equals(gender, getGender()) ) { + setGender(gender); + } + } + } + if( values.containsKey("Age") ) { + final Object value = values.remove("Age"); + if( (value == null) || (!value.equals(getAge())) ) { + setAge(((Long) value)); + } + } + if( values.containsKey("Emails") ) { + final Object value = values.remove("Emails"); + if( value instanceof Iterable ) { + final LinkedList emails = new LinkedList(); + for( Object item : ((Iterable) value) ) { + emails.add(((java.lang.String) item)); + } + setEmails(emails); + } + } + if( values.containsKey("FavoriteFeature") ) { + final Object value = values.remove("FavoriteFeature"); + if( (value instanceof java.lang.String) || (value == null) ) { + final Feature favoriteFeature = VdmEnum.getConstant(Feature.class, ((java.lang.String) value)); + if( !Objects.equals(favoriteFeature, getFavoriteFeature()) ) { + setFavoriteFeature(favoriteFeature); + } + } + } + if( values.containsKey("Features") ) { + final Object value = values.remove("Features"); + if( (value == null) && (getFeatures() != null) ) { + setFeatures(null); + } + if( value instanceof Iterable ) { + final LinkedList features = new LinkedList(); + for( Object item : ((Iterable) value) ) { + if( item instanceof java.lang.String ) { + final Feature enumConstant = VdmEnum.getConstant(Feature.class, ((java.lang.String) item)); + features.add(enumConstant); + } + } + if( !Objects.equals(features, getFeatures()) ) { + setFeatures(features); + } + } + } + } + // structured properties + { + if( values.containsKey("AddressInfo") ) { + final Object value = values.remove("AddressInfo"); + if( value instanceof Iterable ) { + final LinkedList addressInfo = new LinkedList(); + for( Object properties : ((Iterable) value) ) { + if( properties instanceof Map ) { + final Location item = new Location(); + @SuppressWarnings( "unchecked" ) + final Map inputMap = ((Map) value); + item.fromMap(inputMap); + addressInfo.add(item); + } + } + setAddressInfo(addressInfo); + } + if( (value == null) && (getAddressInfo() != null) ) { + setAddressInfo(null); + } + } + if( values.containsKey("HomeAddress") ) { + final Object value = values.remove("HomeAddress"); + if( value instanceof Map ) { + if( getHomeAddress() == null ) { + setHomeAddress(new Location()); + } + @SuppressWarnings( "unchecked" ) + final Map inputMap = ((Map) value); + getHomeAddress().fromMap(inputMap); + } + if( (value == null) && (getHomeAddress() != null) ) { + setHomeAddress(null); + } + } + } + // navigation properties + { + if( (values).containsKey("Friends") ) { + final Object value = (values).remove("Friends"); + if( value instanceof Iterable ) { + if( toFriends == null ) { + toFriends = Lists.newArrayList(); + } else { + toFriends = Lists.newArrayList(toFriends); + } + int i = 0; + for( Object item : ((Iterable) value) ) { + if( !(item instanceof Map) ) { + continue; + } + Person entity; + if( toFriends.size() > i ) { + entity = toFriends.get(i); + } else { + entity = new Person(); + toFriends.add(entity); + } + i = (i + 1); + @SuppressWarnings( "unchecked" ) + final Map inputMap = ((Map) item); + entity.fromMap(inputMap); + } + } + } + if( (values).containsKey("BestFriend") ) { + final Object value = (values).remove("BestFriend"); + if( value instanceof Map ) { + if( toBestFriend == null ) { + toBestFriend = new Person(); + } + @SuppressWarnings( "unchecked" ) + final Map inputMap = ((Map) value); + toBestFriend.fromMap(inputMap); + } + } + if( (values).containsKey("Trips") ) { + final Object value = (values).remove("Trips"); + if( value instanceof Iterable ) { + if( toTrips == null ) { + toTrips = Lists.newArrayList(); + } else { + toTrips = Lists.newArrayList(toTrips); + } + int i = 0; + for( Object item : ((Iterable) value) ) { + if( !(item instanceof Map) ) { + continue; + } + Trip entity; + if( toTrips.size() > i ) { + entity = toTrips.get(i); + } else { + entity = new Trip(); + toTrips.add(entity); + } + i = (i + 1); + @SuppressWarnings( "unchecked" ) + final Map inputMap = ((Map) item); + entity.fromMap(inputMap); + } + } + } + } + super.fromMap(values); + } + + @Override + protected java.lang.String getDefaultServicePath() + { + return TrippinService.DEFAULT_SERVICE_PATH; + } + + @Nonnull + @Override + protected Map toMapOfNavigationProperties() + { + final Map values = super.toMapOfNavigationProperties(); + if( toFriends != null ) { + (values).put("Friends", toFriends); + } + if( toBestFriend != null ) { + (values).put("BestFriend", toBestFriend); + } + if( toTrips != null ) { + (values).put("Trips", toTrips); + } + return values; + } + + /** + * Retrieval of associated Person entities (one to many). This corresponds to the OData navigation property + * Friends. + *

+ * If the navigation property for an entity Person has not been resolved yet, this method will not + * query further information. Instead its Option result state will be empty. + * + * @return If the information for navigation property Friends is already loaded, the result will contain the + * Person entities. If not, an Option with result state empty is returned. + */ + @Nonnull + public Option> getFriendsIfPresent() + { + return Option.of(toFriends); + } + + /** + * Overwrites the list of associated Person entities for the loaded navigation property Friends. + *

+ * If the navigation property Friends of a queried Person is operated lazily, an ODataException + * can be thrown in case of an OData query error. + *

+ * Please note: Lazy loading of OData entity associations is the process of asynchronous retrieval and + * persisting of items from a navigation property. If a lazy property is requested by the application for the + * first time and it has not yet been loaded, an OData query will be run in order to load the missing information + * and its result will get cached for future invocations. + * + * @param value + * List of Person entities. + */ + public void setFriends( @Nonnull final List value ) + { + if( toFriends == null ) { + toFriends = Lists.newArrayList(); + } + toFriends.clear(); + toFriends.addAll(value); + } + + /** + * Adds elements to the list of associated Person entities. This corresponds to the OData navigation property + * Friends. + *

+ * If the navigation property Friends of a queried Person is operated lazily, an ODataException + * can be thrown in case of an OData query error. + *

+ * Please note: Lazy loading of OData entity associations is the process of asynchronous retrieval and + * persisting of items from a navigation property. If a lazy property is requested by the application for the + * first time and it has not yet been loaded, an OData query will be run in order to load the missing information + * and its result will get cached for future invocations. + * + * @param entity + * Array of Person entities. + */ + public void addFriends( Person... entity ) + { + if( toFriends == null ) { + toFriends = Lists.newArrayList(); + } + toFriends.addAll(Lists.newArrayList(entity)); + } + + /** + * Retrieval of associated Person entity (one to one). This corresponds to the OData navigation property + * BestFriend. + *

+ * If the navigation property for an entity Person has not been resolved yet, this method will not + * query further information. Instead its Option result state will be empty. + * + * @return If the information for navigation property BestFriend is already loaded, the result will contain + * the Person entity. If not, an Option with result state empty is + * returned. + */ + @Nonnull + public Option getBestFriendIfPresent() + { + return Option.of(toBestFriend); + } + + /** + * Overwrites the associated Person entity for the loaded navigation property BestFriend. + * + * @param value + * New Person entity. + */ + public void setBestFriend( final Person value ) + { + toBestFriend = value; + } + + /** + * Retrieval of associated Trip entities (one to many). This corresponds to the OData navigation property + * Trips. + *

+ * If the navigation property for an entity Person has not been resolved yet, this method will not + * query further information. Instead its Option result state will be empty. + * + * @return If the information for navigation property Trips is already loaded, the result will contain the + * Trip entities. If not, an Option with result state empty is returned. + */ + @Nonnull + public Option> getTripsIfPresent() + { + return Option.of(toTrips); + } + + /** + * Overwrites the list of associated Trip entities for the loaded navigation property Trips. + *

+ * If the navigation property Trips of a queried Person is operated lazily, an ODataException + * can be thrown in case of an OData query error. + *

+ * Please note: Lazy loading of OData entity associations is the process of asynchronous retrieval and + * persisting of items from a navigation property. If a lazy property is requested by the application for the + * first time and it has not yet been loaded, an OData query will be run in order to load the missing information + * and its result will get cached for future invocations. + * + * @param value + * List of Trip entities. + */ + public void setTrips( @Nonnull final List value ) + { + if( toTrips == null ) { + toTrips = Lists.newArrayList(); + } + toTrips.clear(); + toTrips.addAll(value); + } + + /** + * Adds elements to the list of associated Trip entities. This corresponds to the OData navigation property + * Trips. + *

+ * If the navigation property Trips of a queried Person is operated lazily, an ODataException + * can be thrown in case of an OData query error. + *

+ * Please note: Lazy loading of OData entity associations is the process of asynchronous retrieval and + * persisting of items from a navigation property. If a lazy property is requested by the application for the + * first time and it has not yet been loaded, an OData query will be run in order to load the missing information + * and its result will get cached for future invocations. + * + * @param entity + * Array of Trip entities. + */ + public void addTrips( Trip... entity ) + { + if( toTrips == null ) { + toTrips = Lists.newArrayList(); + } + toTrips.addAll(Lists.newArrayList(entity)); + } + + /** + * Function that can be applied to any entity object of this class. + *

+ * + * @return Function object prepared with the given parameters to be applied to any entity object of this class. + *

+ * To execute it use the {@code service.forEntity(entity).applyFunction(thisFunction)} API. + */ + @Nonnull + public static + com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.SingleToSingle + getFavoriteAirline() + { + final Map parameters = Collections.emptyMap(); + return new com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.SingleToSingle( + Person.class, + Airline.class, + "Trippin.GetFavoriteAirline", + parameters); + } + + /** + * Function that can be applied to any entity object of this class. + *

+ * + * @param userName + * Constraints: Not nullable + *

+ * Original parameter name from the Odata EDM: userName + *

+ * @return Function object prepared with the given parameters to be applied to any entity object of this class. + *

+ * To execute it use the {@code service.forEntity(entity).applyFunction(thisFunction)} API. + */ + @Nonnull + public static + com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.SingleToCollection + getFriendsTrips( @Nonnull final java.lang.String userName ) + { + final Map parameters = new HashMap(); + parameters.put("userName", userName); + return new com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.SingleToCollection( + Person.class, + Trip.class, + "Trippin.GetFriendsTrips", + parameters); + } + + /** + * Function that can be applied to any entity object of this class. + *

+ * + * @return Function object prepared with the given parameters to be applied to any entity object of this class. + *

+ * To execute it use the {@code service.forEntity(entity).applyFunction(thisFunction)} API. + */ + @Nonnull + public static com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.SingleToSingle isHappy() + { + final Map parameters = Collections.emptyMap(); + return new com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.SingleToSingle( + Person.class, + Boolean.class, + "Trippin.IsHappy", + parameters); + } + + /** + * Function that can be applied to any entity object of this class. + *

+ * + * @param really + * Constraints: Nullable + *

+ * Original parameter name from the Odata EDM: really + *

+ * @return Function object prepared with the given parameters to be applied to any entity object of this class. + *

+ * To execute it use the {@code service.forEntity(entity).applyFunction(thisFunction)} API. + */ + @Nonnull + public static com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.SingleToSingle isHappy( + @Nullable final Boolean really ) + { + final Map parameters = new HashMap(); + parameters.put("really", really); + return new com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.SingleToSingle( + Person.class, + Boolean.class, + "Trippin.IsHappy", + parameters); + } + + /** + * Function that can be applied to a collection of entities of this class. + *

+ * + * @return Function object prepared with the given parameters to be applied to a collection of entities of this + * class. + *

+ * To execute it use the {@code service.forEntity(entity).applyFunction(thisFunction)} API. + */ + @Nonnull + public static + com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.CollectionToSingle + areAllFriends() + { + final Map parameters = Collections.emptyMap(); + return new com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.CollectionToSingle( + Person.class, + Boolean.class, + "Trippin.AreAllFriends", + parameters); + } + + /** + * Function that can be applied to any entity object of this class. + *

+ * + * @return Function object prepared with the given parameters to be applied to any entity object of this class. + *

+ * To execute it use the {@code service.forEntity(entity).applyFunction(thisFunction)} API. + */ + @Nonnull + public static + com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.SingleToSingleEntity.Composable + worstFriend() + { + final Map parameters = Collections.emptyMap(); + return new com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.SingleToSingleEntity.Composable( + Person.class, + Person.class, + "Trippin.WorstFriend", + parameters); + } + + /** + * Function that can be applied to a collection of entities of this class. + *

+ * + * @return Function object prepared with the given parameters to be applied to a collection of entities of this + * class. + *

+ * To execute it use the {@code service.forEntity(entity).applyFunction(thisFunction)} API. + */ + @Nonnull + public static + com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.CollectionToSingleEntity.Composable + mostPopularPerson() + { + final Map parameters = Collections.emptyMap(); + return new com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.CollectionToSingleEntity.Composable( + Person.class, + Person.class, + "Trippin.MostPopularPerson", + parameters); + } + + /** + * Function that can be applied to a collection of entities of this class. + *

+ * + * @return Function object prepared with the given parameters to be applied to a collection of entities of this + * class. + *

+ * To execute it use the {@code service.forEntity(entity).applyFunction(thisFunction)} API. + */ + @Nonnull + public static + com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.CollectionToCollectionEntity.Composable + mostPopularPersons() + { + final Map parameters = Collections.emptyMap(); + return new com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.CollectionToCollectionEntity.Composable( + Person.class, + Person.class, + "Trippin.MostPopularPersons", + parameters); + } + + /** + * Action that can be applied to any entity object of this class. + *

+ * + * @param lastName + * Constraints: Not nullable + *

+ * Original parameter name from the Odata EDM: lastName + *

+ * @return Action object prepared with the given parameters to be applied to any entity object of this class. + *

+ * To execute it use the {@code service.forEntity(entity).applyAction(thisAction)} API. + */ + @Nonnull + public static com.sap.cloud.sdk.datamodel.odatav4.core.BoundAction.SingleToSingle updateLastName( + @Nonnull final java.lang.String lastName ) + { + final Map parameters = new HashMap(); + parameters.put("lastName", lastName); + return new com.sap.cloud.sdk.datamodel.odatav4.core.BoundAction.SingleToSingle( + Person.class, + Boolean.class, + "Trippin.UpdateLastName", + parameters); + } + + /** + * Action that can be applied to any entity object of this class. + *

+ * + * @param tripId + * Constraints: Not nullable + *

+ * Original parameter name from the Odata EDM: tripId + *

+ * @param userName + * Constraints: Not nullable + *

+ * Original parameter name from the Odata EDM: userName + *

+ * @return Action object prepared with the given parameters to be applied to any entity object of this class. + *

+ * To execute it use the {@code service.forEntity(entity).applyAction(thisAction)} API. + */ + @Nonnull + public static + com.sap.cloud.sdk.datamodel.odatav4.core.BoundAction.SingleToSingle + shareTrip( @Nonnull final java.lang.String userName, @Nonnull final Integer tripId ) + { + final Map parameters = new HashMap(); + parameters.put("userName", userName); + parameters.put("tripId", tripId); + return new com.sap.cloud.sdk.datamodel.odatav4.core.BoundAction.SingleToSingle( + Person.class, + Void.class, + "Trippin.ShareTrip", + parameters); + } + + /** + * Action that can be applied to any entity object of this class. + *

+ * + * @return Action object prepared with the given parameters to be applied to any entity object of this class. + *

+ * To execute it use the {@code service.forEntity(entity).applyAction(thisAction)} API. + */ + @Nonnull + public static com.sap.cloud.sdk.datamodel.odatav4.core.BoundAction.SingleToSingle makeHappy() + { + final Map parameters = Collections.emptyMap(); + return new com.sap.cloud.sdk.datamodel.odatav4.core.BoundAction.SingleToSingle( + Person.class, + Void.class, + "Trippin.MakeHappy", + parameters); + } + + /** + * Action that can be applied to any entity object of this class. + *

+ * + * @param very + * Constraints: Nullable + *

+ * Original parameter name from the Odata EDM: very + *

+ * @return Action object prepared with the given parameters to be applied to any entity object of this class. + *

+ * To execute it use the {@code service.forEntity(entity).applyAction(thisAction)} API. + */ + @Nonnull + public static com.sap.cloud.sdk.datamodel.odatav4.core.BoundAction.SingleToSingle makeUnhappy( + @Nullable final Boolean very ) + { + final Map parameters = new HashMap(); + parameters.put("very", very); + return new com.sap.cloud.sdk.datamodel.odatav4.core.BoundAction.SingleToSingle( + Person.class, + Person.class, + "Trippin.MakeUnhappy", + parameters); + } + + /** + * Action that can be applied to a collection of entities of this class. + *

+ * + * @return Action object prepared with the given parameters to be applied to a collection of entities of this class. + *

+ * To execute it use the {@code service.forEntity(entity).applyAction(thisAction)} API. + */ + @Nonnull + public static com.sap.cloud.sdk.datamodel.odatav4.core.BoundAction.CollectionToSingle makeAllHappy() + { + final Map parameters = Collections.emptyMap(); + return new com.sap.cloud.sdk.datamodel.odatav4.core.BoundAction.CollectionToSingle( + Person.class, + Void.class, + "Trippin.MakeAllHappy", + parameters); + } + + /** + * Action that can be applied to any entity object of this class. + *

+ * + * @param subject + * Constraints: Nullable + *

+ * Original parameter name from the Odata EDM: subject + *

+ * @return Action object prepared with the given parameters to be applied to any entity object of this class. + *

+ * To execute it use the {@code service.forEntity(entity).applyAction(thisAction)} API. + */ + @Nonnull + public static com.sap.cloud.sdk.datamodel.odatav4.core.BoundAction.SingleToCollection sendMail( + @Nullable final java.lang.String subject ) + { + final Map parameters = new HashMap(); + parameters.put("subject", subject); + return new com.sap.cloud.sdk.datamodel.odatav4.core.BoundAction.SingleToCollection( + Person.class, + Person.class, + "Trippin.sendMail", + parameters); + } + + /** + * Helper class to allow for fluent creation of Person instances. + * + */ + public final static class PersonBuilder + { + + private List toFriends = Lists.newArrayList(); + private Person toBestFriend; + private List toTrips = Lists.newArrayList(); + + private Person.PersonBuilder toFriends( final List value ) + { + toFriends.addAll(value); + return this; + } + + /** + * Navigation property Friends for Person to multiple Person. + * + * @param value + * The Persons to build this Person with. + * @return This Builder to allow for a fluent interface. + */ + @Nonnull + public Person.PersonBuilder friends( Person... value ) + { + return toFriends(Lists.newArrayList(value)); + } + + private Person.PersonBuilder toBestFriend( final Person value ) + { + toBestFriend = value; + return this; + } + + /** + * Navigation property BestFriend for Person to single Person. + * + * @param value + * The Person to build this Person with. + * @return This Builder to allow for a fluent interface. + */ + @Nonnull + public Person.PersonBuilder bestFriend( final Person value ) + { + return toBestFriend(value); + } + + private Person.PersonBuilder toTrips( final List value ) + { + toTrips.addAll(value); + return this; + } + + /** + * Navigation property Trips for Person to multiple Trip. + * + * @param value + * The Trips to build this Person with. + * @return This Builder to allow for a fluent interface. + */ + @Nonnull + public Person.PersonBuilder trips( Trip... value ) + { + return toTrips(Lists.newArrayList(value)); + } + + } + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/PersonGender.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/PersonGender.java new file mode 100644 index 0000000000..d581b02c5d --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/PersonGender.java @@ -0,0 +1,66 @@ +/* + * Generated by OData VDM code generator of SAP Cloud SDK in version 4.21.0 + */ + +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory; +import com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmEnumDeserializer; +import com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmEnumSerializer; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEnum; + +/** + *

+ * Original enum type name from the Odata EDM: PersonGender + *

+ * + */ +@JsonAdapter( GsonVdmAdapterFactory.class ) +@JsonSerialize( using = JacksonVdmEnumSerializer.class ) +@JsonDeserialize( using = JacksonVdmEnumDeserializer.class ) +public enum PersonGender implements VdmEnum +{ + + /** + * Male + * + */ + MALE("Male", 0L), + + /** + * Female + * + */ + FEMALE("Female", 1L), + + /** + * Unknown + * + */ + UNKNOWN("Unknown", 2L); + + private final String name; + private final Long value; + + private PersonGender( final String enumName, final Long enumValue ) + { + name = enumName; + value = enumValue; + } + + @Override + public String getName() + { + return name; + } + + @Override + public Long getValue() + { + return value; + } + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/PlanItem.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/PlanItem.java new file mode 100644 index 0000000000..731f0b86aa --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/PlanItem.java @@ -0,0 +1,286 @@ +/* + * Generated by OData VDM code generator of SAP Cloud SDK in version 4.21.0 + */ + +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.common.collect.Maps; +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEntity; +import com.sap.cloud.sdk.result.ElementName; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +/** + *

+ * Original entity name from the Odata EDM: PlanItem + *

+ * + */ +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@ToString( doNotUseGetters = true, callSuper = true ) +@EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) +@JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) +@JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) +@JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) +public class PlanItem extends VdmEntity +{ + + @Getter + private final java.lang.String odataType = "Trippin.PlanItem"; + /** + * Selector for all available fields of PlanItem. + * + */ + public final static SimpleProperty ALL_FIELDS = all(); + /** + * (Key Field) Constraints: Not nullable + *

+ * Original property name from the Odata EDM: PlanItemId + *

+ * + * @return The planItemId contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "PlanItemId" ) + private Integer planItemId; + public final static SimpleProperty.NumericInteger PLAN_ITEM_ID = + new SimpleProperty.NumericInteger(PlanItem.class, "PlanItemId"); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: ConfirmationCode + *

+ * + * @return The confirmationCode contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "ConfirmationCode" ) + private java.lang.String confirmationCode; + public final static SimpleProperty.String CONFIRMATION_CODE = + new SimpleProperty.String(PlanItem.class, "ConfirmationCode"); + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: StartsAt + *

+ * + * @return The startsAt contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "StartsAt" ) + private OffsetDateTime startsAt; + public final static SimpleProperty.DateTime STARTS_AT = + new SimpleProperty.DateTime(PlanItem.class, "StartsAt"); + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: EndsAt + *

+ * + * @return The endsAt contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "EndsAt" ) + private OffsetDateTime endsAt; + public final static SimpleProperty.DateTime ENDS_AT = + new SimpleProperty.DateTime(PlanItem.class, "EndsAt"); + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: Duration + *

+ * + * @return The duration contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "Duration" ) + private BigDecimal duration; + public final static SimpleProperty.Duration DURATION = + new SimpleProperty.Duration(PlanItem.class, "Duration"); + + @Nonnull + @Override + public Class getType() + { + return PlanItem.class; + } + + /** + * (Key Field) Constraints: Not nullable + *

+ * Original property name from the Odata EDM: PlanItemId + *

+ * + * @param planItemId + * The planItemId to set. + */ + public void setPlanItemId( @Nullable final Integer planItemId ) + { + rememberChangedField("PlanItemId", this.planItemId); + this.planItemId = planItemId; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: ConfirmationCode + *

+ * + * @param confirmationCode + * The confirmationCode to set. + */ + public void setConfirmationCode( @Nullable final java.lang.String confirmationCode ) + { + rememberChangedField("ConfirmationCode", this.confirmationCode); + this.confirmationCode = confirmationCode; + } + + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: StartsAt + *

+ * + * @param startsAt + * The startsAt to set. + */ + public void setStartsAt( @Nullable final OffsetDateTime startsAt ) + { + rememberChangedField("StartsAt", this.startsAt); + this.startsAt = startsAt; + } + + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: EndsAt + *

+ * + * @param endsAt + * The endsAt to set. + */ + public void setEndsAt( @Nullable final OffsetDateTime endsAt ) + { + rememberChangedField("EndsAt", this.endsAt); + this.endsAt = endsAt; + } + + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: Duration + *

+ * + * @param duration + * The duration to set. + */ + public void setDuration( @Nullable final BigDecimal duration ) + { + rememberChangedField("Duration", this.duration); + this.duration = duration; + } + + @Override + protected java.lang.String getEntityCollection() + { + return "PlanItems"; + } + + @Nonnull + @Override + protected ODataEntityKey getKey() + { + final ODataEntityKey entityKey = super.getKey(); + entityKey.addKeyProperty("PlanItemId", getPlanItemId()); + return entityKey; + } + + @Nonnull + @Override + protected Map toMapOfFields() + { + final Map values = super.toMapOfFields(); + values.put("PlanItemId", getPlanItemId()); + values.put("ConfirmationCode", getConfirmationCode()); + values.put("StartsAt", getStartsAt()); + values.put("EndsAt", getEndsAt()); + values.put("Duration", getDuration()); + return values; + } + + @Override + protected void fromMap( final Map inputValues ) + { + final Map values = Maps.newHashMap(inputValues); + // simple properties + { + if( values.containsKey("PlanItemId") ) { + final Object value = values.remove("PlanItemId"); + if( (value == null) || (!value.equals(getPlanItemId())) ) { + setPlanItemId(((Integer) value)); + } + } + if( values.containsKey("ConfirmationCode") ) { + final Object value = values.remove("ConfirmationCode"); + if( (value == null) || (!value.equals(getConfirmationCode())) ) { + setConfirmationCode(((java.lang.String) value)); + } + } + if( values.containsKey("StartsAt") ) { + final Object value = values.remove("StartsAt"); + if( (value == null) || (!value.equals(getStartsAt())) ) { + setStartsAt(((OffsetDateTime) value)); + } + } + if( values.containsKey("EndsAt") ) { + final Object value = values.remove("EndsAt"); + if( (value == null) || (!value.equals(getEndsAt())) ) { + setEndsAt(((OffsetDateTime) value)); + } + } + if( values.containsKey("Duration") ) { + final Object value = values.remove("Duration"); + if( (value == null) || (!value.equals(getDuration())) ) { + setDuration(((BigDecimal) value)); + } + } + } + // structured properties + { + } + // navigation properties + { + } + super.fromMap(values); + } + + @Nonnull + @Override + protected Map toMapOfNavigationProperties() + { + final Map values = super.toMapOfNavigationProperties(); + return values; + } + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Trip.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Trip.java new file mode 100644 index 0000000000..729f984bec --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/namespaces/trippin/Trip.java @@ -0,0 +1,560 @@ +/* + * Generated by OData VDM code generator of SAP Cloud SDK in version 4.21.0 + */ + +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin; + +import java.time.OffsetDateTime; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.gson.annotations.JsonAdapter; +import com.sap.cloud.sdk.datamodel.odata.client.request.ODataEntityKey; +import com.sap.cloud.sdk.datamodel.odatav4.core.SimpleProperty; +import com.sap.cloud.sdk.datamodel.odatav4.core.VdmEntity; +import com.sap.cloud.sdk.result.ElementName; + +import io.vavr.control.Option; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +/** + *

+ * Original entity name from the Odata EDM: Trip + *

+ * + */ +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +@ToString( doNotUseGetters = true, callSuper = true ) +@EqualsAndHashCode( doNotUseGetters = true, callSuper = true ) +@JsonAdapter( com.sap.cloud.sdk.datamodel.odatav4.adapter.GsonVdmAdapterFactory.class ) +@JsonSerialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectSerializer.class ) +@JsonDeserialize( using = com.sap.cloud.sdk.datamodel.odatav4.adapter.JacksonVdmObjectDeserializer.class ) +public class Trip extends VdmEntity +{ + + @Getter + private final java.lang.String odataType = "Trippin.Trip"; + /** + * Selector for all available fields of Trip. + * + */ + public final static SimpleProperty ALL_FIELDS = all(); + /** + * (Key Field) Constraints: Not nullable + *

+ * Original property name from the Odata EDM: TripId + *

+ * + * @return The tripId contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "TripId" ) + private Integer tripId; + public final static SimpleProperty.NumericInteger TRIP_ID = + new SimpleProperty.NumericInteger(Trip.class, "TripId"); + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: ShareId + *

+ * + * @return The shareId contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "ShareId" ) + private UUID shareId; + public final static SimpleProperty.Guid SHARE_ID = new SimpleProperty.Guid(Trip.class, "ShareId"); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Name + *

+ * + * @return The name contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "Name" ) + private java.lang.String name; + public final static SimpleProperty.String NAME = new SimpleProperty.String(Trip.class, "Name"); + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: Budget + *

+ * + * @return The budget contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "Budget" ) + private Float budget; + public final static SimpleProperty.NumericDecimal BUDGET = + new SimpleProperty.NumericDecimal(Trip.class, "Budget"); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Description + *

+ * + * @return The description contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "Description" ) + private java.lang.String description; + public final static SimpleProperty.String DESCRIPTION = + new SimpleProperty.String(Trip.class, "Description"); + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Tags + *

+ * + * @return The tags contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "Tags" ) + private java.util.Collection tags; + public final static SimpleProperty.Collection TAGS = + new SimpleProperty.Collection(Trip.class, "Tags", java.lang.String.class); + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: StartsAt + *

+ * + * @return The startsAt contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "StartsAt" ) + private OffsetDateTime startsAt; + public final static SimpleProperty.DateTime STARTS_AT = + new SimpleProperty.DateTime(Trip.class, "StartsAt"); + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: EndsAt + *

+ * + * @return The endsAt contained in this {@link VdmEntity}. + */ + @Nullable + @ElementName( "EndsAt" ) + private OffsetDateTime endsAt; + public final static SimpleProperty.DateTime ENDS_AT = new SimpleProperty.DateTime(Trip.class, "EndsAt"); + /** + * Navigation property PlanItems for Trip to multiple PlanItem. + * + */ + @ElementName( "PlanItems" ) + @Getter( AccessLevel.NONE ) + @Setter( AccessLevel.NONE ) + private List toPlanItems; + /** + * Use with available request builders to apply the PlanItems navigation property to query operations. + * + */ + public final static com.sap.cloud.sdk.datamodel.odatav4.core.NavigationProperty.Collection TO_PLAN_ITEMS = + new com.sap.cloud.sdk.datamodel.odatav4.core.NavigationProperty.Collection( + Trip.class, + "PlanItems", + PlanItem.class); + + @Nonnull + @Override + public Class getType() + { + return Trip.class; + } + + /** + * (Key Field) Constraints: Not nullable + *

+ * Original property name from the Odata EDM: TripId + *

+ * + * @param tripId + * The tripId to set. + */ + public void setTripId( @Nullable final Integer tripId ) + { + rememberChangedField("TripId", this.tripId); + this.tripId = tripId; + } + + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: ShareId + *

+ * + * @param shareId + * The shareId to set. + */ + public void setShareId( @Nullable final UUID shareId ) + { + rememberChangedField("ShareId", this.shareId); + this.shareId = shareId; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Name + *

+ * + * @param name + * The name to set. + */ + public void setName( @Nullable final java.lang.String name ) + { + rememberChangedField("Name", this.name); + this.name = name; + } + + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: Budget + *

+ * + * @param budget + * The budget to set. + */ + public void setBudget( @Nullable final Float budget ) + { + rememberChangedField("Budget", this.budget); + this.budget = budget; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Description + *

+ * + * @param description + * The description to set. + */ + public void setDescription( @Nullable final java.lang.String description ) + { + rememberChangedField("Description", this.description); + this.description = description; + } + + /** + * Constraints: Nullable + *

+ * Original property name from the Odata EDM: Tags + *

+ * + * @param tags + * The tags to set. + */ + public void setTags( @Nullable final java.util.Collection tags ) + { + rememberChangedField("Tags", this.tags); + this.tags = tags; + } + + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: StartsAt + *

+ * + * @param startsAt + * The startsAt to set. + */ + public void setStartsAt( @Nullable final OffsetDateTime startsAt ) + { + rememberChangedField("StartsAt", this.startsAt); + this.startsAt = startsAt; + } + + /** + * Constraints: Not nullable + *

+ * Original property name from the Odata EDM: EndsAt + *

+ * + * @param endsAt + * The endsAt to set. + */ + public void setEndsAt( @Nullable final OffsetDateTime endsAt ) + { + rememberChangedField("EndsAt", this.endsAt); + this.endsAt = endsAt; + } + + @Override + protected java.lang.String getEntityCollection() + { + return "Trips"; + } + + @Nonnull + @Override + protected ODataEntityKey getKey() + { + final ODataEntityKey entityKey = super.getKey(); + entityKey.addKeyProperty("TripId", getTripId()); + return entityKey; + } + + @Nonnull + @Override + protected Map toMapOfFields() + { + final Map values = super.toMapOfFields(); + values.put("TripId", getTripId()); + values.put("ShareId", getShareId()); + values.put("Name", getName()); + values.put("Budget", getBudget()); + values.put("Description", getDescription()); + values.put("Tags", getTags()); + values.put("StartsAt", getStartsAt()); + values.put("EndsAt", getEndsAt()); + return values; + } + + @Override + protected void fromMap( final Map inputValues ) + { + final Map values = Maps.newHashMap(inputValues); + // simple properties + { + if( values.containsKey("TripId") ) { + final Object value = values.remove("TripId"); + if( (value == null) || (!value.equals(getTripId())) ) { + setTripId(((Integer) value)); + } + } + if( values.containsKey("ShareId") ) { + final Object value = values.remove("ShareId"); + if( (value == null) || (!value.equals(getShareId())) ) { + setShareId(((UUID) value)); + } + } + if( values.containsKey("Name") ) { + final Object value = values.remove("Name"); + if( (value == null) || (!value.equals(getName())) ) { + setName(((java.lang.String) value)); + } + } + if( values.containsKey("Budget") ) { + final Object value = values.remove("Budget"); + if( (value == null) || (!value.equals(getBudget())) ) { + setBudget(((Float) value)); + } + } + if( values.containsKey("Description") ) { + final Object value = values.remove("Description"); + if( (value == null) || (!value.equals(getDescription())) ) { + setDescription(((java.lang.String) value)); + } + } + if( values.containsKey("Tags") ) { + final Object value = values.remove("Tags"); + if( value instanceof Iterable ) { + final LinkedList tags = new LinkedList(); + for( Object item : ((Iterable) value) ) { + tags.add(((java.lang.String) item)); + } + setTags(tags); + } + } + if( values.containsKey("StartsAt") ) { + final Object value = values.remove("StartsAt"); + if( (value == null) || (!value.equals(getStartsAt())) ) { + setStartsAt(((OffsetDateTime) value)); + } + } + if( values.containsKey("EndsAt") ) { + final Object value = values.remove("EndsAt"); + if( (value == null) || (!value.equals(getEndsAt())) ) { + setEndsAt(((OffsetDateTime) value)); + } + } + } + // structured properties + { + } + // navigation properties + { + if( (values).containsKey("PlanItems") ) { + final Object value = (values).remove("PlanItems"); + if( value instanceof Iterable ) { + if( toPlanItems == null ) { + toPlanItems = Lists.newArrayList(); + } else { + toPlanItems = Lists.newArrayList(toPlanItems); + } + int i = 0; + for( Object item : ((Iterable) value) ) { + if( !(item instanceof Map) ) { + continue; + } + PlanItem entity; + if( toPlanItems.size() > i ) { + entity = toPlanItems.get(i); + } else { + entity = new PlanItem(); + toPlanItems.add(entity); + } + i = (i + 1); + @SuppressWarnings( "unchecked" ) + final Map inputMap = ((Map) item); + entity.fromMap(inputMap); + } + } + } + } + super.fromMap(values); + } + + @Nonnull + @Override + protected Map toMapOfNavigationProperties() + { + final Map values = super.toMapOfNavigationProperties(); + if( toPlanItems != null ) { + (values).put("PlanItems", toPlanItems); + } + return values; + } + + /** + * Retrieval of associated PlanItem entities (one to many). This corresponds to the OData navigation property + * PlanItems. + *

+ * If the navigation property for an entity Trip has not been resolved yet, this method will not query + * further information. Instead its Option result state will be empty. + * + * @return If the information for navigation property PlanItems is already loaded, the result will contain + * the PlanItem entities. If not, an Option with result state empty is + * returned. + */ + @Nonnull + public Option> getPlanItemsIfPresent() + { + return Option.of(toPlanItems); + } + + /** + * Overwrites the list of associated PlanItem entities for the loaded navigation property PlanItems. + *

+ * If the navigation property PlanItems of a queried Trip is operated lazily, an ODataException + * can be thrown in case of an OData query error. + *

+ * Please note: Lazy loading of OData entity associations is the process of asynchronous retrieval and + * persisting of items from a navigation property. If a lazy property is requested by the application for the + * first time and it has not yet been loaded, an OData query will be run in order to load the missing information + * and its result will get cached for future invocations. + * + * @param value + * List of PlanItem entities. + */ + public void setPlanItems( @Nonnull final List value ) + { + if( toPlanItems == null ) { + toPlanItems = Lists.newArrayList(); + } + toPlanItems.clear(); + toPlanItems.addAll(value); + } + + /** + * Adds elements to the list of associated PlanItem entities. This corresponds to the OData navigation + * property PlanItems. + *

+ * If the navigation property PlanItems of a queried Trip is operated lazily, an ODataException + * can be thrown in case of an OData query error. + *

+ * Please note: Lazy loading of OData entity associations is the process of asynchronous retrieval and + * persisting of items from a navigation property. If a lazy property is requested by the application for the + * first time and it has not yet been loaded, an OData query will be run in order to load the missing information + * and its result will get cached for future invocations. + * + * @param entity + * Array of PlanItem entities. + */ + public void addPlanItems( PlanItem... entity ) + { + if( toPlanItems == null ) { + toPlanItems = Lists.newArrayList(); + } + toPlanItems.addAll(Lists.newArrayList(entity)); + } + + /** + * Function that can be applied to any entity object of this class. + *

+ * + * @return Function object prepared with the given parameters to be applied to any entity object of this class. + *

+ * To execute it use the {@code service.forEntity(entity).applyFunction(thisFunction)} API. + */ + @Nonnull + public static + com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.SingleToCollection + getInvolvedPeople() + { + final Map parameters = Collections.emptyMap(); + return new com.sap.cloud.sdk.datamodel.odatav4.core.BoundFunction.SingleToCollection( + Trip.class, + Person.class, + "Trippin.GetInvolvedPeople", + parameters); + } + + /** + * Helper class to allow for fluent creation of Trip instances. + * + */ + public final static class TripBuilder + { + + private List toPlanItems = Lists.newArrayList(); + + private Trip.TripBuilder toPlanItems( final List value ) + { + toPlanItems.addAll(value); + return this; + } + + /** + * Navigation property PlanItems for Trip to multiple PlanItem. + * + * @param value + * The PlanItems to build this Trip with. + * @return This Builder to allow for a fluent interface. + */ + @Nonnull + public Trip.TripBuilder planItems( PlanItem... value ) + { + return toPlanItems(Lists.newArrayList(value)); + } + + } + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/services/DefaultTrippinService.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/services/DefaultTrippinService.java new file mode 100644 index 0000000000..fe1d480252 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/services/DefaultTrippinService.java @@ -0,0 +1,242 @@ +/* + * Generated by OData VDM code generator of SAP Cloud SDK in version 4.21.0 + */ + +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odatav4.core.BatchRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.CountRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.CreateRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.DeleteRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.GetAllRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.GetByKeyRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.ServiceWithNavigableEntities; +import com.sap.cloud.sdk.datamodel.odatav4.core.SingleValueActionRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.SingleValueFunctionRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.UpdateRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; + +import lombok.Getter; + +/** + *

Details:

+ * + * + * + * + * + *
OData Service:trippin
+ * + */ +public class DefaultTrippinService implements ServiceWithNavigableEntities, TrippinService +{ + + @Nonnull + @Getter + private final String servicePath; + + /** + * Creates a service using {@link TrippinService#DEFAULT_SERVICE_PATH} to send the requests. + * + */ + public DefaultTrippinService() + { + servicePath = TrippinService.DEFAULT_SERVICE_PATH; + } + + /** + * Creates a service using the provided service path to send the requests. + *

+ * Used by the fluent {@link #withServicePath(String)} method. + * + */ + private DefaultTrippinService( @Nonnull final String servicePath ) + { + this.servicePath = servicePath; + } + + @Override + @Nonnull + public DefaultTrippinService withServicePath( @Nonnull final String servicePath ) + { + return new DefaultTrippinService(servicePath); + } + + @Override + @Nonnull + public BatchRequestBuilder batch() + { + return new BatchRequestBuilder(servicePath); + } + + @Override + @Nonnull + public GetAllRequestBuilder getAllPeople() + { + return new GetAllRequestBuilder(servicePath, Person.class, "People"); + } + + @Override + @Nonnull + public CountRequestBuilder countPeople() + { + return new CountRequestBuilder(servicePath, Person.class, "People"); + } + + @Override + @Nonnull + public GetByKeyRequestBuilder getPeopleByKey( final String userName ) + { + final Map key = new HashMap(); + key.put("UserName", userName); + return new GetByKeyRequestBuilder(servicePath, Person.class, key, "People"); + } + + @Override + @Nonnull + public CreateRequestBuilder createPeople( @Nonnull final Person person ) + { + return new CreateRequestBuilder(servicePath, person, "People"); + } + + @Override + @Nonnull + public UpdateRequestBuilder updatePeople( @Nonnull final Person person ) + { + return new UpdateRequestBuilder(servicePath, person, "People"); + } + + @Override + @Nonnull + public DeleteRequestBuilder deletePeople( @Nonnull final Person person ) + { + return new DeleteRequestBuilder(servicePath, person, "People"); + } + + @Override + @Nonnull + public GetAllRequestBuilder getAllAirlines() + { + return new GetAllRequestBuilder(servicePath, Airline.class, "Airlines"); + } + + @Override + @Nonnull + public CountRequestBuilder countAirlines() + { + return new CountRequestBuilder(servicePath, Airline.class, "Airlines"); + } + + @Override + @Nonnull + public GetByKeyRequestBuilder getAirlinesByKey( final String airlineCode ) + { + final Map key = new HashMap(); + key.put("AirlineCode", airlineCode); + return new GetByKeyRequestBuilder(servicePath, Airline.class, key, "Airlines"); + } + + @Override + @Nonnull + public CreateRequestBuilder createAirlines( @Nonnull final Airline airline ) + { + return new CreateRequestBuilder(servicePath, airline, "Airlines"); + } + + @Override + @Nonnull + public UpdateRequestBuilder updateAirlines( @Nonnull final Airline airline ) + { + return new UpdateRequestBuilder(servicePath, airline, "Airlines"); + } + + @Override + @Nonnull + public DeleteRequestBuilder deleteAirlines( @Nonnull final Airline airline ) + { + return new DeleteRequestBuilder(servicePath, airline, "Airlines"); + } + + @Override + @Nonnull + public GetAllRequestBuilder getAllAirports() + { + return new GetAllRequestBuilder(servicePath, Airport.class, "Airports"); + } + + @Override + @Nonnull + public CountRequestBuilder countAirports() + { + return new CountRequestBuilder(servicePath, Airport.class, "Airports"); + } + + @Override + @Nonnull + public GetByKeyRequestBuilder getAirportsByKey( final String icaoCode ) + { + final Map key = new HashMap(); + key.put("IcaoCode", icaoCode); + return new GetByKeyRequestBuilder(servicePath, Airport.class, key, "Airports"); + } + + @Override + @Nonnull + public CreateRequestBuilder createAirports( @Nonnull final Airport airport ) + { + return new CreateRequestBuilder(servicePath, airport, "Airports"); + } + + @Override + @Nonnull + public UpdateRequestBuilder updateAirports( @Nonnull final Airport airport ) + { + return new UpdateRequestBuilder(servicePath, airport, "Airports"); + } + + @Override + @Nonnull + public DeleteRequestBuilder deleteAirports( @Nonnull final Airport airport ) + { + return new DeleteRequestBuilder(servicePath, airport, "Airports"); + } + + @Override + @Nonnull + public SingleValueFunctionRequestBuilder getPersonWithMostFriends() + { + return new SingleValueFunctionRequestBuilder(servicePath, "GetPersonWithMostFriends", Person.class); + } + + @Override + @Nonnull + public + SingleValueFunctionRequestBuilder + getNearestAirport( @Nonnull final Double lat, @Nonnull final Double lon ) + { + final LinkedHashMap parameters = new LinkedHashMap(); + parameters.put("lat", lat); + parameters.put("lon", lon); + return new SingleValueFunctionRequestBuilder( + servicePath, + "GetNearestAirport", + parameters, + Airport.class); + } + + @Override + @Nonnull + public SingleValueActionRequestBuilder resetDataSource() + { + return new SingleValueActionRequestBuilder(servicePath, "ResetDataSource", Void.class); + } + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/services/TrippinService.java b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/services/TrippinService.java new file mode 100644 index 0000000000..58a2a882d3 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/java/com/sap/cloud/sdk/datamodel/odatav4/referenceservice/services/TrippinService.java @@ -0,0 +1,398 @@ +/* + * Generated by OData VDM code generator of SAP Cloud SDK in version 4.21.0 + */ + +package com.sap.cloud.sdk.datamodel.odatav4.referenceservice.services; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.odatav4.core.BatchRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.CountRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.CreateRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.DeleteRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.GetAllRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.GetByKeyRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.SingleValueActionRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.SingleValueFunctionRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.core.UpdateRequestBuilder; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport; +import com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person; + +/** + *

Details:

+ * + * + * + * + * + *
OData Service:trippin
+ * + */ +public interface TrippinService +{ + + /** + * If no other path was provided via the {@link #withServicePath(String)} method, this is the default service path + * used to access the endpoint. + * + */ + String DEFAULT_SERVICE_PATH = "/TripPinRESTierServiceTrippin"; + + /** + * Overrides the default service path and returns a new service instance with the specified service path. Also + * adjusts the respective entity URLs. + * + * @param servicePath + * Service path that will override the default. + * @return A new service instance with the specified service path. + */ + @Nonnull + TrippinService withServicePath( @Nonnull final String servicePath ); + + /** + * Creates a batch request builder object. + * + * @return A request builder to handle batch operation on this service. To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.BatchRequestBuilder#execute(Destination) execute} method + * on the request builder object. + */ + @Nonnull + BatchRequestBuilder batch(); + + /** + * Fetch multiple {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person Person} + * entities. + * + * @return A request builder to fetch multiple + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person Person} entities. + * This request builder allows methods which modify the underlying query to be called before executing the + * query itself. To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.GetAllRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + GetAllRequestBuilder getAllPeople(); + + /** + * Fetch the number of entries from the + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person Person} entity collection + * matching the filter and search expressions. + * + * @return A request builder to fetch the count of + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person Person} entities. + * This request builder allows methods which modify the underlying query to be called before executing the + * query itself. To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.CountRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + CountRequestBuilder countPeople(); + + /** + * Fetch a single {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person Person} + * entity using key fields. + * + * @param userName + *

+ * Constraints: Not nullable + *

+ * @return A request builder to fetch a single + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person Person} entity + * using key fields. This request builder allows methods which modify the underlying query to be called + * before executing the query itself. To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.GetByKeyRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + GetByKeyRequestBuilder getPeopleByKey( final String userName ); + + /** + * Create a new {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person Person} entity + * and save it to the S/4HANA system. + * + * @param person + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person Person} entity + * object that will be created in the S/4HANA system. + * @return A request builder to create a new + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person Person} entity. To + * perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.CreateRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + CreateRequestBuilder createPeople( @Nonnull final Person person ); + + /** + * Update an existing {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person Person} + * entity and save it to the S/4HANA system. + * + * @param person + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person Person} entity + * object that will be updated in the S/4HANA system. + * @return A request builder to update an existing + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person Person} entity. To + * perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.UpdateRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + UpdateRequestBuilder updatePeople( @Nonnull final Person person ); + + /** + * Deletes an existing {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person Person} + * entity in the S/4HANA system. + * + * @param person + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person Person} entity + * object that will be deleted in the S/4HANA system. + * @return A request builder to delete an existing + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Person Person} entity. To + * perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.DeleteRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + DeleteRequestBuilder deletePeople( @Nonnull final Person person ); + + /** + * Fetch multiple {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline Airline} + * entities. + * + * @return A request builder to fetch multiple + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline Airline} entities. + * This request builder allows methods which modify the underlying query to be called before executing the + * query itself. To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.GetAllRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + GetAllRequestBuilder getAllAirlines(); + + /** + * Fetch the number of entries from the + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline Airline} entity collection + * matching the filter and search expressions. + * + * @return A request builder to fetch the count of + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline Airline} entities. + * This request builder allows methods which modify the underlying query to be called before executing the + * query itself. To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.CountRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + CountRequestBuilder countAirlines(); + + /** + * Fetch a single {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline Airline} + * entity using key fields. + * + * @param airlineCode + *

+ * Constraints: Not nullable + *

+ * @return A request builder to fetch a single + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline Airline} entity + * using key fields. This request builder allows methods which modify the underlying query to be called + * before executing the query itself. To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.GetByKeyRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + GetByKeyRequestBuilder getAirlinesByKey( final String airlineCode ); + + /** + * Create a new {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline Airline} + * entity and save it to the S/4HANA system. + * + * @param airline + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline Airline} entity + * object that will be created in the S/4HANA system. + * @return A request builder to create a new + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline Airline} entity. + * To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.CreateRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + CreateRequestBuilder createAirlines( @Nonnull final Airline airline ); + + /** + * Update an existing {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline + * Airline} entity and save it to the S/4HANA system. + * + * @param airline + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline Airline} entity + * object that will be updated in the S/4HANA system. + * @return A request builder to update an existing + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline Airline} entity. + * To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.UpdateRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + UpdateRequestBuilder updateAirlines( @Nonnull final Airline airline ); + + /** + * Deletes an existing {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline + * Airline} entity in the S/4HANA system. + * + * @param airline + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline Airline} entity + * object that will be deleted in the S/4HANA system. + * @return A request builder to delete an existing + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airline Airline} entity. + * To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.DeleteRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + DeleteRequestBuilder deleteAirlines( @Nonnull final Airline airline ); + + /** + * Fetch multiple {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport Airport} + * entities. + * + * @return A request builder to fetch multiple + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport Airport} entities. + * This request builder allows methods which modify the underlying query to be called before executing the + * query itself. To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.GetAllRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + GetAllRequestBuilder getAllAirports(); + + /** + * Fetch the number of entries from the + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport Airport} entity collection + * matching the filter and search expressions. + * + * @return A request builder to fetch the count of + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport Airport} entities. + * This request builder allows methods which modify the underlying query to be called before executing the + * query itself. To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.CountRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + CountRequestBuilder countAirports(); + + /** + * Fetch a single {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport Airport} + * entity using key fields. + * + * @param icaoCode + *

+ * Constraints: Not nullable + *

+ * @return A request builder to fetch a single + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport Airport} entity + * using key fields. This request builder allows methods which modify the underlying query to be called + * before executing the query itself. To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.GetByKeyRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + GetByKeyRequestBuilder getAirportsByKey( final String icaoCode ); + + /** + * Create a new {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport Airport} + * entity and save it to the S/4HANA system. + * + * @param airport + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport Airport} entity + * object that will be created in the S/4HANA system. + * @return A request builder to create a new + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport Airport} entity. + * To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.CreateRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + CreateRequestBuilder createAirports( @Nonnull final Airport airport ); + + /** + * Update an existing {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport + * Airport} entity and save it to the S/4HANA system. + * + * @param airport + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport Airport} entity + * object that will be updated in the S/4HANA system. + * @return A request builder to update an existing + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport Airport} entity. + * To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.UpdateRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + UpdateRequestBuilder updateAirports( @Nonnull final Airport airport ); + + /** + * Deletes an existing {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport + * Airport} entity in the S/4HANA system. + * + * @param airport + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport Airport} entity + * object that will be deleted in the S/4HANA system. + * @return A request builder to delete an existing + * {@link com.sap.cloud.sdk.datamodel.odatav4.referenceservice.namespaces.trippin.Airport Airport} entity. + * To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.DeleteRequestBuilder#execute + * execute} method on the request builder object. + */ + @Nonnull + DeleteRequestBuilder deleteAirports( @Nonnull final Airport airport ); + + /** + *

+ * Creates a request builder for the GetPersonWithMostFriends OData function. + *

+ * + * @return A request builder object that will execute the GetPersonWithMostFriends OData function with the + * provided parameters. To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.SingleValueFunctionRequestBuilder#execute execute} method + * on the request builder object. + */ + @Nonnull + SingleValueFunctionRequestBuilder getPersonWithMostFriends(); + + /** + *

+ * Creates a request builder for the GetNearestAirport OData function. + *

+ * + * @param lon + * Constraints: Not nullable + *

+ * Original parameter name from the Odata EDM: lon + *

+ * @param lat + * Constraints: Not nullable + *

+ * Original parameter name from the Odata EDM: lat + *

+ * @return A request builder object that will execute the GetNearestAirport OData function with the provided + * parameters. To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.SingleValueFunctionRequestBuilder#execute execute} method + * on the request builder object. + */ + @Nonnull + SingleValueFunctionRequestBuilder + getNearestAirport( @Nonnull final Double lat, @Nonnull final Double lon ); + + /** + *

+ * Creates a request builder for the ResetDataSource OData action. + *

+ * + * @return A request builder object that will execute the ResetDataSource OData action with the provided + * parameters. To perform execution, call the + * {@link com.sap.cloud.sdk.datamodel.odatav4.core.SingleValueActionRequestBuilder#execute execute} method + * on the request builder object. + */ + @Nonnull + SingleValueActionRequestBuilder resetDataSource(); + +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/CollectionValueActionRequestBuilderTest/ActionNoParametersRequestBody.json b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/CollectionValueActionRequestBuilderTest/ActionNoParametersRequestBody.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/CollectionValueActionRequestBuilderTest/ActionNoParametersRequestBody.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/CsrfTokenHandlingTest/CreateResponse.json b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/CsrfTokenHandlingTest/CreateResponse.json new file mode 100644 index 0000000000..6993dec68c --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/CsrfTokenHandlingTest/CreateResponse.json @@ -0,0 +1,4 @@ +{ + "@odata.context": "/sap/ODATA_SRV$metadata#People/$entity", + "ShoeSize": 46 +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ETagTest/GetAllResponseBody.json b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ETagTest/GetAllResponseBody.json new file mode 100644 index 0000000000..379421658b --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ETagTest/GetAllResponseBody.json @@ -0,0 +1,20 @@ +{ + "value": [ + { + "@context": "context", + "@type": "type", + "@etag": "W/\"123\"", + "UserName": "russellwhyte", + "FirstName": "Russell", + "LastName": "Whyte" + }, + { + "@context": "context", + "@type": "type", + "@etag": "W/\"999\"", + "UserName": "scottketchum", + "FirstName": "Scott", + "LastName": "Ketchum" + } + ] +} \ No newline at end of file diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ETagTest/GetSingleResponseBody.json b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ETagTest/GetSingleResponseBody.json new file mode 100644 index 0000000000..552beb42bf --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ETagTest/GetSingleResponseBody.json @@ -0,0 +1,5 @@ +{ + "UserName": "russellwhyte", + "FirstName": "Russell", + "LastName": "Whyte" +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/HeadersHandlingTest/CreateResponse.json b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/HeadersHandlingTest/CreateResponse.json new file mode 100644 index 0000000000..6993dec68c --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/HeadersHandlingTest/CreateResponse.json @@ -0,0 +1,4 @@ +{ + "@odata.context": "/sap/ODATA_SRV$metadata#People/$entity", + "ShoeSize": 46 +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchEmptyRequest.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchEmptyRequest.txt new file mode 100644 index 0000000000..87e43fd868 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchEmptyRequest.txt @@ -0,0 +1 @@ +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchEmptyResponse.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchEmptyResponse.txt new file mode 100644 index 0000000000..906fce312f --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchEmptyResponse.txt @@ -0,0 +1,2 @@ +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef-- + diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchOnlyReadsErrorResponse.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchOnlyReadsErrorResponse.txt new file mode 100644 index 0000000000..a4b70ad11a --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchOnlyReadsErrorResponse.txt @@ -0,0 +1,21 @@ +--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 400 Bad Request +Content-Type: application/json;charset=utf-8 +Content-Length: 1050 +dataserviceversion: 1.0 + +{"error":{"code":"ZCU/100","message":{"lang":"nl","value":"Service 0000000003 0000000010 niet gevonden voor operatie 0410"},"innererror":{"application":{"component_id":"","service_namespace":"/SAP/","service_id":"ZCU_PE_ORDER_SRV","service_version":"0001"},"transactionid":"23F2932D54040110E005FD84A23B406E","timestamp":"20201215151501.0634490","Error_Resolution":{"SAP_Transaction":"Run transaction /IWFND/ERROR_LOG on SAP Gateway hub system (System Alias ) 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)","Batch_SAP_Note":"See SAP Note 1869434 for details about working with $batch (https://service.sap.com/sap/support/notes/1869434)"},"errordetails":[{"code":"ZCU/100","message":"Service 0000000003 0000000010 niet gevonden voor operatie 0410","propertyref":"","severity":"error","target":""},{"code":"/IWBEP/CX_MGW_BUSI_EXCEPTION","message":"Fout bij wijzigen PE order.","propertyref":"","severity":"error","target":""}]}}} +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef-- + diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchOnlyReadsMissingRequest.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchOnlyReadsMissingRequest.txt new file mode 100644 index 0000000000..65acbfb138 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchOnlyReadsMissingRequest.txt @@ -0,0 +1,28 @@ +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 1 + +GET People(%27one%27) HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 2 + +GET People(%27two%27) HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 3 + +GET People(%27three%27) HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchOnlyReadsMissingResponse.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchOnlyReadsMissingResponse.txt new file mode 100644 index 0000000000..14db6511d8 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchOnlyReadsMissingResponse.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":"one"}]} +--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-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchOnlyReadsRequest.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchOnlyReadsRequest.txt new file mode 100644 index 0000000000..075656d033 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchOnlyReadsRequest.txt @@ -0,0 +1,19 @@ +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 1 + +GET People?$top=1 HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 2 + +GET People?$top=2&$skip=1 HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchOnlyReadsResponse.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchOnlyReadsResponse.txt new file mode 100644 index 0000000000..04c690beb0 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchOnlyReadsResponse.txt @@ -0,0 +1,21 @@ +--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-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchReadsAndWritesErrorRequest.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchReadsAndWritesErrorRequest.txt new file mode 100644 index 0000000000..929ca524f6 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchReadsAndWritesErrorRequest.txt @@ -0,0 +1,37 @@ +--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: 1 + +POST People HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{"UserName":"JohnDoe1", "FirstName":"John"} + +--changeset_00000000-0000-0000-0000-000000000002 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 2 + +POST People HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{"UserName":"JohnDoe2"} + +--changeset_00000000-0000-0000-0000-000000000002-- + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 3 + +GET People(%27klauskinski%27) HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchReadsAndWritesErrorResponse.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchReadsAndWritesErrorResponse.txt new file mode 100644 index 0000000000..7486fe8290 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchReadsAndWritesErrorResponse.txt @@ -0,0 +1,23 @@ +--batchresponse_2e5e59e3-ef58-493c-b947-638f445896aa +Content-Type: multipart/mixed; boundary=changesetresponse_4459f831-1259-4f7c-89a0-95162d172489 + +--changesetresponse_4459f831-1259-4f7c-89a0-95162d172489 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 2 + +HTTP/1.1 400 Bad Request +Content-Type: application/json; charset=utf-8 + +{"error": {"code": "005056A509B11EE1B9A8FEC11C23378E","message": {"lang": "en","value": "The FirstName field is required"}}} +--changesetresponse_4459f831-1259-4f7c-89a0-95162d172489-- +--batchresponse_2e5e59e3-ef58-493c-b947-638f445896aa +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/$entity","UserName":"klauskinski","FirstName":"Klaus","LastName":"Kinski","MiddleName":null,"Gender":"Male","Age":null,"Emails":[],"FavoriteFeature":null,"Features":[],"AddressInfo":[],"HomeAddress":null} +--batchresponse_2e5e59e3-ef58-493c-b947-638f445896aa-- diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchReadsAndWritesErrorResponseWithoutChangeset.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchReadsAndWritesErrorResponseWithoutChangeset.txt new file mode 100644 index 0000000000..95a0c1abde --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchReadsAndWritesErrorResponseWithoutChangeset.txt @@ -0,0 +1,19 @@ +--batchresponse_2e5e59e3-ef58-493c-b947-638f445896aa +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 2 + +HTTP/1.1 400 Bad Request +Content-Type: application/json; charset=utf-8 + +[{"Id":"System.ComponentModel.DataAnnotations.RequiredAttribute","Message":"The FirstName field is required.","PropertyName":"FirstName","Severity":"Error"}] +--batchresponse_2e5e59e3-ef58-493c-b947-638f445896aa +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/$entity","UserName":"klauskinski","FirstName":"Klaus","LastName":"Kinski","MiddleName":null,"Gender":"Male","Age":null,"Emails":[],"FavoriteFeature":null,"Features":[],"AddressInfo":[],"HomeAddress":null} +--batchresponse_2e5e59e3-ef58-493c-b947-638f445896aa-- diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchReadsAndWritesSuccessRequest.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchReadsAndWritesSuccessRequest.txt new file mode 100644 index 0000000000..c1517843ad --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchReadsAndWritesSuccessRequest.txt @@ -0,0 +1,37 @@ +--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: 1 + +POST People HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{"UserName":"JohnDoe1", "FirstName":"John"} + +--changeset_00000000-0000-0000-0000-000000000002 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 2 + +POST People HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{"UserName":"JohnDoe2", "FirstName":"John"} + +--changeset_00000000-0000-0000-0000-000000000002-- + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 3 + +GET People(%27foo%27) HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchReadsAndWritesSuccessResponse.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchReadsAndWritesSuccessResponse.txt new file mode 100644 index 0000000000..c69262bcd8 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataClientBatchResponseParsingUnitTest/BatchReadsAndWritesSuccessResponse.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-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchEmptyRequest.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchEmptyRequest.txt new file mode 100644 index 0000000000..87e43fd868 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchEmptyRequest.txt @@ -0,0 +1 @@ +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchEmptyResponse.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchEmptyResponse.txt new file mode 100644 index 0000000000..906fce312f --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchEmptyResponse.txt @@ -0,0 +1,2 @@ +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef-- + diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchOnlyReadsErrorResponse.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchOnlyReadsErrorResponse.txt new file mode 100644 index 0000000000..338622ee98 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchOnlyReadsErrorResponse.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 400 Bad Request +Content-Type: application/json;charset=utf-8 +Content-Length: 1050 + +{"error":{"code":"ZCU/100","message":{"lang":"nl","value":"Service 0000000003 0000000010 niet gevonden voor operatie 0410"},"innererror":{"application":{"component_id":"","service_namespace":"/SAP/","service_id":"ZCU_PE_ORDER_SRV","service_version":"0001"},"transactionid":"23F2932D54040110E005FD84A23B406E","timestamp":"20201215151501.0634490","Error_Resolution":{"SAP_Transaction":"Run transaction /IWFND/ERROR_LOG on SAP Gateway hub system (System Alias ) 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)","Batch_SAP_Note":"See SAP Note 1869434 for details about working with $batch (https://service.sap.com/sap/support/notes/1869434)"},"errordetails":[{"code":"ZCU/100","message":"Service 0000000003 0000000010 niet gevonden voor operatie 0410","propertyref":"","severity":"error","target":""},{"code":"/IWBEP/CX_MGW_BUSI_EXCEPTION","message":"Fout bij wijzigen PE order.","propertyref":"","severity":"error","target":""}]}}} +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef-- + diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchOnlyReadsRequest.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchOnlyReadsRequest.txt new file mode 100644 index 0000000000..075656d033 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchOnlyReadsRequest.txt @@ -0,0 +1,19 @@ +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 1 + +GET People?$top=1 HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 2 + +GET People?$top=2&$skip=1 HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchOnlyReadsResponse.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchOnlyReadsResponse.txt new file mode 100644 index 0000000000..04c690beb0 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchOnlyReadsResponse.txt @@ -0,0 +1,21 @@ +--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-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchReadsAndWritesSuccessRequest.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchReadsAndWritesSuccessRequest.txt new file mode 100644 index 0000000000..b568462c2e --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchReadsAndWritesSuccessRequest.txt @@ -0,0 +1,37 @@ +--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: 1 + +POST People HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{"@odata.type":"#Trippin.Person","UserName":"JohnDoe1","FirstName":"John","Friends":[],"Trips":[]} + +--changeset_00000000-0000-0000-0000-000000000002 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 2 + +POST People HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{"@odata.type":"#Trippin.Person","UserName":"JohnDoe2","FirstName":"John","Friends":[],"Trips":[]} + +--changeset_00000000-0000-0000-0000-000000000002-- + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 3 + +GET People(%27foo%27) HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchReadsAndWritesSuccessResponse.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchReadsAndWritesSuccessResponse.txt new file mode 100644 index 0000000000..c69262bcd8 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchReferenceServiceUnitTest/BatchReadsAndWritesSuccessResponse.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-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchRequestUnitTest/BatchReadsAndWritesSuccessRequest.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchRequestUnitTest/BatchReadsAndWritesSuccessRequest.txt new file mode 100644 index 0000000000..9f0b46136d --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchRequestUnitTest/BatchReadsAndWritesSuccessRequest.txt @@ -0,0 +1,100 @@ +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 1 + +GET EntityCollection HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 2 + +GET EntityCollection(%27foobar%27) 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: 3 + +POST EntityCollection HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{"@odata.type":"#TestEntity"} + +--changeset_00000000-0000-0000-0000-000000000002 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 4 + +PATCH EntityCollection(%27upd%27) HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{"@odata.type":"#TestEntity"} + +--changeset_00000000-0000-0000-0000-000000000002 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 5 + +DELETE EntityCollection(%27del%27) HTTP/1.1 +Accept: application/json + + +--changeset_00000000-0000-0000-0000-000000000002-- + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 6 + +GET function-single(secret=%27pass%27) HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 7 + +GET function-multiple(secret=%27pass%27) HTTP/1.1 +Accept: application/json + + +--batch_00000000-0000-0000-0000-000000000001 +Content-Type: multipart/mixed;boundary=changeset_00000000-0000-0000-0000-000000000003 + +--changeset_00000000-0000-0000-0000-000000000003 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 8 + +POST action-single HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{"secret":"pass"} + +--changeset_00000000-0000-0000-0000-000000000003 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 9 + +POST action-multiple HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{"secret":"pass"} + +--changeset_00000000-0000-0000-0000-000000000003-- + +--batch_00000000-0000-0000-0000-000000000001-- diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchRequestUnitTest/BatchReadsAndWritesSuccessResponse.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchRequestUnitTest/BatchReadsAndWritesSuccessResponse.txt new file mode 100644 index 0000000000..7290820ed7 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODataV4BatchRequestUnitTest/BatchReadsAndWritesSuccessResponse.txt @@ -0,0 +1,87 @@ +--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://localhost/service/$metadata#TestEntity","value":[{"id":"adam"},{"id":"eve"},{"id":"foobar"}]} +--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://localhost/service/$metadata#TestEntity/$entity","id":"foobar"} +--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: 1 + +HTTP/1.1 201 Created +Location: https://localhost/service/TestEntities('new') +Content-Type: application/json; odata.metadata=minimal +OData-Version: 4.0 + +{"@odata.context":"https://localhost/service/$metadata#TestEntity","id":"new"} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8cb4 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 2 + +HTTP/1.1 200 OK +Content-Type: application/json; odata.metadata=minimal; odata.streaming=true +OData-Version: 4.0 + +{"@odata.context":"https://localhost/service/$metadata#TestEntity","id":"updated"} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8cb4 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 3 + +HTTP/1.1 204 No Content + +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8cb4-- +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: text/plain + +42 +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json + +{"value":["Something","here"]} +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: multipart/mixed; boundary=changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000 + +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000 +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json + +{"value":42} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000 +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 200 OK +Content-Type: application/json + +{"value":["Something","here"]} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8000-- +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef-- diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODatav4BatchConnectionTest/BatchResponseWithChangeset.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODatav4BatchConnectionTest/BatchResponseWithChangeset.txt new file mode 100644 index 0000000000..bc821ac212 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODatav4BatchConnectionTest/BatchResponseWithChangeset.txt @@ -0,0 +1,51 @@ +--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://localhost/service/$metadata#TestEntity","value":[{"id":"adam"},{"id":"eve"},{"id":"foobar"}]} +--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://localhost/service/$metadata#TestEntity/$entity","id":"foobar"} +--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: 1 + +HTTP/1.1 201 Created +Location: https://localhost/service/TestEntities('new') +Content-Type: application/json; odata.metadata=minimal +OData-Version: 4.0 + +{"@odata.context":"https://localhost/service/$metadata#TestEntity","id":"new"} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8cb4 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 2 + +HTTP/1.1 200 OK +Content-Type: application/json; odata.metadata=minimal; odata.streaming=true +OData-Version: 4.0 + +{"@odata.context":"https://localhost/service/$metadata#TestEntity","id":"updated"} +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8cb4 +Content-Type: application/http +Content-Transfer-Encoding: binary +Content-ID: 3 + +HTTP/1.1 204 No Content + +--changesetresponse_e4c6cc48-c59e-42f8-bb00-1250fa2a8cb4-- +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef-- diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODatav4BatchConnectionTest/BatchResponseWithError.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODatav4BatchConnectionTest/BatchResponseWithError.txt new file mode 100644 index 0000000000..f2d88779c4 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODatav4BatchConnectionTest/BatchResponseWithError.txt @@ -0,0 +1,28 @@ +--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://localhost/service/$metadata#TestEntity","value":[{"id":"adam"},{"id":"eve"},{"id":"foobar"}]} +--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://localhost/service/$metadata#TestEntity/$entity","id":"foobar"} +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef +Content-Type: application/http +Content-Transfer-Encoding: binary + +HTTP/1.1 400 Bad Request +Content-Type: application/json;charset=utf-8 +Content-Length: 1050 + +{"error":{"code":"ZCU/100","message":{"lang":"nl","value":"Service 0000000003 0000000010 niet gevonden voor operatie 0410"},"innererror":{"application":{"component_id":"","service_namespace":"/SAP/","service_id":"ZCU_PE_ORDER_SRV","service_version":"0001"},"transactionid":"23F2932D54040110E005FD84A23B406E","timestamp":"20201215151501.0634490","Error_Resolution":{"SAP_Transaction":"Run transaction /IWFND/ERROR_LOG on SAP Gateway hub system (System Alias ) 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)","Batch_SAP_Note":"See SAP Note 1869434 for details about working with $batch (https://service.sap.com/sap/support/notes/1869434)"},"errordetails":[{"code":"ZCU/100","message":"Service 0000000003 0000000010 niet gevonden voor operatie 0410","propertyref":"","severity":"error","target":""},{"code":"/IWBEP/CX_MGW_BUSI_EXCEPTION","message":"Fout bij wijzigen PE order.","propertyref":"","severity":"error","target":""}]}}} +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef-- \ No newline at end of file diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODatav4BatchConnectionTest/BatchResponseWithoutChangeset.txt b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODatav4BatchConnectionTest/BatchResponseWithoutChangeset.txt new file mode 100644 index 0000000000..359e8efa0e --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/ODatav4BatchConnectionTest/BatchResponseWithoutChangeset.txt @@ -0,0 +1,19 @@ +--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://localhost/service/$metadata#TestEntity","value":[{"id":"adam"},{"id":"eve"},{"id":"foobar"}]} +--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://localhost/service/$metadata#TestEntity/$entity","id":"foobar"} +--batchresponse_76ef6b0a-a0e2-4f31-9f70-f5d3f73a6bef-- \ No newline at end of file diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/SingleValueActionRequestBuilderTest/ActionComplexTypeRequestBody.json b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/SingleValueActionRequestBuilderTest/ActionComplexTypeRequestBody.json new file mode 100644 index 0000000000..58613d4755 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/SingleValueActionRequestBuilderTest/ActionComplexTypeRequestBody.json @@ -0,0 +1,6 @@ +{ + "complexEntity": {"@odata.type": "#com.sap.cloud.sdk.ComplexType", + "City": "Stockholm", + "Country": "Sweden" + } +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/SingleValueActionRequestBuilderTest/ActionEntityRequestBody.json b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/SingleValueActionRequestBuilderTest/ActionEntityRequestBody.json new file mode 100644 index 0000000000..c2ec3886d8 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/SingleValueActionRequestBuilderTest/ActionEntityRequestBody.json @@ -0,0 +1,4 @@ +{ + "entityParameter": {"@odata.type": "#com.sap.cloud.sdk.TestEntity", + "Name": "Tester"} +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/SingleValueActionRequestBuilderTest/ActionNoParametersRequestBody.json b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/SingleValueActionRequestBuilderTest/ActionNoParametersRequestBody.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/SingleValueActionRequestBuilderTest/ActionNoParametersRequestBody.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/SingleValueActionRequestBuilderTest/ActionNullEntityRequestBody.json b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/SingleValueActionRequestBuilderTest/ActionNullEntityRequestBody.json new file mode 100644 index 0000000000..1714b4861f --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/SingleValueActionRequestBuilderTest/ActionNullEntityRequestBody.json @@ -0,0 +1,3 @@ +{ + "entityParameter": null +} diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/SingleValueActionRequestBuilderTest/ActionParametersRequestBody.json b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/SingleValueActionRequestBuilderTest/ActionParametersRequestBody.json new file mode 100644 index 0000000000..e0c51b0612 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/SingleValueActionRequestBuilderTest/ActionParametersRequestBody.json @@ -0,0 +1,9 @@ +{ + "stringParameter" : "test", + "booleanParameter" : true, + "integerParameter" : 9000, + "decimalParameter" : 3.14, + "durationParameter" : "PT8H", + "dateTimeOffsetParameter":"2020-03-12T05:02:30Z", + "timeOfDayParameter":"13:03:39.999" +} \ No newline at end of file diff --git a/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/unused_trippin.edmx b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/unused_trippin.edmx new file mode 100644 index 0000000000..26ed9a8945 --- /dev/null +++ b/datamodel/odata-v4-core-apache-httpclient5/src/test/resources/unused_trippin.edmx @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Name + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/datamodel/pom.xml b/datamodel/pom.xml index fb51f9f405..9018e060aa 100644 --- a/datamodel/pom.xml +++ b/datamodel/pom.xml @@ -35,7 +35,9 @@ odata-client odata-client-apache-httpclient5 odata + odata-core-apache-httpclient5 odata-v4 + odata-v4-core-apache-httpclient5 soap openapi diff --git a/release_notes.md b/release_notes.md index 3112514a9d..ed6620e985 100644 --- a/release_notes.md +++ b/release_notes.md @@ -12,7 +12,7 @@ ### ✨ New Functionality -- +- Introduced the `odata-core-apache-httpclient5` and `odata-v4-core-apache-httpclient5` modules, which run on top of Apache HttpClient 5. These are drop-in replacements for `odata-core` and `odata-v4-core` (same Java packages) for consumers moving off the end-of-life Apache HttpClient 4.x stack. ### 📈 Improvements