+ * Of the result only the property {@code ResultProperty} is accessed.
+ */
+@EqualsAndHashCode
+public final class ODataResourcePath
+{
+ /**
+ * The current path as a list of its individual path segments.
+ */
+ @Getter( AccessLevel.PUBLIC )
+ @Nonnull
+ private final List> segments = new ArrayList<>();
+
+ /**
+ * Convenience method for {@code new ODataResourcePath().addSegment(segment)}. It creates a new resource path for
+ * the given path string.
+ *
+ * @param segment
+ * The string identifying the resource e.g. {@code EntityName}
+ * @return A new {@link ODataResourcePath}.
+ */
+ @Nonnull
+ public static ODataResourcePath of( @Nonnull final String segment )
+ {
+ return new ODataResourcePath().addSegment(segment);
+ }
+
+ /**
+ * Convenience method for {@code new ODataResourcePath().addSegment(segment, segmentParameter)}. It creates a new
+ * resource path for the given path string and parameters.
+ *
+ * @param segment
+ * The string identifying the resource e.g. {@code EntityName}
+ * @param segmentParameter
+ * The parameters to be included in this segment e.g. {@code (key1='val',key2=123)}
+ * @return A new {@link ODataResourcePath}.
+ */
+ @Nonnull
+ public static
+ ODataResourcePath
+ of( @Nonnull final String segment, @Nonnull final AbstractODataParameters segmentParameter )
+ {
+ return new ODataResourcePath().addSegment(segment, segmentParameter);
+ }
+
+ /**
+ * Add a navigation to the path.
+ *
+ * @param segment
+ * The navigation to add. Any slashes will be encoded and not treated as segment separators.
+ * @return This builder instance.
+ */
+ @Nonnull
+ public ODataResourcePath addSegment( @Nonnull final String segment )
+ {
+ return addSegment(segment, null);
+ }
+
+ /**
+ * Add a navigation with a parameters or function or action call to the path.
+ *
+ * @param segment
+ * The unencoded navigation or function or action reference to add. Any slashes will be encoded and not
+ * treated as segment separators.
+ * @param parameters
+ * The unencoded parameters or parameters to add.
+ * @return This builder instance.
+ */
+ @Nonnull
+ public
+ ODataResourcePath
+ addSegment( @Nonnull final String segment, @Nullable final AbstractODataParameters parameters )
+ {
+ segments.add(Tuple.of(segment, parameters));
+ return this;
+ }
+
+ /**
+ * Add a path parameter to the last segment. Can only be applied if the last segment added to this builder did not
+ * contain any parameters.
+ *
+ * @param parameters
+ * The unencoded parameters to add.
+ * @return This builder instance.
+ *
+ * @throws IllegalStateException
+ * When the current path is empty or the last segment already contains a parameter.
+ */
+ @Nonnull
+ public ODataResourcePath addParameterToLastSegment( @Nonnull final AbstractODataParameters parameters )
+ {
+ if( segments.isEmpty() ) {
+ throw new IllegalStateException(
+ "Cannot add parameter to the last segment because the current path is empty.");
+ }
+ final Tuple2 lastSegment = segments.get(segments.size() - 1);
+ if( lastSegment._2() != null ) {
+ final String msg =
+ String
+ .format(
+ "Cannot add parameter for path segment \"%s\". The segment already contains a parameter expression.",
+ lastSegment._2());
+ throw new IllegalStateException(msg);
+ }
+ segments.set(segments.size() - 1, lastSegment.update2(parameters));
+ return this;
+ }
+
+ /**
+ * Encodes the path segments and assembles them together. The returned path will always start with a leading forward
+ * slash and ends without any trailing slashes.
+ *
+ * @return The encoded path.
+ */
+ @Nonnull
+ public String toEncodedPathString()
+ {
+ return toEncodedPathString(UriEncodingStrategy.REGULAR);
+ }
+
+ /**
+ * Encodes the path segments and assembles them together. The returned path will always start with a leading forward
+ * slash and ends without any trailing slashes.
+ *
+ * @param strategy
+ * The URI encoding strategy.
+ * @return The encoded path.
+ */
+ @Nonnull
+ public String toEncodedPathString( @Nonnull final UriEncodingStrategy strategy )
+ {
+ return "/"
+ + segments
+ .stream()
+ .map(t -> t.map1(strategy.getPathPercentEscaper()::escape))
+ .map(t -> t.map2(key -> key != null ? key.toEncodedString(strategy) : ""))
+ .map(t -> t._1() + t._2())
+ .collect(Collectors.joining("/"));
+ }
+
+ /**
+ * Builds the path segments to a full URL path.
+ *
+ * @return The unencoded URL path.
+ */
+ @Override
+ @Nonnull
+ public String toString()
+ {
+ return "/"
+ + segments
+ .stream()
+ .map(t -> t.map2(key -> key != null ? key.toString() : ""))
+ .map(t -> t._1() + t._2())
+ .collect(Collectors.joining("/"));
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/OrderExpression.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/OrderExpression.java
new file mode 100644
index 000000000..9ecbd1965
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/OrderExpression.java
@@ -0,0 +1,70 @@
+package com.sap.cloud.sdk.datamodel.odata.client.expression;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import javax.annotation.Nonnull;
+
+import com.sap.cloud.sdk.datamodel.odata.client.query.Order;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+/**
+ * A class representing order expressions over fields, maintaining an order over them.
+ */
+@NoArgsConstructor( access = AccessLevel.PRIVATE )
+public final class OrderExpression
+{
+ private final Map orderBy = new LinkedHashMap<>();
+
+ /**
+ * To create OrderExpression with a field and ordering.
+ *
+ * @param fieldName
+ * The field name to create an order expression for.
+ * @param order
+ * The order direction.
+ * @return The order expression.
+ */
+ @Nonnull
+ public static OrderExpression of( @Nonnull final String fieldName, @Nonnull final Order order )
+ {
+ return new OrderExpression().and(fieldName, order);
+ }
+
+ /**
+ * To translate OrderExpression to query string.
+ *
+ * @return The part of the query string dedicated to ordering.
+ */
+ @Nonnull
+ public String toOrderByString()
+ {
+ return orderBy
+ .entrySet()
+ .stream()
+ .map(
+ entry -> entry.getValue() == null
+ ? entry.getKey()
+ : entry.getKey() + " " + entry.getValue().toString().toLowerCase())
+ .collect(Collectors.joining(","));
+ }
+
+ /**
+ * To maintain a Map of OrderExpressions with field name and order.
+ *
+ * @param fieldName
+ * The field name to create an order expression for.
+ * @param order
+ * The order direction.
+ * @return The concatenated order expression (conjunction).
+ */
+ @Nonnull
+ public OrderExpression and( @Nonnull final String fieldName, @Nonnull final Order order )
+ {
+ orderBy.put(fieldName, order);
+ return this;
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueBinary.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueBinary.java
new file mode 100644
index 000000000..2f2e545b4
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueBinary.java
@@ -0,0 +1,55 @@
+package com.sap.cloud.sdk.datamodel.odata.client.expression;
+
+import java.util.Base64;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+import lombok.RequiredArgsConstructor;
+import lombok.experimental.Delegate;
+
+/**
+ * OData filter expression operand of type Edm.String
+ */
+public interface ValueBinary extends Expressions.OperandSingle, FilterableComparisonAbsolute
+{
+ /**
+ * Null value for binary operations.
+ */
+ @Nonnull
+ ValueBinary NULL = Expressions.OperandSingle.NULL::getExpression;
+
+ /**
+ * Lambda to translate a byte array to String.
+ */
+ Function ENCODE_TO_STRING = Base64.getEncoder()::encodeToString;
+
+ /**
+ * Lambda to translate a String with "ISO-8859-1" encoding to byte array.
+ */
+ Function DECODE_FROM_STRING = Base64.getDecoder()::decode;
+
+ /**
+ * Returns a {@link ValueBinary} from the given {@code v}.
+ *
+ * @param v
+ * The value to be transformed into a {@link ValueBinary}.
+ * @return A {@link ValueBinary} that contains the given {@code v}.
+ */
+ @Nonnull
+ static ValueBinary literal( @Nonnull final byte[] v )
+ {
+ final String value = ENCODE_TO_STRING.apply(v);
+ return ( protocol, prefixes ) -> "binary'" + value + "'";
+ }
+
+ /**
+ * OData expression for binary values.
+ */
+ @RequiredArgsConstructor
+ class Expression implements FilterExpression, ValueBinary
+ {
+ @Delegate
+ private final FilterExpression delegate;
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueBoolean.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueBoolean.java
new file mode 100644
index 000000000..851ad48e0
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueBoolean.java
@@ -0,0 +1,41 @@
+package com.sap.cloud.sdk.datamodel.odata.client.expression;
+
+import javax.annotation.Nonnull;
+
+import lombok.RequiredArgsConstructor;
+import lombok.experimental.Delegate;
+
+/**
+ * OData filter expression operand of type Edm.Boolean
+ */
+public interface ValueBoolean extends Expressions.OperandSingle, FilterableBoolean, FilterableComparisonAbsolute
+{
+ /**
+ * Null value for boolean operations.
+ */
+ @Nonnull
+ ValueBoolean NULL = Expressions.OperandSingle.NULL::getExpression;
+
+ /**
+ * Get the literal of this expression.
+ *
+ * @param v
+ * boolean.
+ * @return The literal.
+ */
+ @Nonnull
+ static ValueBoolean literal( @Nonnull final Boolean v )
+ {
+ return ( protocol, prefixes ) -> v.toString();
+ }
+
+ /**
+ * Implementation with literal number value.
+ */
+ @RequiredArgsConstructor
+ class Expression implements FilterExpression, ValueBoolean
+ {
+ @Delegate
+ private final FilterExpression delegate;
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueCollection.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueCollection.java
new file mode 100644
index 000000000..5b8c176a6
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueCollection.java
@@ -0,0 +1,56 @@
+package com.sap.cloud.sdk.datamodel.odata.client.expression;
+
+import java.util.stream.Collectors;
+
+import javax.annotation.Nonnull;
+
+import com.google.common.collect.Streams;
+
+import lombok.RequiredArgsConstructor;
+import lombok.experimental.Delegate;
+
+/**
+ * OData filter expression for a generic collection.
+ */
+public interface ValueCollection
+ extends
+ Expressions.OperandMultiple,
+ FilterableCollection,
+ FilterableComparisonAbsolute,
+ FilterableComparisonRelative
+{
+ /**
+ * Null value for collection operations.
+ */
+ @Nonnull
+ ValueCollection NULL = Expressions.Operand.NULL::getExpression;
+
+ /**
+ * Returns a {@link ValueCollection} instance from the given {@code v}.
+ *
+ * @param v
+ * The values to be transformed into a {@link ValueCollection}.
+ * @return A {@link ValueCollection} that contains the given {@code v}.
+ */
+ @Nonnull
+ static ValueCollection literal( @Nonnull final Iterable> v )
+ {
+ return ( protocol, prefixes ) -> "["
+ + Streams
+ .stream(v)
+ .map(Expressions::createOperand)
+ .map(o -> o.getExpression(protocol))
+ .collect(Collectors.joining(","))
+ + "]";
+ }
+
+ /**
+ * OData expression for generic value collections.
+ */
+ @RequiredArgsConstructor
+ class Expression implements FilterExpression, ValueCollection
+ {
+ @Delegate
+ private final FilterExpression delegate;
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDate.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDate.java
new file mode 100644
index 000000000..8fd404ffe
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDate.java
@@ -0,0 +1,49 @@
+package com.sap.cloud.sdk.datamodel.odata.client.expression;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+
+import javax.annotation.Nonnull;
+
+import lombok.RequiredArgsConstructor;
+import lombok.experimental.Delegate;
+
+/**
+ * OData filter expression operand of type Edm.Date
+ */
+public interface ValueDate
+ extends
+ Expressions.OperandSingle,
+ FilterableDate,
+ FilterableComparisonAbsolute,
+ FilterableComparisonRelative
+{
+ /**
+ * Null value for date operations.
+ */
+ @Nonnull
+ ValueDate NULL = Expressions.OperandSingle.NULL::getExpression;
+
+ /**
+ * Returns a {@link ValueDate} from the given {@code v}.
+ *
+ * @param v
+ * The value to be transformed into a {@link ValueDate}.
+ * @return A {@link ValueDate} that contains the given {@code v}.
+ */
+ @Nonnull
+ static ValueDate literal( @Nonnull final LocalDate v )
+ {
+ return ( protocol, prefixes ) -> v.format(DateTimeFormatter.ISO_LOCAL_DATE);
+ }
+
+ /**
+ * OData expression for date values.
+ */
+ @RequiredArgsConstructor
+ class Expression implements FilterExpression, ValueDate
+ {
+ @Delegate
+ private final FilterExpression delegate;
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDateTime.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDateTime.java
new file mode 100644
index 000000000..5c60353bf
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDateTime.java
@@ -0,0 +1,48 @@
+package com.sap.cloud.sdk.datamodel.odata.client.expression;
+
+import java.time.LocalDateTime;
+
+import javax.annotation.Nonnull;
+
+import lombok.RequiredArgsConstructor;
+import lombok.experimental.Delegate;
+
+/**
+ * OData filter expression operand of type Edm.DateTime (OData 2.0 only)
+ */
+public interface ValueDateTime
+ extends
+ Expressions.OperandSingle,
+ FilterableDateTimeOffset,
+ FilterableComparisonAbsolute,
+ FilterableComparisonRelative
+{
+ /**
+ * Null value for date time operations.
+ */
+ @Nonnull
+ ValueDateTime NULL = Expressions.OperandSingle.NULL::getExpression;
+
+ /**
+ * Returns a {@link ValueDateTime} from the given {@code v}.
+ *
+ * @param v
+ * The value to be transformed into a {@link ValueDateTime}.
+ * @return A {@link ValueDateTime} that contains the given {@code v}.
+ */
+ @Nonnull
+ static ValueDateTime literal( @Nonnull final LocalDateTime v )
+ {
+ return ( protocol, prefixes ) -> protocol.getDateTimeSerializer().apply(v);
+ }
+
+ /**
+ * OData expression for date time values.
+ */
+ @RequiredArgsConstructor
+ class Expression implements FilterExpression, ValueDateTime
+ {
+ @Delegate
+ private final FilterExpression delegate;
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDateTimeOffset.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDateTimeOffset.java
new file mode 100644
index 000000000..9be9a1918
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDateTimeOffset.java
@@ -0,0 +1,48 @@
+package com.sap.cloud.sdk.datamodel.odata.client.expression;
+
+import java.time.OffsetDateTime;
+
+import javax.annotation.Nonnull;
+
+import lombok.RequiredArgsConstructor;
+import lombok.experimental.Delegate;
+
+/**
+ * OData filter expression operand of type Edm.DateTimeOffset
+ */
+public interface ValueDateTimeOffset
+ extends
+ Expressions.OperandSingle,
+ FilterableDateTimeOffset,
+ FilterableComparisonAbsolute,
+ FilterableComparisonRelative
+{
+ /**
+ * Null value for date time offset operations.
+ */
+ @Nonnull
+ ValueDateTimeOffset NULL = Expressions.OperandSingle.NULL::getExpression;
+
+ /**
+ * Returns a {@link ValueDateTimeOffset} from the given {@code v}.
+ *
+ * @param v
+ * The value to be transformed into a {@link ValueDateTimeOffset}.
+ * @return A {@link ValueDateTimeOffset} that contains the given {@code v}.
+ */
+ @Nonnull
+ static ValueDateTimeOffset literal( @Nonnull final OffsetDateTime v )
+ {
+ return ( protocol, prefixes ) -> protocol.getDateTimeOffsetSerializer().apply(v);
+ }
+
+ /**
+ * OData expression for date time offset values.
+ */
+ @RequiredArgsConstructor
+ class Expression implements FilterExpression, ValueDateTimeOffset
+ {
+ @Delegate
+ private final FilterExpression delegate;
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDuration.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDuration.java
new file mode 100644
index 000000000..5ee051baa
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueDuration.java
@@ -0,0 +1,48 @@
+package com.sap.cloud.sdk.datamodel.odata.client.expression;
+
+import java.time.Duration;
+
+import javax.annotation.Nonnull;
+
+import lombok.RequiredArgsConstructor;
+import lombok.experimental.Delegate;
+
+/**
+ * OData filter expression operand of type Edm.Duration
+ */
+public interface ValueDuration
+ extends
+ Expressions.OperandSingle,
+ FilterableDuration,
+ FilterableComparisonAbsolute,
+ FilterableComparisonRelative
+{
+ /**
+ * Null value for enum operations.
+ */
+ @Nonnull
+ ValueEnum NULL = Expressions.OperandSingle.NULL::getExpression;
+
+ /**
+ * Returns a {@link ValueDuration} from the given {@code v}.
+ *
+ * @param v
+ * The value to be transformed into a {@link ValueDuration}.
+ * @return A {@link ValueDuration} that contains the given {@code v}.
+ */
+ @Nonnull
+ static ValueDuration literal( @Nonnull final Duration v )
+ {
+ return ( protocol, prefixes ) -> "duration'" + v + "'";
+ }
+
+ /**
+ * OData expression for duration values.
+ */
+ @RequiredArgsConstructor
+ class Expression implements FilterExpression, ValueDuration
+ {
+ @Delegate
+ private final FilterExpression delegate;
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueEnum.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueEnum.java
new file mode 100644
index 000000000..5fb3e45fc
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueEnum.java
@@ -0,0 +1,43 @@
+package com.sap.cloud.sdk.datamodel.odata.client.expression;
+
+import javax.annotation.Nonnull;
+
+/**
+ * OData filter expression operand of enumeration type
+ */
+public interface ValueEnum extends Expressions.OperandSingle, FilterableComparisonAbsolute
+{
+ /**
+ * Null value for enum operations.
+ */
+ @Nonnull
+ ValueEnum NULL = Expressions.OperandSingle.NULL::getExpression;
+
+ /**
+ * Returns a {@link ValueEnum} from the given {@code v}.
+ *
+ * @param v
+ * The value to be transformed into a {@link ValueEnum}.
+ * @return A {@link ValueEnum} that contains the given {@code v}.
+ */
+ @Nonnull
+ static ValueEnum literal( @Nonnull final String v )
+ {
+ return ( protocol, prefixes ) -> "'" + v + "'";
+ }
+
+ /**
+ * Returns a {@link ValueEnum} from the given {@code enumType} and {@code v}.
+ *
+ * @param enumType
+ * The enum type.
+ * @param v
+ * The value to be transformed into a {@link ValueEnum}.
+ * @return A {@link ValueEnum} that contains the given {@code v}.
+ */
+ @Nonnull
+ static ValueEnum literal( @Nonnull final String enumType, @Nonnull final String v )
+ {
+ return ( protocol, prefixes ) -> enumType + "'" + v + "'";
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueGuid.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueGuid.java
new file mode 100644
index 000000000..6295d6432
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueGuid.java
@@ -0,0 +1,30 @@
+package com.sap.cloud.sdk.datamodel.odata.client.expression;
+
+import java.util.UUID;
+
+import javax.annotation.Nonnull;
+
+/**
+ * OData filter expression operand of type Edm.Guid
+ */
+public interface ValueGuid extends Expressions.OperandSingle, FilterableComparisonAbsolute, FilterableComparisonRelative
+{
+ /**
+ * Null value for guid operations.
+ */
+ @Nonnull
+ ValueGuid NULL = Expressions.OperandSingle.NULL::getExpression;
+
+ /**
+ * Returns a {@link ValueGuid} from the given {@code v}.
+ *
+ * @param v
+ * The value to be transformed into a {@link ValueGuid}.
+ * @return A {@link ValueGuid} that contains the given {@code v}.
+ */
+ @Nonnull
+ static ValueGuid literal( @Nonnull final UUID v )
+ {
+ return ( protocol, prefixes ) -> protocol.getUUIDSerializer().apply(v);
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueNumeric.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueNumeric.java
new file mode 100644
index 000000000..7028b3fca
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueNumeric.java
@@ -0,0 +1,46 @@
+package com.sap.cloud.sdk.datamodel.odata.client.expression;
+
+import javax.annotation.Nonnull;
+
+import lombok.RequiredArgsConstructor;
+import lombok.experimental.Delegate;
+
+/**
+ * OData filter expression operand of type Edm.Int32, ...
+ */
+public interface ValueNumeric
+ extends
+ Expressions.OperandSingle,
+ FilterableNumeric,
+ FilterableComparisonAbsolute,
+ FilterableComparisonRelative
+{
+ /**
+ * Null value for numeric operations.
+ */
+ @Nonnull
+ ValueNumeric NULL = Expressions.OperandSingle.NULL::getExpression;
+
+ /**
+ * Returns a {@link ValueNumeric} from the given {@code v}.
+ *
+ * @param v
+ * The value to be transformed into a {@link ValueNumeric}.
+ * @return A {@link ValueNumeric} that contains the given {@code v}.
+ */
+ @Nonnull
+ static ValueNumeric literal( @Nonnull final Number v )
+ {
+ return ( protocol, prefixes ) -> protocol.getNumberSerializer().apply(v);
+ }
+
+ /**
+ * OData expression on numeric values.
+ */
+ @RequiredArgsConstructor
+ class Expression implements FilterExpression, ValueNumeric
+ {
+ @Delegate
+ private final FilterExpression delegate;
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueString.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueString.java
new file mode 100644
index 000000000..0016499a5
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueString.java
@@ -0,0 +1,47 @@
+package com.sap.cloud.sdk.datamodel.odata.client.expression;
+
+import javax.annotation.Nonnull;
+
+import lombok.RequiredArgsConstructor;
+import lombok.experimental.Delegate;
+
+/**
+ * OData filter expression operand of type Edm.String
+ */
+public interface ValueString
+ extends
+ Expressions.OperandSingle,
+ FilterableString,
+ FilterableComparisonAbsolute,
+ FilterableComparisonRelative
+{
+ /**
+ * Null value for string operations.
+ */
+ @Nonnull
+ ValueString NULL = Expressions.OperandSingle.NULL::getExpression;
+
+ /**
+ * Returns a {@link ValueString} from the given {@code v}.
+ *
+ * @param v
+ * The value to be transformed into a {@link ValueString}.
+ * @return A {@link ValueString} that contains the given {@code v}.
+ */
+ @Nonnull
+ static ValueString literal( @Nonnull final String v )
+ {
+ final String escapedValue = v.replaceAll("'", "''");
+ return ( protocol, prefixes ) -> "'" + escapedValue + "'";
+ }
+
+ /**
+ * OData expression on string values.
+ */
+ @RequiredArgsConstructor
+ class Expression implements FilterExpression, ValueString
+ {
+ @Delegate
+ private final FilterExpression delegate;
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueTimeOfDay.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueTimeOfDay.java
new file mode 100644
index 000000000..28c2ba312
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/expression/ValueTimeOfDay.java
@@ -0,0 +1,48 @@
+package com.sap.cloud.sdk.datamodel.odata.client.expression;
+
+import java.time.LocalTime;
+
+import javax.annotation.Nonnull;
+
+import lombok.RequiredArgsConstructor;
+import lombok.experimental.Delegate;
+
+/**
+ * OData filter expression operand of type Edm.TimeOfDay in case of OData 4.0 or Edm.Time in case of OData 2.0.
+ */
+public interface ValueTimeOfDay
+ extends
+ Expressions.OperandSingle,
+ FilterableTimeOfDay,
+ FilterableComparisonAbsolute,
+ FilterableComparisonRelative
+{
+ /**
+ * Null value for time of day operations.
+ */
+ @Nonnull
+ ValueTimeOfDay NULL = Expressions.OperandSingle.NULL::getExpression;
+
+ /**
+ * Returns a {@link ValueTimeOfDay} from the given {@code v}.
+ *
+ * @param v
+ * The value to be transformed into a {@link ValueTimeOfDay}.
+ * @return A {@link ValueTimeOfDay} that contains the given {@code v}.
+ */
+ @Nonnull
+ static ValueTimeOfDay literal( @Nonnull final LocalTime v )
+ {
+ return ( protocol, prefixes ) -> protocol.getTimeOfDaySerializer().apply(v);
+ }
+
+ /**
+ * OData expression for time of day values.
+ */
+ @RequiredArgsConstructor
+ class Expression implements FilterExpression, ValueTimeOfDay
+ {
+ @Delegate
+ private final FilterExpression delegate;
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/Order.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/Order.java
new file mode 100644
index 000000000..ec1df56e3
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/Order.java
@@ -0,0 +1,17 @@
+package com.sap.cloud.sdk.datamodel.odata.client.query;
+
+/**
+ * Used with orderBy methods in entity fluent helper objects to set the sorting order of field values.
+ */
+public enum Order
+{
+ /**
+ * Sort field values in ascending order.
+ */
+ ASC,
+
+ /**
+ * Sort field values in descending order.
+ */
+ DESC
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/QuerySerializable.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/QuerySerializable.java
new file mode 100644
index 000000000..c2db448c0
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/QuerySerializable.java
@@ -0,0 +1,28 @@
+package com.sap.cloud.sdk.datamodel.odata.client.query;
+
+import javax.annotation.Nonnull;
+
+import com.sap.cloud.sdk.datamodel.odata.client.request.UriEncodingStrategy;
+
+/**
+ * A serializable query interface to serve an encoded and not-encoded String representation.
+ */
+public interface QuerySerializable
+{
+ /**
+ * Compute the encoded string representation of this query with the following {@link UriEncodingStrategy#REGULAR}
+ * strategy.
+ *
+ * @return A string representing the encoded request query.
+ */
+ @Nonnull
+ String getEncodedQueryString();
+
+ /**
+ * Compute the string representation of this query.
+ *
+ * @return A string representing the request query.
+ */
+ @Nonnull
+ String getQueryString();
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/QuerySerializer.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/QuerySerializer.java
new file mode 100644
index 000000000..3784ed147
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/QuerySerializer.java
@@ -0,0 +1,191 @@
+package com.sap.cloud.sdk.datamodel.odata.client.query;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiFunction;
+import java.util.stream.Collectors;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.google.common.collect.ImmutableMap;
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.OrderExpression;
+import com.sap.cloud.sdk.datamodel.odata.client.request.ODataUriFactory;
+
+import io.vavr.control.Option;
+
+class QuerySerializer
+{
+ private static final String SEPARATOR_SUB_QUERY = ";";
+ private static final String SEPARATOR_ROOT_QUERY = "&";
+
+ private static final Map> QUERY_TO_STRING_MAP =
+ ImmutableMap
+ .> builder()
+ .put("$select=%s", QuerySerializer::selectorsToQueryString)
+ .put("$expand=%s", QuerySerializer::expansionsToQueryString)
+ .put("$filter=%s", QuerySerializer::filtersToQueryString)
+ .put("$top=%s", ( q, applyEncoding ) -> q.top)
+ .put("$skip=%s", ( q, applyEncoding ) -> q.skip)
+ .put("$orderby=%s", QuerySerializer::orderByToQueryString)
+ .put("$search=%s", ( q, applyEncoding ) -> conditionalEncode(q.search, applyEncoding))
+ .build();
+
+ @Nonnull
+ static String serializeAndEncodeQuery( @Nonnull final StructuredQuery query, final boolean applyEncoding )
+ {
+ final List parameters = new ArrayList<>();
+
+ // For every system query (e.g. select, expand) apply the provided StructuredQuery object.
+ // Add the computed String to the list of HTTP query parameters. Optionally apply URI encoding to the value.
+ QUERY_TO_STRING_MAP
+ .forEach(
+ ( parameterString, valueFunction ) -> Option
+ .of(valueFunction.apply(query, applyEncoding))
+ .filter(q -> q instanceof String ? !"".equals(q) : q != null)
+ .map(q -> String.format(parameterString, q))
+ .forEach(parameters::add));
+
+ if( query.isRoot() ) {
+ query
+ .getCustomParameters()
+ .forEach(( key, value ) -> parameters.add(key + "=" + conditionalEncode(value, applyEncoding)));
+ }
+
+ final String queryElementSeparator = query.isRoot() ? SEPARATOR_ROOT_QUERY : SEPARATOR_SUB_QUERY;
+ return String.join(queryElementSeparator, parameters);
+ }
+
+ /**
+ * Helper method to translate the simple selector expressions to query String.
+ *
+ * @return The part of the query string dedicated to selects.
+ */
+ @Nonnull
+ private static String selectorsToQueryString( @Nonnull final StructuredQuery q, final boolean applyEncoding )
+ {
+ final List selectors = getSelectors(q, applyEncoding);
+ return String.join(",", selectors);
+ }
+
+ /**
+ * Helper method to translate selector expressions of a structured query to query String.
+ *
+ * @param query
+ * The structured query which will be translated
+ * @param applyEncoding
+ * Boolean flag for encoding values to enable HTTP URI compatibility
+ *
+ * @return The part of the query string dedicated to $select
+ */
+ @Nonnull
+ private static List getSelectors( @Nonnull final StructuredQuery query, final boolean applyEncoding )
+ {
+ // common simple selectors for query in OData V2 + OData V4
+ final List selectors =
+ query
+ .getSimpleSelectors()
+ .stream()
+ .map(result -> applyEncoding ? ODataUriFactory.encodeQuery(result) : result)
+ .collect(Collectors.toList());
+
+ if( query.getProtocol() == ODataProtocol.V2 ) {
+ // derive selectors from expansions and add to result list
+ for( final StructuredQuery childQuery : query.getComplexSelectors() ) {
+ String propertyName = childQuery.getEntityOrPropertyName();
+ if( applyEncoding ) {
+ propertyName = ODataUriFactory.encodeQuery(propertyName);
+ }
+ for( final String select : getSelectors(childQuery, applyEncoding) ) {
+ selectors.add(propertyName + "/" + select);
+ }
+ }
+ }
+ return selectors;
+ }
+
+ /**
+ * Helper method to translate the complex selector expressions to encoded query String.
+ *
+ * @return The part of the query string dedicated to selects.
+ */
+ @Nonnull
+ private static String expansionsToQueryString( @Nonnull final StructuredQuery q, final boolean applyEncoding )
+ {
+ final List filters = getExpansions(q, applyEncoding);
+ return String.join(",", filters);
+ }
+
+ /**
+ * Helper method to translate expansion expressions of a structured query to query String.
+ *
+ * @param query
+ * The structured query which will be translated
+ * @param applyEncoding
+ * Boolean flag for encoding values to enable HTTP URI compatibility
+ *
+ * @return The part of the query string dedicated to $expand
+ */
+ @Nonnull
+ private static List getExpansions( @Nonnull final StructuredQuery query, final boolean applyEncoding )
+ {
+ final List expansions = new ArrayList<>();
+ final Collection complexSelectors = query.getComplexSelectors();
+ for( final StructuredQuery subQuery : complexSelectors ) {
+ String propertyName = subQuery.getEntityOrPropertyName();
+ if( applyEncoding ) {
+ propertyName = ODataUriFactory.encodeQuery(propertyName);
+ }
+
+ if( query.getProtocol() == ODataProtocol.V2 ) {
+ expansions.add(propertyName);
+ for( final String expand : getExpansions(subQuery, applyEncoding) ) {
+ expansions.add(propertyName + "/" + expand);
+ }
+ } else {
+ final String subQueryString =
+ applyEncoding ? subQuery.getEncodedQueryString() : subQuery.getQueryString();
+ if( !subQueryString.isEmpty() ) {
+ propertyName += "(" + subQueryString + ")";
+ }
+ expansions.add(propertyName);
+ }
+ }
+ return expansions;
+ }
+
+ /**
+ * Helper method to translate the filter expressions to query String.
+ *
+ * @return The part of the query string dedicated to filters.
+ */
+ @Nonnull
+ private static String filtersToQueryString( @Nonnull final StructuredQuery q, final boolean applyEncoding )
+ {
+ final List filters =
+ q.getFilters().stream().map(filter -> filter.getExpression(q.getProtocol())).collect(Collectors.toList());
+
+ return conditionalEncode(String.join(" and ", filters), applyEncoding);
+ }
+
+ /**
+ * Helper method to translate the order expressions to query String.
+ *
+ * @return The part of the query string dedicated to filters.
+ */
+ @Nullable
+ private static String orderByToQueryString( @Nonnull final StructuredQuery q, final boolean applyEncoding )
+ {
+ final String orderBy = Option.of(q.getOrderBy()).map(OrderExpression::toOrderByString).getOrNull();
+ return conditionalEncode(orderBy, applyEncoding);
+ }
+
+ @Nullable
+ private static String conditionalEncode( @Nullable final String input, final boolean applyEncoding )
+ {
+ return applyEncoding && input != null ? ODataUriFactory.encodeQuery(input) : input;
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/StructuredQuery.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/StructuredQuery.java
new file mode 100644
index 000000000..030b2caf9
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/query/StructuredQuery.java
@@ -0,0 +1,294 @@
+package com.sap.cloud.sdk.datamodel.odata.client.query;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.google.common.base.Strings;
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.OrderExpression;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.ValueBoolean;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * {@code StructuredQuery} acts as a builder for OData 2.0 or 4.0 queries. It assists with assembling request parameters
+ * such as {@code $select, $filter, ...}. This API does not differentiate between OData versions. Only leveraging
+ * features e.g. within filters that conform to the selected protocol version is the responsibility of the consumer.
+ */
+@RequiredArgsConstructor( access = AccessLevel.PRIVATE )
+public final class StructuredQuery implements QuerySerializable
+{
+ /**
+ * The structured property field name.
+ */
+ @Getter
+ @Nonnull
+ private final String entityOrPropertyName;
+
+ @Getter
+ private final boolean isRoot;
+
+ @Getter
+ private final ODataProtocol protocol;
+
+ @Getter
+ @Nonnull
+ private final Collection simpleSelectors = new LinkedHashSet<>();
+ @Getter
+ @Nonnull
+ private final Collection complexSelectors = new LinkedHashSet<>();
+ @Getter
+ @Nonnull
+ private final Collection filters = new ArrayList<>();
+ @Getter
+ @Nonnull
+ private final Map customParameters = new LinkedHashMap<>();
+ @Getter
+ @Nullable
+ private OrderExpression orderBy = null;
+
+ @Getter
+ @Nullable
+ Number top;
+ @Getter
+ @Nullable
+ Number skip;
+ @Getter
+ @Nullable
+ String search;
+
+ /**
+ * Create a {@code StructuredQuery} for building up OData 2.0 or 4.0 queries.
+ *
+ * @param entityName
+ * The entity collection to be queried.
+ *
+ * @param protocol
+ * The {@link ODataProtocol} version this query should conform to.
+ * @return A new {@code StructuredQuery} object.
+ */
+ @Nonnull
+ public static StructuredQuery onEntity( @Nonnull final String entityName, @Nonnull final ODataProtocol protocol )
+ {
+ return new StructuredQuery(entityName, true, protocol);
+ }
+
+ /**
+ * Create a nested query on a property. This is an OData 4.0 specific feature.
+ *
+ * @param fieldName
+ * The property that is to be queried.
+ * @param protocol
+ * The {@link ODataProtocol} version this query should conform to.
+ *
+ * @return A new {@code StructuredQuery} object.
+ */
+ @Nonnull
+ public static
+ StructuredQuery
+ asNestedQueryOnProperty( @Nonnull final String fieldName, @Nonnull final ODataProtocol protocol )
+ {
+ return new StructuredQuery(fieldName, false, protocol);
+ }
+
+ /**
+ * Query modifier to limit which field values of the entity get fetched and populated.
+ *
+ * @param fields
+ * Properties to be selected.
+ * @return This query object with the added selections.
+ */
+ @Nonnull
+ public StructuredQuery select( @Nonnull final String... fields )
+ {
+ simpleSelectors.addAll(Arrays.asList(fields));
+ return this;
+ }
+
+ /**
+ * Query modifier to limit which complex and navigational properties will be expanded (and thus selected). Such
+ * expansions are represented again through structured queries.
+ *
+ * @param subqueries
+ * Query objects on properties to be expanded. The {@link StructuredQuery#getEntityOrPropertyName()} will
+ * be the key for fields that should be expanded.
+ * @return This query object with the added selections.
+ */
+ @Nonnull
+ public StructuredQuery select( @Nonnull final StructuredQuery... subqueries )
+ {
+ final List queries = Arrays.asList(subqueries);
+ // If there are already expands on the same properties as the ones passed in -> remove them
+ // That avoids duplications e.g. $expand=BestFriend,BestFriend,BestFriend($select=FirstName)
+ final List fields =
+ queries.stream().map(StructuredQuery::getEntityOrPropertyName).collect(Collectors.toList());
+
+ complexSelectors.removeIf(selector -> fields.contains(selector.getEntityOrPropertyName()));
+ complexSelectors.addAll(queries);
+ return this;
+ }
+
+ /**
+ * Query modified to limit which entities should be contained in the result set.
+ *
+ * @param filters
+ * Filter objects on properties to be filtered.
+ * @return This query object with the added filters.
+ */
+ @Nonnull
+ @SuppressWarnings( "varargs" )
+ public StructuredQuery filter( @Nonnull final ValueBoolean... filters )
+ {
+ this.filters.addAll(Arrays.asList(filters));
+ return this;
+ }
+
+ /**
+ * Query modifier to limit how many entities should be contained in the result set.
+ *
+ * @param top
+ * The number of entities to include in the result set at most.
+ * @return This query object with the top limit included.
+ */
+ @Nonnull
+ public StructuredQuery top( @Nonnull final Number top )
+ {
+ this.top = top;
+ return this;
+ }
+
+ /**
+ * Query modifier to skip a certain amount of results before then adding entities.
+ *
+ * @param skip
+ * The number of entities that shall be skipped before actually filling the result set.
+ * @return This query object with the skip operation included.
+ */
+ @Nonnull
+ public StructuredQuery skip( @Nonnull final Number skip )
+ {
+ this.skip = skip;
+ return this;
+ }
+
+ /**
+ * Adds an {@code orderBy} expression to this query object.
+ *
+ * If there is no {@code orderBy} operation present yet, a new one will be created.
+ *
+ *
+ * @param field
+ * The name of the field that should for ordering the result set.
+ * @param order
+ * The {@link Order}.
+ * @return This query object with the order by operation added.
+ */
+ @Nonnull
+ public StructuredQuery orderBy( @Nonnull final String field, @Nonnull final Order order )
+ {
+ if( orderBy == null ) {
+ orderBy = OrderExpression.of(field, order);
+ } else {
+ orderBy.and(field, order);
+ }
+ return this;
+ }
+
+ /**
+ * Sets the {@code orderBy} operation for this query object, overwriting any existing {@code orderBy} operations.
+ *
+ * @param ordering
+ * The {@link OrderExpression} to be used.
+ * @return This query object with the given {@code ordering} set.
+ */
+ @Nonnull
+ public StructuredQuery orderBy( @Nonnull final OrderExpression ordering )
+ {
+ orderBy = ordering;
+ return this;
+ }
+
+ /**
+ * Sets the {@code search} operation for this query object.
+ *
+ * @param search
+ * The value to be searched for.
+ * @return This query object with the {@code search} operation set.
+ */
+ @Nonnull
+ public StructuredQuery search( @Nonnull final String search )
+ {
+ this.search = search;
+ return this;
+ }
+
+ /**
+ * Adds a custom query parameter in the form of a key=value pair. This will not override any parameters set via
+ * {@link #select(String...) select}, {@link #filter(ValueBoolean...) filter} etc.
+ *
+ * @param key
+ * The parameter key. Must not be null or empty.
+ * @param value
+ * The parameter value.
+ * @return This query object with the added parameter
+ *
+ * @throws IllegalArgumentException
+ * if the key is null or empty
+ * @throws IllegalStateException
+ * if this query object is a nested query
+ */
+ @Nonnull
+ public StructuredQuery withCustomParameter( @Nonnull final String key, @Nullable final String value )
+ {
+ if( Strings.isNullOrEmpty(key) ) {
+ throw new IllegalArgumentException("Custom parameter key must not be null or empty.");
+ }
+ // if the query is inside an expand it is not corresponding to an HTTP query
+ // so here only odata parameters are allowed
+ else if( !isRoot() ) {
+ throw new IllegalStateException(
+ "Custom query parameters can only be added to the HTTP query but not to nested OData queries on navigation properties.");
+ }
+ customParameters.put(key, value);
+ return this;
+ }
+
+ /**
+ * Requests an inline count by adding the system query option {@code $inlinecount} (OData V2) or {@code $count}
+ * (OData V4).
+ *
+ * @return This query object with the added parameter
+ */
+ @Nonnull
+ public StructuredQuery withInlineCount()
+ {
+ final Map.Entry queryOption = getProtocol().getQueryOptionInlineCount(true);
+ customParameters.put(queryOption.getKey(), queryOption.getValue());
+ return this;
+ }
+
+ @Nonnull
+ @Override
+ public String getEncodedQueryString()
+ {
+ return QuerySerializer.serializeAndEncodeQuery(this, true);
+ }
+
+ @Nonnull
+ @Override
+ public String getQueryString()
+ {
+ return QuerySerializer.serializeAndEncodeQuery(this, false);
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/AbstractODataParameters.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/AbstractODataParameters.java
new file mode 100644
index 000000000..0abf559c7
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/AbstractODataParameters.java
@@ -0,0 +1,180 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.Expressions;
+
+import io.vavr.Tuple;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * Abstract class to build parameter expressions for the URL path. Parameters can resemble an entity key or function
+ * parameters.
+ */
+@RequiredArgsConstructor( access = AccessLevel.PACKAGE )
+public abstract class AbstractODataParameters
+{
+ @Getter( AccessLevel.PACKAGE )
+ @Nonnull
+ private final Map parameters = new LinkedHashMap<>();
+
+ /**
+ * The {@link ODataProtocol} these parameters should conform to.
+ */
+ @Getter
+ @Nonnull
+ private final ODataProtocol protocol;
+
+ /**
+ * Add a parameter.
+ *
+ * @param parameterName
+ * Name of the entity property or function parameters.
+ * @param value
+ * Property value, assumed to be a primitive.
+ * @param
+ * Type of the primitive value.
+ * @throws IllegalArgumentException
+ * When a parameter by that idenfitier already exists or primitive type is not supported.
+ */
+ void addParameterInternal( @Nonnull final String parameterName, @Nullable final PrimitiveT value )
+ {
+ if( parameters.containsKey(parameterName) ) {
+ throw new IllegalArgumentException(
+ "Cannot add parameter \"" + parameterName + "\": A parameter by that name already exists.");
+ }
+ parameters.put(parameterName, Expressions.createOperand(value));
+ }
+
+ /**
+ * Convenience method to add multpiple parameters at once.
+ *
+ * @throws IllegalArgumentException
+ * When one of the values is null.
+ *
+ * @see #addParameterInternal(String, Object)
+ */
+ void addParameterSetInternal( @Nonnull final Map properties )
+ {
+ properties.forEach(this::addParameterInternal);
+ }
+
+ /**
+ * Serializes all parameters into an encoded URL path segment. The format is as follows:
+ *
+ *
+ *
An empty set of parameters will yield {@code ()}
+ *
A single parameter entry will yield {@code (value)}
+ *
Multiple of parameters will yield {@code (key1=val,key2=val)}
+ *
+ *
+ *
+ * @return Encoded URL string representation of the parameters.
+ */
+ @Nonnull
+ public String toEncodedString()
+ {
+ return toStringInternal(UriEncodingStrategy.REGULAR, ParameterFormat.PATH_SHORT);
+ }
+
+ /**
+ * Serializes all parameters into an encoded URL path segment. The format is as follows:
+ *
+ *
+ *
An empty set of parameters will yield {@code ()}
+ *
A single parameter entry will yield {@code (value)}
+ *
Multiple of parameters will yield {@code (key1=val,key2=val)}
+ *
+ *
+ *
+ * @param strategy
+ * The URI encoding strategy.
+ * @return Encoded URL string representation of the parameters.
+ */
+ @Nonnull
+ public String toEncodedString( @Nonnull final UriEncodingStrategy strategy )
+ {
+ return toStringInternal(strategy, ParameterFormat.PATH_SHORT);
+ }
+
+ /**
+ * Serializes all parameters into an unencoded URL path segment. The format is as follows:
+ *
+ *
+ *
An empty set of parameters will yield {@code ()}
+ *
A single parameter entry will yield {@code (value)}
+ *
Multiple of parameters will yield {@code (key1=val,key2=val)}
+ *
+ *
+ *
+ * @return String representation of the parameters.
+ */
+ @Nonnull
+ @Override
+ public String toString()
+ {
+ return toStringInternal(UriEncodingStrategy.NONE, ParameterFormat.PATH_SHORT);
+ }
+
+ @Nonnull
+ String toStringInternal( @Nonnull final UriEncodingStrategy strategy, final ParameterFormat format )
+ {
+ final Function encoder =
+ format.isQuery() ? strategy.getQueryPercentEscaper()::escape : strategy.getPathPercentEscaper()::escape;
+
+ // case short format: single key value without field-name
+ if( format == ParameterFormat.PATH_SHORT && parameters.size() == 1 ) {
+ final Expressions.OperandSingle singleValue = parameters.values().iterator().next();
+ String parameterValue = singleValue.getExpression(protocol);
+ parameterValue = encoder.apply(parameterValue);
+ parameterValue = String.format("(%s)", parameterValue);
+
+ return parameterValue;
+ }
+
+ // case long format: compound key with field-name/value pair(s)
+ final String keyDelimiter = format.isQuery() ? "&" : ",";
+ String keys =
+ parameters
+ .entrySet()
+ .stream()
+ .map(param -> Tuple.of(param.getKey(), param.getValue()))
+ .map(param -> param.map2(val -> val.getExpression(protocol)))
+ .map(param -> param.map2(encoder))
+ .map(param -> param.apply(( key, val ) -> String.format("%s=%s", key, val)))
+ .collect(Collectors.joining(keyDelimiter));
+
+ if( !format.isQuery() ) {
+ keys = String.format("(%s)", keys);
+ }
+ return keys;
+ }
+
+ enum ParameterFormat
+ {
+ PATH(false, false),
+ PATH_SHORT(false, true),
+ QUERY(true, false);
+
+ @Getter
+ private final boolean isQuery;
+
+ @Getter
+ private final boolean isShort;
+
+ ParameterFormat( final boolean isQuery, final boolean isShort )
+ {
+ this.isQuery = isQuery;
+ this.isShort = isShort;
+ }
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ETagSubmissionStrategy.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ETagSubmissionStrategy.java
new file mode 100644
index 000000000..f2d209d3a
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ETagSubmissionStrategy.java
@@ -0,0 +1,47 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import io.vavr.control.Option;
+
+/**
+ * Strategy options for sending IF-MATCH headers.
+ */
+public enum ETagSubmissionStrategy
+{
+ /**
+ * Send an IF-MATCH header, if and only if a version identifier is defined on an {@code VdmEntity}.
+ */
+ SUBMIT_ETAG_FROM_ENTITY,
+ /**
+ * Do not send any IF-MATCH header.
+ */
+ SUBMIT_NO_ETAG,
+ /**
+ * Send a wildcard ({@code *}) in the IF-MATCH header matching all version identifiers. This is essentially a force
+ * overwrite.
+ */
+ SUBMIT_ANY_MATCH_ETAG;
+
+ /**
+ * Returns the value of the IF-MATCH header to be sent based on the given {@code maybeVersionIdentifier}.
+ *
+ * @param maybeVersionIdentifier
+ * The version identifier to be sent in the IF-MATCH header.
+ * @return The value of the IF-MATCH header to be sent, or {@code null} if no header should be sent.
+ */
+ @Nullable
+ public String getHeaderFromVersionIdentifier( @Nonnull final Option maybeVersionIdentifier )
+ {
+ switch( this ) {
+ case SUBMIT_ANY_MATCH_ETAG:
+ return "*";
+ case SUBMIT_NO_ETAG:
+ return null;
+ case SUBMIT_ETAG_FROM_ENTITY:
+ default:
+ return maybeVersionIdentifier.filter(s -> !s.isEmpty()).getOrNull();
+ }
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/HttpEntityReader.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/HttpEntityReader.java
new file mode 100644
index 000000000..9753e74e4
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/HttpEntityReader.java
@@ -0,0 +1,129 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.function.BiFunction;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HttpEntity;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonIOException;
+import com.google.gson.JsonParser;
+import com.google.gson.stream.JsonReader;
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataDeserializationException;
+
+import io.vavr.CheckedFunction1;
+import lombok.AccessLevel;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Utility class to leverage reading from an HTTP response.
+ */
+@Slf4j
+@RequiredArgsConstructor( access = AccessLevel.PRIVATE )
+final class HttpEntityReader
+{
+ private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
+
+ @Nullable
+ private final HttpEntity entity;
+
+ @Nonnull
+ private final BiFunction errorHandler;
+
+ @Nonnull
+ private final ODataProtocol protocol;
+
+ @Nullable
+ private T read( @Nonnull final CheckedFunction1 streamConsumer )
+ {
+ if( entity == null ) {
+ final String msg = protocol + " response does not contain an HTTP entity.";
+ log.warn(msg);
+ throw errorHandler.apply(msg, null);
+ }
+
+ try( InputStream content = entity.getContent() ) {
+ return streamConsumer.apply(content);
+ }
+ catch( final IOException | JsonIOException e ) {
+ final String msg = protocol + " response stream cannot be read for HTTP entity: ";
+ log.debug(msg + entity, e);
+ throw errorHandler.apply(msg + entity.getClass().getName(), e);
+ }
+ catch( final UnsupportedOperationException e ) {
+ final String msg = protocol + " response entity content cannot be represented as stream object.";
+ log.debug(msg, e);
+ throw errorHandler.apply(msg, e);
+ }
+ // CHECKSTYLE:OFF
+ catch( final Throwable e ) {
+ final String msg = "A problem occurred while streaming the " + protocol + " response.";
+ log.debug(msg, e);
+ throw errorHandler.apply(msg, e);
+ }
+ // CHECKSTYLE:ON
+ }
+
+ /**
+ * Protocol dependent method to consume the InputStream from the HTTP response entity. This method parses the whole
+ * JSON tree eagerly.
+ *
+ * @param result
+ * The result object to read from.
+ * @param elementConsumer
+ * Function to turn a GSON element to a generic result.
+ * @param
+ * The generic return type.
+ * @return The response.
+ * @throws ODataDeserializationException
+ * When streamed deserialization process failed.
+ */
+ static T read(
+ @Nonnull final ODataRequestResult result,
+ @Nonnull final CheckedFunction1 elementConsumer )
+ {
+ return stream(result, reader -> {
+ final JsonElement rootElement = JsonParser.parseReader(reader);
+ return elementConsumer.apply(rootElement);
+ });
+ }
+
+ /**
+ * Protocol dependent method to consume the InputStream from the HTTP response entity. This method allows for lazy
+ * consuming of the JSON tree.
+ *
+ * @param result
+ * The result object to read from.
+ * @param readerConsumer
+ * The consumer of the JsonReader.
+ * @param
+ * The generic return type.
+ * @return The response.
+ * @throws ODataDeserializationException
+ * When streamed deserialization process failed.
+ */
+ static T stream(
+ @Nonnull final ODataRequestResult result,
+ @Nonnull final CheckedFunction1 readerConsumer )
+ {
+ final ClassicHttpResponse httpResponse = result.getHttpResponse();
+ final ODataRequestGeneric request = result.getODataRequest();
+ final BiFunction errorHandler =
+ ( msg, e ) -> new ODataDeserializationException(request, httpResponse, msg, e);
+
+ return new HttpEntityReader(httpResponse.getEntity(), errorHandler, request.getProtocol()).read(inputStream -> {
+ final JsonReader reader = new JsonReader(new InputStreamReader(inputStream, DEFAULT_CHARSET));
+ return readerConsumer.apply(reader);
+ });
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartHttpResponse.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartHttpResponse.java
new file mode 100644
index 000000000..6ab2173ec
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartHttpResponse.java
@@ -0,0 +1,166 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.io.Serial;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.HttpVersion;
+import org.apache.hc.core5.http.NameValuePair;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.apache.hc.core5.http.message.BasicClassicHttpResponse;
+import org.apache.hc.core5.http.message.BasicHeader;
+import org.apache.hc.core5.http.message.BasicNameValuePair;
+import org.apache.hc.core5.http.message.StatusLine;
+
+import io.vavr.control.Try;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Helper class to construct an HttpResponse object on behalf of serialized HTTP protocol content.
+ */
+@Slf4j
+class MultipartHttpResponse extends BasicClassicHttpResponse
+{
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
+ private static final Pattern PATTERN_STATUS_LINE = Pattern.compile("^HTTP/(\\d).(\\d) (\\d+) (.*)");
+ private static final Pattern PATTERN_NEW_LINE = Pattern.compile("\\R");
+
+ @Nullable
+ @Getter
+ private final Integer contentId;
+
+ private MultipartHttpResponse(
+ @Nonnull final StatusLine statusLine,
+ @Nonnull final List headers,
+ @Nonnull final HttpEntity entity,
+ @Nullable final Integer contentId )
+ {
+ super(statusLine.getStatusCode(), statusLine.getReasonPhrase());
+ setVersion(statusLine.getProtocolVersion());
+ headers.forEach(this::addHeader);
+ setEntity(entity);
+ this.contentId = contentId;
+ }
+
+ /**
+ * Factory method to construct an {@link MultipartHttpResponse} on behalf of serialized HTTP protocol content: First
+ * line is the status line, the following lines are headers, the optional body is introduced with an empty line.
+ *
+ * @param entry
+ * The HTTP protocol content, consisting of status line, headers, (empty-line) and payload.
+ * @return A new HTTP response instance.
+ */
+ @Nonnull
+ public static MultipartHttpResponse ofHttpContent( @Nonnull final MultipartParser.Entry entry )
+ {
+ final Matcher contentIdMatcher =
+ Pattern
+ .compile("^Content-ID:\\s*(\\d+)\\s*$", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE)
+ .matcher(entry.getMeta());
+ final Integer contentId = contentIdMatcher.find() ? Integer.parseInt(contentIdMatcher.group(1)) : null;
+ final String[] lines = PATTERN_NEW_LINE.split(entry.getPayload());
+
+ final StatusLine statusLine = getStatusLine(lines[0]);
+
+ final StringBuilder payload = new StringBuilder();
+ final StringBuilder header = new StringBuilder();
+
+ boolean isHeaders = true;
+ for( int i = 1; i < lines.length; i++ ) {
+ if( isHeaders ) {
+ if( lines[i].isEmpty() ) {
+ isHeaders = false;
+ } else {
+ header.append(lines[i]).append('\n');
+ }
+ } else {
+ payload.append(lines[i]).append('\n');
+ }
+ }
+
+ final List headers = getHeadersFromString(header.toString());
+ final ContentType contentType = getContentType(headers).orElse(ContentType.APPLICATION_JSON);
+ final ContentType contentTypeCharset = withFallbackCharset(contentType);
+ final StringEntity httpEntity = new StringEntity(payload.toString(), contentTypeCharset);
+ return new MultipartHttpResponse(statusLine, headers, httpEntity, contentId);
+ }
+
+ @Nonnull
+ static List getHeadersFromString( @Nonnull final String headerString )
+ {
+ final List result = new ArrayList<>();
+ for( final String headerLine : PATTERN_NEW_LINE.split(headerString.trim()) ) {
+ final String[] split = headerLine.split(":", 2);
+ result.add(new BasicHeader(split[0].trim(), split.length > 1 ? split[1].trim() : ""));
+ }
+ return result;
+ }
+
+ @Nonnull
+ static Optional getContentType( @Nonnull final List headers )
+ {
+ return headers
+ .stream()
+ .filter(h -> HttpHeaders.CONTENT_TYPE.equalsIgnoreCase(h.getName()))
+ .map(NameValuePair::getValue)
+ .map(contentType -> Try.of(() -> ContentType.parse(contentType)).getOrNull())
+ .filter(Objects::nonNull)
+ .findFirst();
+ }
+
+ @Nonnull
+ private static ContentType withFallbackCharset( @Nonnull final ContentType contentType )
+ {
+ if( contentType.getCharset() != null ) {
+ return contentType;
+ }
+ return contentType
+ .withParameters(new BasicNameValuePair("charset", MultipartHttpResponse.DEFAULT_CHARSET.name()));
+ }
+
+ @Nonnull
+ private static StatusLine getStatusLine( @Nonnull final String firstLine )
+ {
+ final Matcher m = PATTERN_STATUS_LINE.matcher(firstLine);
+ if( m.find() ) {
+ final int major = Integer.parseInt(m.group(1));
+ final int minor = Integer.parseInt(m.group(2));
+ final int code = Integer.parseInt(m.group(3));
+ final String reason = m.group(4);
+ return new StatusLine(new HttpVersion(major, minor), code, reason);
+ }
+ log.error("Failed to construct status line for HTTP protocol response: {}", firstLine);
+ return new StatusLine(HttpVersion.HTTP_1_1, 0, "Unknown");
+ }
+
+ @Serial
+ private void readObject( java.io.ObjectInputStream in )
+ throws java.io.NotSerializableException
+ {
+ throw new java.io.NotSerializableException(getClass().getName());
+ }
+
+ @Serial
+ private void writeObject( java.io.ObjectOutputStream out )
+ throws java.io.NotSerializableException
+ {
+ throw new java.io.NotSerializableException(getClass().getName());
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartParser.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartParser.java
new file mode 100644
index 000000000..194d52356
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartParser.java
@@ -0,0 +1,264 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Spliterator;
+import java.util.Spliterators;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+import javax.annotation.Nonnull;
+
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpResponse;
+
+import io.vavr.control.Try;
+import lombok.AccessLevel;
+import lombok.RequiredArgsConstructor;
+import lombok.Value;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Helper class to parse an {@link InputStream} to an {@link Iterable} multi-part response. One part can have multiple
+ * segments (e.g. changeset). For that reason the API exposes {@code Iterable>}.
+ */
+@Slf4j
+@RequiredArgsConstructor( access = AccessLevel.PACKAGE )
+class MultipartParser implements AutoCloseable
+{
+ private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
+ private static final String MULTIPART_MIXED_MIME_TYPE = "multipart/mixed";
+ private static final String MULTIPART_MIXED_BOUNDARY = "boundary";
+
+ @Nonnull
+ private final BufferedReader reader;
+
+ @Nonnull
+ private final String delimiter;
+
+ /**
+ * Factory method to instantiate an {@link MultipartParser} object.
+ *
+ * @param httpResponse
+ * The HTTP response to read from: content-type, input-stream and charset.
+ * @return A new instance of {@link MultipartParser}.
+ */
+ @SuppressWarnings( "PMD.CloseResource" ) // The attached InputStream is closed by the MultipartParser itself.
+ public static MultipartParser ofHttpResponse( @Nonnull final ClassicHttpResponse httpResponse )
+ {
+ final HttpEntity entity = httpResponse.getEntity();
+ if( entity == null ) {
+ throw new IllegalStateException("HTTP response does not contain a content.");
+ }
+
+ final InputStream inputStream =
+ Try
+ .of(entity::getContent)
+ .getOrElseThrow(e -> new IllegalStateException("Unable to read HTTP content.", e));
+
+ final String delimiter =
+ getDelimiterFromHttpResponse(httpResponse)
+ .orElseThrow(() -> new IllegalStateException("No delimiter found in HTTP header."));
+
+ final Charset charsetValue =
+ Try
+ .of(() -> ContentType.parse(entity.getContentType()))
+ .onFailure(e -> log.debug("Unable to detect charset, using to default charset {}", DEFAULT_CHARSET))
+ .toOption()
+ .map(ContentType::getCharset)
+ .filter(Objects::nonNull)
+ .peek(c -> log.debug("Using detected charset {}", c))
+ .getOrElse(DEFAULT_CHARSET);
+
+ return ofInputStream(inputStream, charsetValue, delimiter);
+ }
+
+ /**
+ * Factory method to instantiate an {@link MultipartParser} object.
+ *
+ * @param contentStream
+ * The {@link InputStream} which is read from.
+ * @param contentCharset
+ * The charset of the content.
+ * @param delimiter
+ * The delimiter, usually derived from content type.
+ * @return A new instance of {@link MultipartParser}.
+ */
+ @Nonnull
+ public static MultipartParser ofInputStream(
+ @Nonnull final InputStream contentStream,
+ @Nonnull final Charset contentCharset,
+ @Nonnull final String delimiter )
+ {
+ final BufferedReader reader =
+ Try
+ .of(() -> new InputStreamReader(contentStream, contentCharset))
+ .map(BufferedReader::new)
+ .getOrElseThrow(e -> new IllegalStateException("Unable to initialize multi-part parsing.", e));
+
+ return new MultipartParser(reader, delimiter);
+ }
+
+ /**
+ * Get the iterable, raw multi-part response. This method can be invoked once. It internally caches the objects, so
+ * the returned List can be iterated multiple times. The {@link List} objects are eagerly evaluated.
+ *
+ * @return An iterable multi-part response.
+ */
+ @Nonnull
+ public List> toList()
+ {
+ return toList(Function.identity());
+ }
+
+ /**
+ * Get the iterable multi-part response, optionally parsed with a custom {@link Function}. This method can be
+ * invoked once. It internally caches the objects, so the returned List can be iterated multiple times. The
+ * {@link List} objects are eagerly evaluated.
+ *
+ * @param
+ * Generic item type.
+ * @param transformation
+ * Generic item transition logic.
+ *
+ * @return An iterable multi-part response, with custom deserialization.
+ */
+ @Nonnull
+ public List> toList( @Nonnull final Function transformation )
+ {
+ return toStream()
+ .map(segments -> segments.map(transformation).collect(Collectors.toList()))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Get the iterable, raw multi-part response. This method only works as long as the underlying {@link InputStream}
+ * of {@link MultipartParserReader} is not depleted. Hence for proper results, this method can usually only be
+ * invoked once. The resulting {@link Iterable} objects are lazily evaluated and remain uncached.
+ *
+ * @return An iterable multi-part response.
+ */
+ @Nonnull
+ public Stream> toStream()
+ {
+ // Create a stream from a spliterator but only retrieve the spliterator upon terminal operation of stream.
+ final Stream> result =
+ StreamSupport.stream(this::createSpliterator, MultipartSpliterator.CHARACTERISTICS, false);
+
+ // Translate stream of spliterators to stream of stream. Make sure all previous spliterators were fully consumed.
+ final AtomicReference> previous = new AtomicReference<>(Spliterators.emptySpliterator());
+
+ return result.map(currentSpliterator -> {
+ // if previous spliterator was not yet fully consumed, do so with the remaining elements
+ previous.getAndSet(currentSpliterator).forEachRemaining(item -> log.trace("Skipping element {}", item));
+
+ // return Stream of current spliterator
+ return StreamSupport.stream(currentSpliterator, false);
+ });
+ }
+
+ private Spliterator> createSpliterator()
+ {
+ final MultipartParserReader batchRead = new MultipartParserReader(reader, delimiter);
+
+ // position reader after first batch delimiter
+ batchRead.untilDelimiter();
+
+ return new MultipartSpliterator<>(() -> {
+ if( batchRead.isFinished() ) {
+ Try.run(reader::close).onFailure(e -> log.debug("Failed to close reader.", e));
+ return null;
+ }
+ final String segmentHead = batchRead.untilPayload();
+ final Optional maybeChangesetBoundary = getDelimiterFromString(segmentHead);
+
+ if( maybeChangesetBoundary.isPresent() ) { // multiple responses in changeset
+ return getChangeset(maybeChangesetBoundary.get(), batchRead);
+ } else { // single response
+ final String content = batchRead.untilDelimiter();
+ return Collections.singleton(new Entry(segmentHead, content)).spliterator();
+ }
+ });
+ }
+
+ @Nonnull
+ private
+ Spliterator
+ getChangeset( @Nonnull final String changesetDelimiter, final MultipartParserReader batchRead )
+ {
+ final MultipartParserReader changesetRead = new MultipartParserReader(reader, changesetDelimiter);
+
+ // position reader after first sub-segment delimiter
+ changesetRead.untilDelimiter();
+
+ return new MultipartSpliterator<>(() -> {
+ if( changesetRead.isFinished() ) {
+ return null;
+ }
+ final String subSegmentHead = changesetRead.untilPayload();
+ log.trace("Iterating Batch changeset segment with header {}", subSegmentHead);
+
+ final String content = changesetRead.untilDelimiter();
+ if( changesetRead.isFinished() ) {
+ batchRead.untilDelimiter(); // position reader to next batch delimiter
+ }
+ return new Entry(subSegmentHead, content);
+ });
+ }
+
+ @Nonnull
+ private static Optional getDelimiterFromString( @Nonnull final String segmentHead )
+ {
+ final List segmentHeaders = MultipartHttpResponse.getHeadersFromString(segmentHead);
+ final Optional segmentContentType = MultipartHttpResponse.getContentType(segmentHeaders);
+ return segmentContentType.flatMap(MultipartParser::getDelimiterFromContentType);
+ }
+
+ @Nonnull
+ private static Optional getDelimiterFromContentType( @Nonnull final ContentType contentType )
+ {
+ final String boundary = contentType.getParameter(MULTIPART_MIXED_BOUNDARY);
+ return boundary == null ? Optional.empty() : Optional.of("--" + boundary);
+ }
+
+ @Nonnull
+ private static Optional getDelimiterFromHttpResponse( @Nonnull final HttpResponse httpResponse )
+ {
+ final List headers = Arrays.asList(httpResponse.getHeaders());
+ final ContentType contentType = MultipartHttpResponse.getContentType(headers).orElse(null);
+ if( contentType == null ) {
+ return Optional.empty();
+ }
+ if( !MULTIPART_MIXED_MIME_TYPE.equalsIgnoreCase(contentType.getMimeType()) ) {
+ log.debug("Unexpected value in HTTP header \"Content-Type\" of OData batch response: {}", contentType);
+ }
+ return getDelimiterFromContentType(contentType);
+ }
+
+ @Override
+ public void close()
+ {
+ Try.run(reader::close).onFailure(e -> log.warn("Failed to close HTTP entity multi-part parser.", e));
+ }
+
+ @Value
+ static class Entry
+ {
+ String meta;
+ String payload;
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartParserReader.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartParserReader.java
new file mode 100644
index 000000000..0c7d758ab
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartParserReader.java
@@ -0,0 +1,85 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+
+import lombok.Getter;
+
+/**
+ * Helper class to manage read operations on a Reader object.
+ */
+class MultipartParserReader
+{
+ @Nonnull
+ private final BufferedReader reader;
+
+ @Nonnull
+ final String delimiter;
+
+ @Nonnull
+ private final String delimiterEnd;
+
+ @Getter
+ private boolean started = false;
+
+ @Getter
+ private boolean finished = false;
+
+ MultipartParserReader( @Nonnull final BufferedReader reader, @Nonnull final String delimiter )
+ {
+ this.reader = reader;
+ this.delimiter = delimiter;
+ this.delimiterEnd = delimiter + "--";
+ }
+
+ /**
+ * Reads and returns the String with new-line separator "\n" until (including) delimiter.
+ *
+ * @return The contents until (including) next separator.
+ */
+ @Nonnull
+ public String untilDelimiter()
+ {
+ return readWhile(line -> !line.equals(delimiter) && !line.equals(delimiterEnd));
+ }
+
+ /**
+ * Read and return the String with new-line separator "\n" until next empty line. This signals the start of HTTP
+ * payload.
+ *
+ * @return The contents until (excluding) start of HTTP payload.
+ */
+ @Nonnull
+ public String untilPayload()
+ {
+ return readWhile(s -> s != null && !s.isEmpty());
+ }
+
+ @Nonnull
+ private String readWhile( @Nonnull final Predicate recordFlag )
+ {
+ started = true;
+ final StringBuilder sb = new StringBuilder();
+ String line;
+ try {
+ while( !finished && (line = reader.readLine()) != null ) {
+ if( !recordFlag.test(line) ) {
+ if( delimiterEnd.equals(line) ) {
+ finished = true;
+ }
+ return sb.toString();
+ }
+ sb.append(line).append('\n');
+ }
+ }
+ catch( final IOException e ) {
+ throw new UncheckedIOException("Unable to parse multi-part text.", e);
+ }
+ finished = true;
+ return sb.toString();
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartSpliterator.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartSpliterator.java
new file mode 100644
index 000000000..b9db73339
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/MultipartSpliterator.java
@@ -0,0 +1,52 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.util.Spliterator;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+import lombok.AccessLevel;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * Helper class to enable a {@link Supplier} based {@link Spliterator} implementation.
+ *
+ * @param
+ * Generic type of produced items.
+ */
+@RequiredArgsConstructor( access = AccessLevel.PACKAGE )
+class MultipartSpliterator implements Spliterator
+{
+ static final int CHARACTERISTICS = IMMUTABLE | ORDERED | NONNULL;
+
+ // if Supplier returns a null, the end is signalled
+ private final Supplier producer;
+
+ @Override
+ public boolean tryAdvance( final Consumer super T> action )
+ {
+ final T result = producer.get();
+ if( result != null ) {
+ action.accept(result);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public Spliterator trySplit()
+ {
+ return null; // don't support split
+ }
+
+ @Override
+ public long estimateSize()
+ {
+ return Long.MAX_VALUE;
+ }
+
+ @Override
+ public int characteristics()
+ {
+ return CHARACTERISTICS;
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/NumberDeserializationStrategy.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/NumberDeserializationStrategy.java
new file mode 100644
index 000000000..193ddf262
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/NumberDeserializationStrategy.java
@@ -0,0 +1,43 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.util.function.Consumer;
+
+import javax.annotation.Nonnull;
+
+import com.google.gson.GsonBuilder;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ * Number deserialization strategy to determine behavior for JSON numbers without target type references.
+ */
+@RequiredArgsConstructor
+public enum NumberDeserializationStrategy
+{
+ /**
+ * Double strategy to deserialize JSON numbers to double, if no target type references are specified.
+ */
+ DOUBLE(gsonBuilder -> {
+ // default behavior of GSON
+ }),
+
+ /**
+ * BigDecimal strategy to deserialize JSON numbers to BigDecimal, if no target type references are specified.
+ */
+ BIG_DECIMAL(gsonBuilder -> {
+ gsonBuilder.setObjectToNumberStrategy(com.google.gson.ToNumberPolicy.BIG_DECIMAL); // FQN for backwards-compatibility
+ });
+
+ private final Consumer adapter;
+
+ /**
+ * Adjust the deserialization strategy for untyped numbers.
+ *
+ * @param gsonBuilder
+ * The GsonBuilder to change.
+ */
+ void decorate( @Nonnull final GsonBuilder gsonBuilder )
+ {
+ adapter.accept(gsonBuilder);
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataEntityKey.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataEntityKey.java
new file mode 100644
index 000000000..20ba20346
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataEntityKey.java
@@ -0,0 +1,131 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.util.Map;
+import java.util.Set;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Fluent API class to build and hold entity keys. A key is comprised of one or multiple individual key-value pairs.
+ */
+@Slf4j
+public class ODataEntityKey extends AbstractODataParameters
+{
+ /**
+ * Create a new, empty entity key for the given protocol version.
+ *
+ * @param protocol
+ * The {@link ODataProtocol} version this key should conform to.
+ */
+ public ODataEntityKey( @Nonnull final ODataProtocol protocol )
+ {
+ super(protocol);
+ }
+
+ /**
+ * Get all field names that are part of this key.
+ *
+ * @return A set of entity property field names.
+ */
+ @Nonnull
+ public Set getFieldNames()
+ {
+ return getParameters().keySet();
+ }
+
+ /**
+ * Add an entity property to this key.
+ *
+ * @param propertyName
+ * Name of the property (derived from the EDMX)
+ * @param value
+ * Property value, assumed to be a primitive.
+ * @param
+ * Type of the primitive value.
+ * @return The modified instance.
+ *
+ * @throws IllegalArgumentException
+ * When a parameter by that idenfitier already exists or primitive type is not supported.
+ */
+ @Nonnull
+ public ODataEntityKey addKeyProperty(
+ @Nonnull final String propertyName,
+ @Nullable final PrimitiveT value )
+ {
+ super.addParameterInternal(propertyName, value);
+ return this;
+ }
+
+ /**
+ * Add properties to the OData entity key.
+ *
+ * @param properties
+ * The key-value mapping.
+ * @return The same instance.
+ *
+ * @throws IllegalArgumentException
+ * When the map contains a primitive type that is not supported.
+ *
+ * @see #addKeyProperty(String, Object)
+ */
+ @Nonnull
+ public ODataEntityKey addKeyProperties( @Nonnull final Map properties )
+ {
+ super.addParameterSetInternal(properties);
+ return this;
+ }
+
+ /**
+ * Create an instance of {@link ODataEntityKey} from a generic key-value composition.
+ *
+ * @param key
+ * Key-value pairs of entity properties and their values.
+ * @param protocol
+ * The {@link ODataProtocol} version this key should conform to.
+ * @return A new instance of {@link ODataEntityKey}.
+ *
+ * @throws IllegalArgumentException
+ * When the map contains a primitive type that is not supported.
+ *
+ * @see #addKeyProperty(String, Object)
+ */
+ @Nonnull
+ public static ODataEntityKey of( @Nonnull final Map key, @Nonnull final ODataProtocol protocol )
+ {
+ return new ODataEntityKey(protocol).addKeyProperties(key);
+ }
+
+ @Override
+ @Nonnull
+ public String toEncodedString( @Nonnull final UriEncodingStrategy strategy )
+ {
+ if( getParameters().isEmpty() ) {
+ log
+ .warn(
+ "The current entity key is empty. Using it within a request will cause the request to fail as empty entity keys are not allowed.");
+ }
+ return super.toEncodedString(strategy);
+ }
+
+ /**
+ * Serializes the entity key into an unencoded OData URL format for entity keys.
+ *
+ * @return Encoded URL string representation of entity key.
+ */
+ @Nonnull
+ @Override
+ public String toString()
+ {
+ if( getParameters().isEmpty() ) {
+ log
+ .warn(
+ "The current entity key is empty. Using it within a request will cause the request to fail as empty entity keys are not allowed.");
+ }
+ return super.toString();
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataFormat.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataFormat.java
new file mode 100644
index 000000000..950213bf6
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataFormat.java
@@ -0,0 +1,55 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import javax.annotation.Nonnull;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * OData format for responses and requests.
+ */
+@RequiredArgsConstructor
+enum ODataFormat
+{
+ /**
+ * JSON
+ */
+ JSON("json", "application/json"),
+
+ /**
+ * XML
+ */
+ XML("xml", "application/xml");
+
+ private final String odataFormatValue;
+
+ @Getter( AccessLevel.PACKAGE )
+ @Nonnull
+ private final String httpAccept;
+
+ @Nonnull
+ @Override
+ public String toString()
+ {
+ return odataFormatValue;
+ }
+
+ /**
+ * Returns the {@code ODataFormat} for the given identifier or throws and {@code IllegalArgumentException} if the
+ * provided string is not a valid identifier. This operation is case-insensitive.
+ *
+ * @param value
+ * The string identifier of the {@code ODataFormat}.
+ * @return The {@code ODataFormat} associated with the identifier.
+ */
+ public static ODataFormat getODataFormat( @Nonnull final String value )
+ {
+ for( final ODataFormat f : values() ) {
+ if( f.toString().equalsIgnoreCase(value) ) {
+ return f;
+ }
+ }
+ throw new IllegalArgumentException();
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataFunctionParameters.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataFunctionParameters.java
new file mode 100644
index 000000000..37942a752
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataFunctionParameters.java
@@ -0,0 +1,144 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+
+/**
+ * Fluent API class to build and hold function parameters.
+ */
+public class ODataFunctionParameters extends AbstractODataParameters
+{
+ private final ParameterFormat parameterFormat;
+
+ /**
+ * Create a new, empty set of parameters for an OData function.
+ *
+ * @param protocol
+ * The {@link ODataProtocol} version the parameters should conform to.
+ */
+ public ODataFunctionParameters( @Nonnull final ODataProtocol protocol )
+ {
+ super(protocol);
+ parameterFormat = protocol.isEqualTo(ODataProtocol.V2) ? ParameterFormat.QUERY : ParameterFormat.PATH;
+ }
+
+ /**
+ * Create an instance of {@link ODataFunctionParameters} from a set of parameters.
+ *
+ * @param parameters
+ * Key-value pairs for parameter values.
+ * @param protocol
+ * The {@link ODataProtocol} version these parameters should conform to.
+ * @return A new instance of {@link ODataFunctionParameters}.
+ *
+ * @throws IllegalArgumentException
+ * When the map contains a primitive type that is not supported.
+ *
+ * @see #addParameter(String, Object)
+ */
+ @Nonnull
+ public static
+ ODataFunctionParameters
+ of( @Nonnull final Map parameters, @Nonnull final ODataProtocol protocol )
+ {
+ final ODataFunctionParameters functionParameters = new ODataFunctionParameters(protocol);
+ functionParameters.addParameterSetInternal(parameters);
+
+ return functionParameters;
+ }
+
+ /**
+ * Convenience method to create an empty set of function parameters.
+ *
+ * @param protocol
+ * The OData protocol version that the parameters should conform to.
+ *
+ * @return A new instance of {@link ODataFunctionParameters}
+ */
+ @Nonnull
+ public static ODataFunctionParameters empty( @Nonnull final ODataProtocol protocol )
+ {
+ return new ODataFunctionParameters(protocol);
+ }
+
+ /**
+ * Add a parameter to function parameters.
+ *
+ * @param parameterName
+ * Name of the property (derived from the EDMX)
+ * @param value
+ * Property value, assumed to be a primitive.
+ * @param
+ * Type of the primitive value.
+ * @return The modified instance.
+ *
+ * @throws IllegalArgumentException
+ * When a parameter by that idenfitier already exists or primitive type is not supported.
+ */
+ @Nonnull
+ public ODataFunctionParameters addParameter(
+ @Nonnull final String parameterName,
+ @Nullable final PrimitiveT value )
+ {
+ addParameterInternal(parameterName, value);
+ return this;
+ }
+
+ /**
+ * Serializes all parameters into an encoded URL path segment. The format is as follows:
+ *
+ *
+ *
An empty set of parameters will yield {@code ()}
+ *
One or more parameters will yield {@code (key1=val,key2=val)}
+ *
+ *
+ *
+ * @return Encoded URL string representation of the parameters.
+ */
+ @Nonnull
+ @Override
+ public String toEncodedString()
+ {
+ return super.toStringInternal(UriEncodingStrategy.REGULAR, parameterFormat);
+ }
+
+ /**
+ * Serializes all parameters into an encoded URL path segment. The format is as follows:
+ *
+ *
+ *
An empty set of parameters will yield {@code ()}
+ *
One or more parameters will yield {@code (key1=val,key2=val)}
+ *
+ *
+ *
+ * @return Encoded URL string representation of the parameters.
+ */
+ @Nonnull
+ @Override
+ public String toEncodedString( @Nonnull final UriEncodingStrategy strategy )
+ {
+ return super.toStringInternal(strategy, parameterFormat);
+ }
+
+ /**
+ * Serializes all parameters into an unencoded URL path segment. The format is as follows:
+ *
+ *
+ *
An empty set of parameters will yield {@code ()}
+ *
One or more parameters will yield {@code (key1=val,key2=val)}
+ *
+ *
+ *
+ * @return String representation of the parameters.
+ */
+ @Nonnull
+ @Override
+ public String toString()
+ {
+ return super.toStringInternal(UriEncodingStrategy.NONE, parameterFormat);
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataGsonBuilder.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataGsonBuilder.java
new file mode 100644
index 000000000..0706d7193
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataGsonBuilder.java
@@ -0,0 +1,64 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+import javax.annotation.Nonnull;
+
+import com.google.gson.GsonBuilder;
+import com.sap.cloud.sdk.datamodel.odata.client.adapter.BinaryTypeAdapter;
+import com.sap.cloud.sdk.datamodel.odata.client.adapter.DurationTypeAdapter;
+import com.sap.cloud.sdk.datamodel.odata.client.adapter.LocalDateTypeAdapter;
+import com.sap.cloud.sdk.datamodel.odata.client.adapter.LocalTimeTypeAdapter;
+import com.sap.cloud.sdk.datamodel.odata.client.adapter.OffsetDateTimeTypeAdapter;
+import com.sap.cloud.sdk.datamodel.odata.client.adapter.UuidTypeAdapter;
+import com.sap.cloud.sdk.result.AnnotatedFieldGsonExclusionStrategy;
+import com.sap.cloud.sdk.result.ElementName;
+import com.sap.cloud.sdk.result.ElementNameGsonFieldNamingStrategy;
+
+/**
+ * Factory class to manage GSON references.
+ */
+public final class ODataGsonBuilder
+{
+ /**
+ * Construct a new GsonBuilder for serialization and deserialization of OData values.
+ *
+ * @return The GsonBuilder reference.
+ */
+ @Nonnull
+ public static GsonBuilder newGsonBuilder()
+ {
+ return newGsonBuilder(NumberDeserializationStrategy.DOUBLE);
+ }
+
+ /**
+ * Construct a new GsonBuilder for serialization and deserialization of OData values.
+ *
+ * @param numberStrategy
+ * The default number deserialization strategy to be used for untyped numbers.
+ * @return The GsonBuilder reference.
+ */
+ @Nonnull
+ static GsonBuilder newGsonBuilder( @Nonnull final NumberDeserializationStrategy numberStrategy )
+ {
+ final GsonBuilder gsonBuilder =
+ new GsonBuilder()
+ .disableHtmlEscaping()
+ .setFieldNamingStrategy(new ElementNameGsonFieldNamingStrategy())
+ .setExclusionStrategies(new AnnotatedFieldGsonExclusionStrategy<>(ElementName.class))
+ .registerTypeAdapter(UUID.class, new UuidTypeAdapter())
+ .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeTypeAdapter())
+ .registerTypeAdapter(Duration.class, new DurationTypeAdapter())
+ .registerTypeAdapter(LocalTime.class, new LocalTimeTypeAdapter())
+ .registerTypeAdapter(LocalDate.class, new LocalDateTypeAdapter())
+ .registerTypeAdapter(byte[].class, new BinaryTypeAdapter());
+
+ numberStrategy.decorate(gsonBuilder);
+
+ return gsonBuilder;
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHealthyResponseValidator.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHealthyResponseValidator.java
new file mode 100644
index 000000000..ba5b45ae9
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHealthyResponseValidator.java
@@ -0,0 +1,110 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.message.StatusLine;
+
+import com.google.gson.JsonObject;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataDeserializationException;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataResponseException;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataServiceError;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataServiceErrorException;
+import com.sap.cloud.sdk.result.GsonResultElementFactory;
+import com.sap.cloud.sdk.result.GsonResultObject;
+import com.sap.cloud.sdk.result.ResultObject;
+
+import io.vavr.control.Option;
+import io.vavr.control.Try;
+
+/**
+ * Utility class to enable a healthy response validation.
+ */
+class ODataHealthyResponseValidator
+{
+ /**
+ * Check the HTTP response code and body of the OData request result. If the code indicates an unhealthy response,
+ * an exception will be thrown with further details.
+ *
+ * @param result
+ * The OData response object.
+ * @throws ODataResponseException
+ * When the response code infers an unhealthy state, i.e. when >= 400.
+ * @throws ODataServiceErrorException
+ * When the response contains an OData error message according to specification.
+ */
+ static void requireHealthyResponse( @Nonnull final ODataRequestResult result )
+ {
+ final ODataRequestGeneric originalRequest = result.getODataRequest();
+ final StatusLine statusLine = result.getStatusLine();
+
+ if( statusLine != null && statusLine.getStatusCode() < HttpStatus.SC_BAD_REQUEST ) { // code < 400
+ return;
+ }
+
+ final ClassicHttpResponse httpResponse = result.getHttpResponse();
+ final ODataRequestGeneric requestRelevantForException =
+ findPotentialBatchItem(httpResponse, originalRequest).getOrElse(originalRequest);
+
+ final Integer statusCode = statusLine == null ? null : statusLine.getStatusCode();
+ final String msg = "The HTTP response code (" + statusCode + ") indicates an error.";
+
+ final Try odataError = Try.of(() -> loadErrorFromResponse(result));
+ if( odataError.isSuccess() ) {
+ final String msgError = msg + " The OData service responded with an error message.";
+ throw new ODataServiceErrorException(
+ requestRelevantForException,
+ httpResponse,
+ msgError,
+ null,
+ odataError.get());
+ }
+ throw new ODataResponseException(requestRelevantForException, httpResponse, msg, null);
+ }
+
+ @Nonnull
+ private static
+ Option
+ findPotentialBatchItem( final HttpResponse httpResponse, final ODataRequestGeneric request )
+ {
+ if( !(request instanceof ODataRequestBatch requestBatch)
+ || !(httpResponse instanceof MultipartHttpResponse multipartHttpResponse) ) {
+ return Option.none();
+ }
+ @Nullable
+ final Integer failedBatchRequestNumber = multipartHttpResponse.getContentId();
+ if( failedBatchRequestNumber == null ) {
+ return Option.none();
+ }
+
+ for( final ODataRequestBatch.BatchItem requestGeneric : requestBatch.getRequests() ) {
+ if( requestGeneric instanceof ODataRequestBatch.BatchItemChangeset changeset ) {
+ for( final ODataRequestBatch.BatchItemSingle single : changeset.getRequests() ) {
+ if( single.getContentId() == failedBatchRequestNumber ) {
+ return Option.of(single.getRequest());
+ }
+ }
+ } else if( requestGeneric instanceof ODataRequestBatch.BatchItemSingle single
+ && single.getContentId() == failedBatchRequestNumber ) {
+ return Option.of(single.getRequest());
+ }
+ }
+ return Option.none();
+ }
+
+ @Nonnull
+ private static ODataServiceError loadErrorFromResponse( final ODataRequestResult result )
+ throws ODataDeserializationException
+ {
+ final GsonResultElementFactory elementFactory = new GsonResultElementFactory(ODataGsonBuilder.newGsonBuilder());
+
+ return HttpEntityReader.read(result, root -> {
+ final JsonObject error = root.getAsJsonObject().get("error").getAsJsonObject();
+ final ResultObject errorObject = new GsonResultObject(error, elementFactory);
+ return ODataServiceError.fromResultObject(errorObject, result.getODataRequest().getProtocol());
+ });
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHttpRequest.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHttpRequest.java
new file mode 100644
index 000000000..4320678e0
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataHttpRequest.java
@@ -0,0 +1,223 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.client5.http.ClientProtocolException;
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.client5.http.classic.methods.HttpDelete;
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.classic.methods.HttpPatch;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.client5.http.classic.methods.HttpPut;
+import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.ConnectionRequestTimeoutException;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpEntityContainer;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataConnectionException;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataRequestException;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@AllArgsConstructor( access = AccessLevel.PRIVATE )
+@Slf4j
+class ODataHttpRequest
+{
+ @Nonnull
+ private final ODataRequestGeneric odataRequest;
+
+ @Nonnull
+ private final HttpClient httpClient;
+
+ @Nullable
+ private final HttpEntity requestBody;
+
+ static
+ ODataHttpRequest
+ withoutBody( @Nonnull final ODataRequestGeneric requestGeneric, @Nonnull final HttpClient httpClient )
+ {
+ return forHttpEntity(requestGeneric, httpClient, null);
+ }
+
+ static ODataHttpRequest forBodyJson(
+ @Nonnull final ODataRequestGeneric requestGeneric,
+ @Nonnull final HttpClient httpClient,
+ @Nonnull final String json )
+ {
+ final StringEntity requestBody = new StringEntity(json, ContentType.APPLICATION_JSON);
+ return forHttpEntity(requestGeneric, httpClient, requestBody);
+ }
+
+ static ODataHttpRequest forBodyText(
+ @Nonnull final ODataRequestGeneric requestGeneric,
+ @Nonnull final HttpClient httpClient,
+ @Nonnull final String text )
+ {
+ return forHttpEntity(requestGeneric, httpClient, new StringEntity(text, UTF_8));
+ }
+
+ static ODataHttpRequest forHttpEntity(
+ @Nonnull final ODataRequestGeneric requestGeneric,
+ @Nonnull final HttpClient httpClient,
+ @Nullable final HttpEntity httpEntity )
+ {
+ return new ODataHttpRequest(requestGeneric, httpClient, httpEntity);
+ }
+
+ /**
+ * Perform the request the remote resource.
+ *
+ * @param requestCreator
+ * The factory for HTTP requests.
+ * @throws ODataRequestException
+ * When the request URI could not constructed.
+ * @throws ODataConnectionException
+ * When an error occurred while handling the HTTP connection.
+ * @return The HTTP response.
+ */
+ @Nonnull
+ private ClassicHttpResponse requestResource( @Nonnull final Function requestCreator )
+ {
+ final HttpUriRequestBase httpRequest = requestCreator.apply(getUri());
+
+ odataRequest.getHeaders().forEach(( k, values ) -> values.forEach(v -> httpRequest.addHeader(k, v)));
+
+ // add optional request body
+ if( httpRequest instanceof HttpEntityContainer ) {
+ if( requestBody != null ) {
+ ((HttpEntityContainer) httpRequest).setEntity(requestBody);
+ } else {
+ log.warn("The HTTP request {} was expecting an entity, but none was provided.", httpRequest);
+ }
+ }
+
+ odataRequest.getListeners().forEach(v -> v.listenOnRequest(httpRequest));
+
+ try {
+ return httpClient.executeOpen(null, httpRequest, null);
+ }
+ catch( final ClientProtocolException e ) {
+ log.debug("Connection could not be established.", e);
+ throw new ODataConnectionException(
+ this.odataRequest,
+ httpRequest,
+ "Connection could not be established.",
+ e);
+ }
+ catch( final ConnectionRequestTimeoutException e ) {
+ log.debug("Connection pool timed out.", e);
+ throw new ODataConnectionException(this.odataRequest, httpRequest, """
+ Time out occurred because of a probable connection leak. Please execute your request \
+ with try-with-resources to ensure resources are properly closed. \
+ If you are using the OData client instead to execute your request, explicitly consume \
+ the entity of the associated ClassicHttpResponse using EntityUtils.consume(httpEntity)\
+ """, e);
+
+ }
+ catch( final IOException e ) {
+ log.debug("Connection was aborted.", e);
+ throw new ODataConnectionException(this.odataRequest, httpRequest, "Connection was aborted.", e);
+ }
+ catch( final Exception e ) {
+ log.debug("Connection failed.", e);
+ throw new ODataConnectionException(this.odataRequest, httpRequest, "Connection failed.", e);
+ }
+ }
+
+ /**
+ * Perform a GET request.
+ *
+ * @return The HTTP response.
+ * @throws ODataRequestException
+ * When the request URI could not constructed.
+ * @throws ODataConnectionException
+ * When an error occurred while handling the HTTP connection.
+ */
+ @Nonnull
+ ClassicHttpResponse requestGet()
+ {
+ return requestResource(HttpGet::new);
+ }
+
+ /**
+ * Perform a POST request.
+ *
+ * @return The HTTP response.
+ * @throws ODataRequestException
+ * When the request URI could not constructed.
+ * @throws ODataConnectionException
+ * When an error occurred while handling the HTTP connection.
+ */
+ @Nonnull
+ ClassicHttpResponse requestPost()
+ {
+ return requestResource(HttpPost::new);
+ }
+
+ /**
+ * Perform a PATCH request.
+ *
+ * @return The HTTP response.
+ * @throws ODataRequestException
+ * When the request URI could not constructed.
+ * @throws ODataConnectionException
+ * When an error occurred while handling the HTTP connection.
+ */
+ @Nonnull
+ ClassicHttpResponse requestPatch()
+ {
+ return requestResource(HttpPatch::new);
+ }
+
+ /**
+ * Perform a PUT request.
+ *
+ * @return The HTTP response.
+ * @throws ODataRequestException
+ * When the request URI could not constructed.
+ * @throws ODataConnectionException
+ * When an error occurred while handling the HTTP connection.
+ */
+ @Nonnull
+ ClassicHttpResponse requestPut()
+ {
+ return requestResource(HttpPut::new);
+ }
+
+ /**
+ * Perform a DELETE request.
+ *
+ * @return The HTTP response.
+ * @throws ODataRequestException
+ * When the request URI could not constructed.
+ * @throws ODataConnectionException
+ * When an error occurred while handling the HTTP connection.
+ */
+ @Nonnull
+ ClassicHttpResponse requestDelete()
+ {
+ return requestResource(HttpDelete::new);
+ }
+
+ /**
+ * Constructs an URI for the given request.
+ *
+ * @return The URI
+ */
+ private URI getUri()
+ {
+ return odataRequest.getRelativeUri();
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestAction.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestAction.java
new file mode 100644
index 000000000..6d763d0b9
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestAction.java
@@ -0,0 +1,130 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.net.URI;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.core5.http.HttpHeaders;
+
+import com.google.common.collect.Lists;
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath;
+
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+/**
+ * The executable OData action request.
+ */
+@Getter
+@EqualsAndHashCode( callSuper = true )
+public class ODataRequestAction extends ODataRequestGeneric
+{
+ @Nonnull
+ private final String actionParameters;
+
+ @Nonnull
+ private final String query;
+
+ /**
+ * Convenience constructor for invocations of unbound actions. For bound actions use
+ * {@link #ODataRequestAction(String, ODataResourcePath, String, ODataProtocol)}.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param actionName
+ * The action name.
+ * @param actionParameters
+ * Optional: The action parameters HTTP payload.
+ * @param protocol
+ * The OData protocol to use.
+ */
+ public ODataRequestAction(
+ @Nonnull final String servicePath,
+ @Nonnull final String actionName,
+ @Nullable final String actionParameters,
+ @Nonnull final ODataProtocol protocol )
+ {
+ this(servicePath, ODataResourcePath.of(actionName), actionParameters, protocol);
+ }
+
+ /**
+ * Default constructor for OData Action request.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param actionPath
+ * The {@link ODataResourcePath path} identifying the action. In case of an unbound
+ * action this is simply the action name. If this is a bound action the path must also
+ * contain the full path to the action.
+ * @param actionParameters
+ * Optional: The action parameters as HTTP payload. This is expected to be a JSON formatted String.
+ * @param protocol
+ * The OData protocol to use.
+ */
+ public ODataRequestAction(
+ @Nonnull final String servicePath,
+ @Nonnull final ODataResourcePath actionPath,
+ @Nullable final String actionParameters,
+ @Nonnull final ODataProtocol protocol )
+ {
+ this(servicePath, actionPath, actionParameters, null, protocol);
+ }
+
+ /**
+ * Constructor with StructuredQuery for OData Function request.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param actionPath
+ * The full {@link ODataResourcePath} containing the action name, its parameters and possible further
+ * path segments. If this is a bound action the path must also contain the full path to
+ * the action.
+ * @param actionParameters
+ * Optional: The action parameters as HTTP payload. This is expected to be a JSON formatted String.
+ * @param encodedQuery
+ * Optional: An encodedQuery HTTP request query.
+ * @param protocol
+ * The OData protocol to use.
+ */
+ public ODataRequestAction(
+ @Nonnull final String servicePath,
+ @Nonnull final ODataResourcePath actionPath,
+ @Nullable final String actionParameters,
+ @Nullable final String encodedQuery,
+ @Nonnull final ODataProtocol protocol )
+ {
+ super(servicePath, actionPath, protocol);
+ this.actionParameters = actionParameters != null ? actionParameters : "{}";
+ this.query = encodedQuery != null ? encodedQuery : "";
+ headers.putIfAbsent(HttpHeaders.CONTENT_TYPE, Lists.newArrayList("application/json"));
+ }
+
+ @Nonnull
+ @Override
+ public URI getRelativeUri( @Nonnull final UriEncodingStrategy strategy )
+ {
+ return ODataUriFactory.createAndEncodeUri(getServicePath(), getResourcePath(), getRequestQuery(), strategy);
+ }
+
+ @Override
+ @Nonnull
+ public String getRequestQuery()
+ {
+ final String genericQueryString = super.getRequestQuery();
+ if( !genericQueryString.isEmpty() && !query.isEmpty() ) {
+ return query + "&" + genericQueryString;
+ }
+ return query + genericQueryString;
+ }
+
+ @Override
+ @Nonnull
+ public ODataRequestResultGeneric execute( @Nonnull final HttpClient httpClient )
+ {
+ final ODataHttpRequest request = ODataHttpRequest.forBodyJson(this, httpClient, actionParameters);
+ return tryExecuteWithCsrfToken(httpClient, request::requestPost).get();
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestBatch.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestBatch.java
new file mode 100644
index 000000000..d1d9e12f5
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestBatch.java
@@ -0,0 +1,489 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import static lombok.AccessLevel.PRIVATE;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.OptionalInt;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+import java.util.stream.IntStream;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.core5.http.HttpHeaders;
+
+import com.google.common.collect.ImmutableMap;
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataRequestException;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath;
+
+import io.vavr.Tuple;
+import io.vavr.Tuple2;
+import io.vavr.control.Try;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * The OData Batch request.
+ */
+@Getter
+@EqualsAndHashCode( callSuper = true )
+@Slf4j
+public class ODataRequestBatch extends ODataRequestGeneric
+{
+ private static final String DEFAULT_ODATA_BATCH_FORMAT_NEWLINE = "\r\n";
+
+ @Nonnull
+ private final List requests = new ArrayList<>();
+
+ private final AtomicInteger contentId = new AtomicInteger(1);
+
+ private final UUID batchUuid;
+
+ private final Supplier uuidProvider;
+
+ /**
+ * Default constructor for OData Batch request.
+ *
+ * The service path will be URL-encoded during request serialization.
+ *
+ * @param servicePath
+ * The unencoded OData service path
+ * @param protocol
+ * The OData protocol
+ */
+ public ODataRequestBatch( @Nonnull final String servicePath, @Nonnull final ODataProtocol protocol )
+ {
+ this(servicePath, protocol, UUID::randomUUID);
+ }
+
+ /**
+ * Default constructor for OData Batch request.
+ *
+ * The service path will be URL-encoded during request serialization.
+ *
+ * @param servicePath
+ * The unencoded OData service path
+ * @param protocol
+ * The OData protocol
+ * @param uuidProvider
+ * A generic UUID provider, customizable for testing
+ */
+ public ODataRequestBatch(
+ @Nonnull final String servicePath,
+ @Nonnull final ODataProtocol protocol,
+ @Nonnull final Supplier uuidProvider )
+ {
+ super(servicePath, ODataResourcePath.of("$batch"), protocol);
+ this.uuidProvider = uuidProvider;
+ this.batchUuid = uuidProvider.get();
+ this.headers.remove(HttpHeaders.ACCEPT); // batch request does not require Accept header
+ this.requestResultFactory = ODataRequestResultFactory.WITHOUT_BUFFER;
+ }
+
+ @Nonnull
+ @Override
+ public URI getRelativeUri( @Nonnull final UriEncodingStrategy strategy )
+ {
+ return ODataUriFactory.createAndEncodeUri(getServicePath(), getResourcePath(), getRequestQuery(), strategy);
+ }
+
+ /**
+ * Add an OData Read request to the current OData Batch request.
+ *
+ * @param request
+ * The Read request.
+ * @return The Batch request fluent helper instance.
+ */
+ @Nonnull
+ public ODataRequestBatch addRead( @Nonnull final ODataRequestRead request )
+ {
+ final BatchItem item = new BatchItemSingle(this, request, "GET", null);
+ requests.add(item);
+ return this;
+ }
+
+ /**
+ * Add an OData Read-By-Key request to the current OData Batch request.
+ *
+ * @param request
+ * The Read-By-Key request.
+ * @return The Batch request fluent helper instance.
+ */
+ @Nonnull
+ public ODataRequestBatch addReadByKey( @Nonnull final ODataRequestReadByKey request )
+ {
+ final BatchItem item = new BatchItemSingle(this, request, "GET", null);
+ requests.add(item);
+ return this;
+ }
+
+ /**
+ * Add an OData Function request to the current OData Batch request.
+ *
+ * @param request
+ * The Function request.
+ * @return The Batch request fluent helper instance.
+ */
+ @Nonnull
+ public ODataRequestBatch addFunction( @Nonnull final ODataRequestFunction request )
+ {
+ final BatchItem item = new BatchItemSingle(this, request, "GET", null);
+ requests.add(item);
+ return this;
+ }
+
+ /**
+ * Instantiate a new changeset to the current OData Batch request. As per specification if any data modifying
+ * operation fails within one changeset, then the incomplete changes will be reverted.
+ *
+ * @return A new Changeset fluent helper instance.
+ */
+ @Nonnull
+ public Changeset beginChangeset()
+ {
+ return new Changeset(this);
+ }
+
+ @Override
+ @Nonnull
+ public ODataRequestResultMultipartGeneric execute( @Nonnull final HttpClient httpClient )
+ {
+ return tryExecute(httpClient).get();
+ }
+
+ private Try tryExecute( @Nonnull final HttpClient httpClient )
+ {
+ final String requestBody = getBatchRequestBody();
+ final ODataHttpRequest request = ODataHttpRequest.forBodyText(this, httpClient, requestBody);
+
+ return Try
+ .of(request::requestPost)
+ .map(response -> new ODataRequestResultMultipartGeneric(this, response))
+ .andThenTry(ODataHealthyResponseValidator::requireHealthyResponse);
+ }
+
+ @Override
+ @Nonnull
+ public Map> getHeaders()
+ {
+ final Map> headers = super.getHeaders();
+
+ // replace select existing headers with custom batch headers
+ for( final Map.Entry batchHeader : getBatchHeaders().entrySet() ) {
+ headers.compute(batchHeader.getKey(), ( k, v ) -> new ArrayList<>(1)).add(batchHeader.getValue());
+ }
+
+ return headers;
+ }
+
+ @Nonnull
+ private Map getBatchHeaders()
+ {
+ return ImmutableMap
+ .of(
+ "Content-Type",
+ "multipart/mixed;boundary=batch_" + batchUuid,
+ "OData-Version",
+ getProtocol().getProtocolVersion());
+ }
+
+ @Nonnull
+ String getBatchRequestBody()
+ {
+ final String batchDelimiter = "--batch_" + batchUuid;
+ final String batchDelimiterEnd = batchDelimiter + "--";
+
+ final List resultLines = new ArrayList<>();
+ for( final BatchItem item : requests ) {
+ resultLines.add(batchDelimiter);
+ resultLines.addAll(item.getLines());
+ }
+
+ // closing delimiter
+ resultLines.add(batchDelimiterEnd);
+ resultLines.add("");
+ return String.join(DEFAULT_ODATA_BATCH_FORMAT_NEWLINE, resultLines);
+ }
+
+ /**
+ * The Changeset representation of the OData Batch operation.
+ */
+ @RequiredArgsConstructor( access = PRIVATE )
+ public static final class Changeset
+ {
+ private final ODataRequestBatch originalRequest;
+ private final List queries = new ArrayList<>();
+
+ /**
+ * Add an OData Create request to the current OData Batch changeset.
+ *
+ * @param request
+ * The Create request.
+ * @return The Changeset fluent helper instance.
+ */
+ @Nonnull
+ public Changeset addCreate( @Nonnull final ODataRequestCreate request )
+ {
+ final BatchItemSingle item =
+ new BatchItemSingle(originalRequest, request, "POST", request::getSerializedEntity);
+ queries.add(item);
+ return this;
+ }
+
+ /**
+ * Add an OData Update request to the current OData Batch changeset.
+ *
+ * @param request
+ * The Update request.
+ * @return The Changeset fluent helper instance.
+ */
+ @Nonnull
+ public Changeset addUpdate( @Nonnull final ODataRequestUpdate request )
+ {
+ final String versionIdentifier = request.getVersionIdentifier();
+ request.addVersionIdentifierToHeaderIfPresent(versionIdentifier);
+
+ final String httpMethod;
+ switch( request.getUpdateStrategy() ) {
+ case MODIFY_WITH_PATCH, MODIFY_WITH_PATCH_RECURSIVE_DELTA, MODIFY_WITH_PATCH_RECURSIVE_FULL:
+ httpMethod = "PATCH";
+ break;
+ case REPLACE_WITH_PUT:
+ httpMethod = "PUT";
+ break;
+ default:
+ throw new IllegalStateException("Unexpected update strategy: " + request.getUpdateStrategy());
+ }
+
+ final BatchItemSingle item =
+ new BatchItemSingle(originalRequest, request, httpMethod, request::getSerializedEntity);
+ queries.add(item);
+ return this;
+ }
+
+ /**
+ * Add an OData Delete request to the current OData Batch changeset.
+ *
+ * @param request
+ * The Delete request.
+ * @return The Changeset fluent helper instance.
+ */
+ @Nonnull
+ public Changeset addDelete( @Nonnull final ODataRequestDelete request )
+ {
+ final String versionIdentifier = request.getVersionIdentifier();
+ request.addVersionIdentifierToHeaderIfPresent(versionIdentifier);
+ final BatchItemSingle item = new BatchItemSingle(originalRequest, request, "DELETE", null);
+ queries.add(item);
+ return this;
+ }
+
+ /**
+ * Add an OData Action request to the current OData Batch changeset.
+ *
+ * @param request
+ * The Action request.
+ * @return The Changeset fluent helper instance.
+ */
+ @Nonnull
+ public Changeset addAction( @Nonnull final ODataRequestAction request )
+ {
+ final BatchItemSingle item =
+ new BatchItemSingle(originalRequest, request, "POST", request::getActionParameters);
+ queries.add(item);
+ return this;
+ }
+
+ /**
+ * Finalizes the current changeset.
+ *
+ * @return The original Batch request fluent helper instance.
+ */
+ @Nonnull
+ public ODataRequestBatch endChangeset()
+ {
+ final UUID changeSetId = originalRequest.uuidProvider.get();
+ final BatchItem item = new BatchItemChangeset(changeSetId, queries);
+ originalRequest.requests.add(item);
+
+ return originalRequest;
+ }
+ }
+
+ @Getter
+ static final class BatchItemSingle implements BatchItem
+ {
+ private final int contentId;
+ @Nonnull
+ final ODataRequestGeneric request;
+ @Nonnull
+ private final String resourcePath;
+ @Nonnull
+ private final String httpMethod;
+ @Nullable
+ private final Supplier payload;
+
+ private BatchItemSingle(
+ @Nonnull final ODataRequestBatch requestBatch,
+ @Nonnull final ODataRequestGeneric requestSingle,
+ @Nonnull final String httpMethod,
+ @Nullable final Supplier payload )
+ {
+ final String encodedRelativeUriSingleRequest =
+ requestSingle.getRelativeUri(UriEncodingStrategy.BATCH).toString();
+ final String encodedServicePathBatchRequest = requestBatch.getEncodedServicePath(UriEncodingStrategy.BATCH);
+
+ assertSingleAndBatchRequestAreConsistent(
+ requestBatch,
+ requestSingle,
+ encodedRelativeUriSingleRequest,
+ encodedServicePathBatchRequest);
+
+ this.contentId = requestBatch.contentId.getAndIncrement();
+ this.request = requestSingle;
+ this.resourcePath = removeStart(encodedRelativeUriSingleRequest, encodedServicePathBatchRequest);
+ this.httpMethod = httpMethod;
+ this.payload = payload;
+ }
+
+ @Nonnull
+ private String removeStart( @Nonnull final String string, @Nonnull final String prefix )
+ {
+ return string.startsWith(prefix) ? string.substring(prefix.length()) : string;
+ }
+
+ private void assertSingleAndBatchRequestAreConsistent(
+ final ODataRequestBatch requestBatch,
+ final ODataRequestGeneric requestSingle,
+ final String encodedRelativeUriSingleRequest,
+ final String encodedServicePathBatchRequest )
+ {
+ if( !encodedRelativeUriSingleRequest.startsWith(encodedServicePathBatchRequest) ) {
+ throw new ODataRequestException(
+ requestBatch,
+ "Batch request contains requests to different service paths (batch request: "
+ + encodedServicePathBatchRequest
+ + ", single request: "
+ + encodedRelativeUriSingleRequest,
+ null);
+ }
+ if( !Objects.equals(requestSingle.getProtocol(), requestBatch.getProtocol()) ) {
+ throw new ODataRequestException(
+ requestBatch,
+ "Batch request contains requests with different protocol versions,",
+ null);
+ }
+ }
+
+ @Nonnull
+ @Override
+ public List getLines()
+ {
+ final List lines = new ArrayList<>();
+ lines.add("Content-Type: application/http");
+ lines.add("Content-Transfer-Encoding: binary");
+ lines.add("Content-ID: " + contentId);
+
+ lines.add("");
+ lines.add(String.format("%s %s HTTP/1.1", httpMethod, resourcePath));
+ request.getHeaders().forEach(( k, values ) -> values.forEach(v -> lines.add(k + ": " + v)));
+ lines.add("");
+
+ if( payload != null ) {
+ lines.add(payload.get());
+ }
+ lines.add("");
+ return lines;
+ }
+ }
+
+ @RequiredArgsConstructor( access = PRIVATE )
+ @Getter
+ static final class BatchItemChangeset implements BatchItem
+ {
+ @Nonnull
+ final UUID changeSetId;
+ @Nonnull
+ final List requests;
+
+ @Nonnull
+ @Override
+ public List getLines()
+ {
+ final String changesetDelimiter = "--changeset_" + changeSetId;
+ final String changesetDelimiterEnd = changesetDelimiter + "--";
+
+ final List lines = new ArrayList<>();
+ lines.add("Content-Type: multipart/mixed;boundary=changeset_" + changeSetId);
+ lines.add("");
+
+ for( final BatchItem request : requests ) {
+ lines.add(changesetDelimiter);
+ lines.addAll(request.getLines());
+ }
+ lines.add(changesetDelimiterEnd);
+ lines.add("");
+ return lines;
+ }
+ }
+
+ interface BatchItem
+ {
+ @Nonnull
+ List getLines();
+ }
+
+ /**
+ * Gets the position for a single request inside a batch request. This is important for mapping the individual
+ * responses later.
+ *
+ * @param batchRequest
+ * The batch request.
+ * @param singleRequest
+ * The request item that was batched.
+ * @return {@code null} if the position cannot be found. {@code Tuple2} if the single request is not
+ * part of a changeset. {@code Tuple2} if the single request is part of a changeset.
+ */
+ @Nullable
+ static Tuple2 getBatchItemPosition(
+ @Nonnull final ODataRequestBatch batchRequest,
+ @Nonnull final ODataRequestGeneric singleRequest )
+ {
+ final List batchItems = batchRequest.getRequests();
+
+ for( int i = 0; i < batchItems.size(); i++ ) {
+ final BatchItem item = batchItems.get(i);
+ if( item instanceof BatchItemChangeset ) {
+ final List nestedRequests = ((BatchItemChangeset) item).getRequests();
+ final OptionalInt pos =
+ IntStream
+ .range(0, nestedRequests.size())
+ .filter(p -> singleRequest == nestedRequests.get(p).getRequest())
+ .findFirst();
+ if( pos.isPresent() ) {
+ return Tuple.of(i, pos.getAsInt());
+ }
+ } else if( item instanceof BatchItemSingle && singleRequest == ((BatchItemSingle) item).getRequest() ) {
+ return Tuple.of(i, null);
+ }
+ }
+ return null;
+ }
+
+ @Nonnull
+ String getEncodedServicePath( @Nonnull final UriEncodingStrategy encodingStrategy )
+ {
+ return ODataUriFactory.createAndEncodeUri(servicePath, "", null, encodingStrategy).toString();
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestCount.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestCount.java
new file mode 100644
index 000000000..d30668c0b
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestCount.java
@@ -0,0 +1,81 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath;
+import com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery;
+
+import lombok.EqualsAndHashCode;
+
+/**
+ * The result type of the OData Count request.
+ */
+@EqualsAndHashCode( callSuper = true )
+public class ODataRequestCount extends ODataRequestRead
+{
+ /**
+ * Default constructor for OData Count request.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param entityName
+ * The OData entity name.
+ * @param encodedQuery
+ * Optional: The encoded HTTP query, if any.
+ * @param protocol
+ * The OData protocol to use.
+ */
+ public ODataRequestCount(
+ @Nonnull final String servicePath,
+ @Nonnull final String entityName,
+ @Nullable final String encodedQuery,
+ @Nonnull final ODataProtocol protocol )
+ {
+ this(servicePath, ODataResourcePath.of(entityName), encodedQuery, protocol);
+ }
+
+ /**
+ * Default constructor for OData Count request.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param resourcePath
+ * The {@link ODataResourcePath} that identifies the collection to be counted.
+ * @param encodedQuery
+ * Optional: The encoded HTTP query, if any.
+ * @param protocol
+ * The OData protocol to use.
+ */
+ public ODataRequestCount(
+ @Nonnull final String servicePath,
+ @Nonnull final ODataResourcePath resourcePath,
+ @Nullable final String encodedQuery,
+ @Nonnull final ODataProtocol protocol )
+ {
+ super(servicePath, resourcePath.addSegment("$count"), encodedQuery, protocol);
+ }
+
+ /**
+ * Constructor with StructuredQuery for OData Count request.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param resourcePath
+ * The {@link ODataResourcePath} that identifies the collection to be counted.
+ * @param query
+ * The structured query.
+ */
+ public ODataRequestCount(
+ @Nonnull final String servicePath,
+ @Nonnull final ODataResourcePath resourcePath,
+ @Nonnull final StructuredQuery query )
+ {
+ this(
+ servicePath,
+ resourcePath.addSegment(query.getEntityOrPropertyName()),
+ query.getEncodedQueryString(),
+ query.getProtocol());
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestCreate.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestCreate.java
new file mode 100644
index 000000000..e6ce7c741
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestCreate.java
@@ -0,0 +1,87 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.net.URI;
+
+import javax.annotation.Nonnull;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.core5.http.HttpHeaders;
+
+import com.google.common.collect.Lists;
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath;
+
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+/**
+ * The executable OData create request.
+ */
+@Getter
+@EqualsAndHashCode( callSuper = true )
+public class ODataRequestCreate extends ODataRequestGeneric
+{
+ @Nonnull
+ private final String serializedEntity;
+
+ /**
+ * Convenience constructor for OData delete requests on entity collections directly. For operations on nested
+ * entities use {@link #ODataRequestCreate(String, ODataResourcePath, String, ODataProtocol)}.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param entityName
+ * The OData entity name.
+ * @param serializedEntity
+ * The serialized query payload.
+ * @param protocol
+ * The OData protocol to use.
+ */
+ public ODataRequestCreate(
+ @Nonnull final String servicePath,
+ @Nonnull final String entityName,
+ @Nonnull final String serializedEntity,
+ @Nonnull final ODataProtocol protocol )
+ {
+ this(servicePath, ODataResourcePath.of(entityName), serializedEntity, protocol);
+ }
+
+ /**
+ * Default constructor for OData Create request.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param entityPath
+ * The {@link ODataResourcePath path} to the OData entity.
+ * @param serializedEntity
+ * The serialized query payload.
+ * @param protocol
+ * The OData protocol to use.
+ */
+ public ODataRequestCreate(
+ @Nonnull final String servicePath,
+ @Nonnull final ODataResourcePath entityPath,
+ @Nonnull final String serializedEntity,
+ @Nonnull final ODataProtocol protocol )
+ {
+ super(servicePath, entityPath, protocol);
+ this.serializedEntity = serializedEntity;
+ headers.putIfAbsent(HttpHeaders.CONTENT_TYPE, Lists.newArrayList("application/json"));
+ }
+
+ @Nonnull
+ @Override
+ public URI getRelativeUri( @Nonnull final UriEncodingStrategy strategy )
+ {
+ return ODataUriFactory.createAndEncodeUri(getServicePath(), getResourcePath(), getRequestQuery(), strategy);
+ }
+
+ @Nonnull
+ @Override
+ public ODataRequestResultGeneric execute( @Nonnull final HttpClient httpClient )
+ {
+ final ODataHttpRequest request = ODataHttpRequest.forBodyJson(this, httpClient, serializedEntity);
+
+ return tryExecuteWithCsrfToken(httpClient, request::requestPost).get();
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestDelete.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestDelete.java
new file mode 100644
index 000000000..73d058891
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestDelete.java
@@ -0,0 +1,92 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.net.URI;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath;
+
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * The executable OData delete request.
+ */
+@Getter
+@EqualsAndHashCode( callSuper = true )
+@Slf4j
+public class ODataRequestDelete extends ODataRequestGeneric
+{
+ @Nullable
+ private final String versionIdentifier;
+
+ /**
+ * Convenience constructor for OData delete requests on entity collections directly. For operations on nested
+ * entities use {@link #ODataRequestDelete(String, String, ODataEntityKey, String, ODataProtocol)}.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param entityName
+ * The name of the entity to delete.
+ * @param entityKey
+ * The {@link ODataEntityKey entity key} that identifies the entity to delete.
+ * @param versionIdentifier
+ * The version identifier.
+ * @param protocol
+ * The OData protocol to use.
+ */
+ public ODataRequestDelete(
+ @Nonnull final String servicePath,
+ @Nonnull final String entityName,
+ @Nonnull final ODataEntityKey entityKey,
+ @Nullable final String versionIdentifier,
+ @Nonnull final ODataProtocol protocol )
+ {
+ this(servicePath, ODataResourcePath.of(entityName, entityKey), versionIdentifier, protocol);
+ }
+
+ /**
+ * Default constructor for OData delete requests.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param entityPath
+ * The {@link ODataResourcePath} that identifies the entity to delete.
+ * @param versionIdentifier
+ * The version identifier.
+ * @param protocol
+ * The OData protocol to use.
+ */
+ public ODataRequestDelete(
+ @Nonnull final String servicePath,
+ @Nonnull final ODataResourcePath entityPath,
+ @Nullable final String versionIdentifier,
+ @Nonnull final ODataProtocol protocol )
+ {
+ super(servicePath, entityPath, protocol);
+ this.versionIdentifier = versionIdentifier;
+ }
+
+ @Nonnull
+ @Override
+ public URI getRelativeUri( @Nonnull final UriEncodingStrategy strategy )
+ {
+ return ODataUriFactory.createAndEncodeUri(getServicePath(), getResourcePath(), getRequestQuery(), strategy);
+ }
+
+ @Nonnull
+ @Override
+ public ODataRequestResultGeneric execute( @Nonnull final HttpClient httpClient )
+ {
+ final ODataHttpRequest request = ODataHttpRequest.withoutBody(this, httpClient);
+
+ addVersionIdentifierToHeaderIfPresent(versionIdentifier);
+
+ return tryExecuteWithCsrfToken(httpClient, request::requestDelete).get();
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestExecutable.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestExecutable.java
new file mode 100644
index 000000000..8d89cdb7b
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestExecutable.java
@@ -0,0 +1,34 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import javax.annotation.Nonnull;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataConnectionException;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataRequestException;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataResponseException;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataServiceErrorException;
+
+/**
+ * General interface for executable OData Requests.
+ */
+public interface ODataRequestExecutable
+{
+ /**
+ * Execute the OData request with the provided HttpClient reference.
+ *
+ * @param httpClient
+ * The HttpClient.
+ * @return An OData request result.
+ * @throws ODataRequestException
+ * When the OData request could not be sent.
+ * @throws ODataConnectionException
+ * When the HTTP connection cannot be established.
+ * @throws ODataResponseException
+ * When the response code infers an unhealthy state, i.e. when >= 400
+ * @throws ODataServiceErrorException
+ * When the response contains an OData error message according to specification.
+ */
+ @Nonnull
+ ODataRequestResult execute( @Nonnull final HttpClient httpClient );
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestFunction.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestFunction.java
new file mode 100644
index 000000000..a13f0437f
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestFunction.java
@@ -0,0 +1,186 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.net.URI;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+
+import com.google.common.base.Strings;
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath;
+import com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery;
+
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+/**
+ * The executable OData function request.
+ */
+@Getter
+@EqualsAndHashCode( callSuper = true )
+public class ODataRequestFunction extends ODataRequestGeneric
+{
+ @Nonnull
+ private final String query;
+
+ /**
+ * Convenience constructor for invocations of unbound functions. The function parameters will be added to the URL
+ * path or URL query depending on the OData protocol verison. For composable or bound functions please use
+ * {@link #ODataRequestFunction(String, ODataResourcePath, String, ODataProtocol)} instead.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param functionName
+ * The name of the unbound OData function.
+ * @param parameters
+ * The parameters of the function invocation.
+ * @param protocol
+ * The OData protocol to use.
+ */
+ public ODataRequestFunction(
+ @Nonnull final String servicePath,
+ @Nonnull final String functionName,
+ @Nonnull final ODataFunctionParameters parameters,
+ @Nonnull final ODataProtocol protocol )
+ {
+ this(
+ servicePath,
+ protocol.isEqualTo(ODataProtocol.V2)
+ ? ODataResourcePath.of(functionName)
+ : ODataResourcePath.of(functionName, parameters),
+ protocol.isEqualTo(ODataProtocol.V2) ? parameters.toEncodedString() : null,
+ protocol);
+ }
+
+ /**
+ * Convenience constructor for invocations of unbound functions. The function parameters will be added to the URL
+ * path or URL query depending on the OData protocol verison. For composable or bound functions please use
+ * {@link #ODataRequestFunction(String, ODataResourcePath, String, ODataProtocol)} instead.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param functionPath
+ * The full {@link ODataResourcePath} containing the function name, its parameters and possible further
+ * path segments. If this is a bound function the path must also contain the full path
+ * to the function.
+ * @param parameters
+ * The parameters of the function invocation.
+ * @param query
+ * Optional: The encoded HTTP query, if any.
+ * @param protocol
+ * The OData protocol to use.
+ */
+ public ODataRequestFunction(
+ @Nonnull final String servicePath,
+ @Nonnull final ODataResourcePath functionPath,
+ @Nonnull final ODataFunctionParameters parameters,
+ @Nullable final String query,
+ @Nonnull final ODataProtocol protocol )
+ {
+ this(
+ servicePath,
+ appendResourcePathWithParameters(functionPath, parameters, protocol),
+ appendQueryWithParameters(query, parameters, protocol),
+ protocol);
+ }
+
+ /**
+ * Default constructor for OData Function request.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param functionPath
+ * The full {@link ODataResourcePath} containing the function name, its parameters and possible further
+ * path segments. If this is a bound function the path must also contain the full path
+ * to the function.
+ * @param encodedQuery
+ * Optional: The encoded HTTP query, if any.
+ * @param protocol
+ * The OData protocol to use.
+ */
+ public ODataRequestFunction(
+ @Nonnull final String servicePath,
+ @Nonnull final ODataResourcePath functionPath,
+ @Nullable final String encodedQuery,
+ @Nonnull final ODataProtocol protocol )
+ {
+ super(servicePath, functionPath, protocol);
+ this.query = encodedQuery != null ? encodedQuery : "";
+ }
+
+ /**
+ * Constructor with StructuredQuery for OData Function request.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param functionPath
+ * The full {@link ODataResourcePath} containing the function name, its parameters and possible further
+ * path segments. If this is a bound function the path must also contain the full path
+ * to the function.
+ * @param structQuery
+ * The structured query.
+ */
+ public ODataRequestFunction(
+ @Nonnull final String servicePath,
+ @Nonnull final ODataResourcePath functionPath,
+ @Nonnull final StructuredQuery structQuery )
+ {
+ super(servicePath, functionPath.addSegment(structQuery.getEntityOrPropertyName()), structQuery.getProtocol());
+ this.query = structQuery.getEncodedQueryString();
+ }
+
+ @Nonnull
+ @Override
+ public URI getRelativeUri( @Nonnull final UriEncodingStrategy strategy )
+ {
+ return ODataUriFactory.createAndEncodeUri(getServicePath(), getResourcePath(), getRequestQuery(), strategy);
+ }
+
+ @Override
+ @Nonnull
+ public ODataRequestResultGeneric execute( @Nonnull final HttpClient httpClient )
+ {
+ final ODataHttpRequest request = ODataHttpRequest.withoutBody(this, httpClient);
+ return tryExecute(request::requestGet, httpClient).get();
+ }
+
+ @Override
+ @Nonnull
+ public String getRequestQuery()
+ {
+ final String genericQueryString = super.getRequestQuery();
+ if( !genericQueryString.isEmpty() && !query.isEmpty() ) {
+ return query + "&" + genericQueryString;
+ }
+ return query + genericQueryString;
+ }
+
+ @Nonnull
+ private static ODataResourcePath appendResourcePathWithParameters(
+ @Nonnull final ODataResourcePath path,
+ @Nonnull final ODataFunctionParameters parameters,
+ @Nonnull final ODataProtocol protocol )
+ {
+ if( protocol.isEqualTo(ODataProtocol.V2) ) {
+ return path;
+ }
+ final ODataResourcePath appendedPath = new ODataResourcePath();
+ path.getSegments().forEach(s -> appendedPath.addSegment(s._1, s._2));
+ return appendedPath.addParameterToLastSegment(parameters);
+ }
+
+ @Nullable
+ private static String appendQueryWithParameters(
+ @Nullable final String query,
+ @Nonnull final ODataFunctionParameters parameters,
+ @Nonnull final ODataProtocol protocol )
+ {
+ if( protocol.isEqualTo(ODataProtocol.V4) ) {
+ return query;
+ }
+ final String encodedParams = parameters.toEncodedString();
+ return Strings.isNullOrEmpty(query) ? encodedParams : encodedParams + "&" + query;
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestGeneric.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestGeneric.java
new file mode 100644
index 000000000..7846132ac
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestGeneric.java
@@ -0,0 +1,261 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.function.Supplier;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HttpHeaders;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.Lists;
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataException;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath;
+
+import io.vavr.control.Try;
+import lombok.AccessLevel;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Generic OData request class to provide default features for service requests.
+ */
+@EqualsAndHashCode
+@Slf4j
+public abstract class ODataRequestGeneric implements ODataRequestExecutable
+{
+ /**
+ * Default {@link ODataFormat} that will be used if none is specified.
+ */
+ private static final ODataFormat DEFAULT_FORMAT = ODataFormat.JSON;
+
+ /**
+ * The service path of the targeted OData service. E.g. {@code sap/opu/odata/sap/API_BUSINESS_PARTNER}
+ */
+ @Getter( AccessLevel.PUBLIC )
+ @Nonnull
+ protected final String servicePath;
+
+ /**
+ * The {@link ODataResourcePath} that identifies the OData resource to operate on. E.g.
+ * {@code /BusinessPartner('123')/BusinessPartnerAddress(456)}.
+ */
+ @Getter( AccessLevel.PROTECTED )
+ @Nonnull
+ protected final ODataResourcePath resourcePath;
+
+ /**
+ * The OData protocol version of this request.
+ */
+ @Getter
+ private final ODataProtocol protocol;
+
+ /**
+ * List of listeners to observe and react on OData actions.
+ */
+ @Getter( AccessLevel.PROTECTED )
+ private final List listeners = new ArrayList<>();
+
+ /**
+ * Map of HTTP header key-values which are added to the OData request.
+ */
+ final Map> headers = new TreeMap<>();
+
+ /**
+ * Map of additional generic HTTP query parameters.
+ */
+ @Getter( AccessLevel.PROTECTED )
+ private final Map queryParameters = new TreeMap<>();
+
+ /**
+ * The response buffer strategy to use for this request.
+ */
+ @Nonnull
+ ODataRequestResultFactory requestResultFactory = ODataRequestResultFactory.WITH_BUFFER;
+
+ ODataRequestGeneric(
+ @Nonnull final String servicePath,
+ @Nonnull final ODataResourcePath resourcePath,
+ @Nonnull final ODataProtocol protocol )
+ {
+ this.protocol = protocol;
+ this.servicePath = servicePath;
+ this.resourcePath = resourcePath;
+ headers.putIfAbsent(HttpHeaders.ACCEPT, Lists.newArrayList(DEFAULT_FORMAT.getHttpAccept()));
+ }
+
+ /**
+ * Get the static request URI of the OData resource.
+ *
+ * @param uriEncodingStrategy
+ * URI encoding strategy.
+ * @return The String representation of the request URI.
+ */
+ @Nonnull
+ public abstract URI getRelativeUri( @Nonnull final UriEncodingStrategy uriEncodingStrategy );
+
+ /**
+ * Get the static request URI of the OData resource.
+ *
+ * @return The String representation of the request URI.
+ */
+ @Nonnull
+ public URI getRelativeUri()
+ {
+ return getRelativeUri(UriEncodingStrategy.REGULAR);
+ }
+
+ /**
+ * Use all OData query information to construct a HTTP request query String.
+ *
+ * @return The request query.
+ */
+ @Nonnull
+ public String getRequestQuery()
+ {
+ return Joiner.on("&").withKeyValueSeparator("=").join(queryParameters);
+ }
+
+ /**
+ * Attach a listener to the request process.
+ *
+ * @param listener
+ * The listener to react on OData request actions.
+ */
+ public void addListener( @Nonnull final ODataRequestListener listener )
+ {
+ listeners.add(listener);
+ }
+
+ /**
+ * Replace a header in the OData HTTP request.
+ *
+ * @param key
+ * The header name.
+ * @param value
+ * The header value.
+ */
+ public void setHeader( @Nonnull final String key, @Nullable final String value )
+ {
+ final List values = new ArrayList<>(1);
+ values.add(value);
+ headers.put(key, values);
+ }
+
+ /**
+ * Replace a header with multiple values in the OData HTTP request.
+ *
+ * @param key
+ * The header name.
+ * @param values
+ * The header values.
+ * @since 4.27.0
+ */
+ public void setHeader( @Nonnull final String key, @Nonnull final Collection values )
+ {
+ headers.put(key, new ArrayList<>(values));
+ }
+
+ /**
+ * Add a header to the OData HTTP request.
+ *
+ * @param key
+ * The header name.
+ * @param value
+ * The header value.
+ */
+ public void addHeader( @Nonnull final String key, @Nullable final String value )
+ {
+ headers.computeIfAbsent(key, k -> new ArrayList<>(1)).add(value);
+ }
+
+ /**
+ * Add a header to the OData HTTP request, if it is not included already.
+ *
+ * @param key
+ * The header name.
+ * @param value
+ * The header value.
+ */
+ public void addHeaderIfAbsent( @Nonnull final String key, @Nullable final String value )
+ {
+ headers.putIfAbsent(key, Lists.newArrayList(value));
+ }
+
+ /**
+ * Add a query parameter to the HTTP request. The value must be encoded.
+ *
+ * @param key
+ * The parameter key.
+ * @param value
+ * The encoded parameters value.
+ */
+ public void addQueryParameter( @Nonnull final String key, @Nullable final String value )
+ {
+ queryParameters.put(key, value);
+ }
+
+ /**
+ * Internal execute method. It will perform the given httpOperation on the given request and ensure a healthy HTTP
+ * response code. Failures will always be a subtype of {@link ODataException}
+ *
+ * @param httpOperation
+ * The HTTP operation to perform, e.g. {@link ODataHttpRequest#requestGet()}
+ * @param httpClient
+ * The HTTP client instance that is being used to perform the operation.
+ * @return A {@code Try} containing either a successful {@link ODataRequestResultGeneric} or an
+ * {@code ODataException}.
+ */
+ @Nonnull
+ protected
+ Try
+ tryExecute( @Nonnull final Supplier httpOperation, @Nonnull final HttpClient httpClient )
+ {
+ return Try
+ .ofSupplier(httpOperation)
+ .map(response -> requestResultFactory.create(this, response, httpClient))
+ .andThenTry(ODataHealthyResponseValidator::requireHealthyResponse);
+ }
+
+ @Nonnull
+ protected Try tryExecuteWithCsrfToken(
+ @Nonnull final HttpClient httpClient,
+ @Nonnull final Supplier httpOperation )
+ {
+ return tryExecute(httpOperation, httpClient);
+ }
+
+ /**
+ * Get the list of headers that will be sent with this request. To add headers, please use
+ * {@link #addHeader(String, String) addHeader} and {@link #addHeaderIfAbsent(String, String) addHeaderIfAbsent}
+ *
+ * @return The list of headers.
+ */
+ @Nonnull
+ public Map> getHeaders()
+ {
+ return new TreeMap<>(headers);
+ }
+
+ void addVersionIdentifierToHeaderIfPresent( @Nullable final String versionIdentifier )
+ {
+ if( versionIdentifier != null ) {
+ addHeaderIfAbsent(HttpHeaders.IF_MATCH, versionIdentifier);
+ } else {
+ log
+ .debug(
+ "Version identifier for {} is either not defined on the entity or is explicitly ignored.",
+ getClass().getSimpleName());
+ }
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestListener.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestListener.java
new file mode 100644
index 000000000..1088a2244
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestListener.java
@@ -0,0 +1,35 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import javax.annotation.Nonnull;
+
+import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
+
+/**
+ * Consumer class for the Listener Pattern to monitor and react on OData actions.
+ */
+public interface ODataRequestListener
+{
+ /**
+ * Handler to react before execution of an HTTP request.
+ *
+ * @param request
+ * The HTTP request.
+ */
+ void listenOnRequest( @Nonnull final HttpUriRequestBase request );
+
+ /**
+ * Handler to react on an error during request generation.
+ *
+ * @param error
+ * The exception reference.
+ */
+ void listenOnRequestError( @Nonnull final Exception error );
+
+ /**
+ * Handler to react on an error during response parsing.
+ *
+ * @param error
+ * The exception reference.
+ */
+ void listenOnParsingError( @Nonnull final Exception error );
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestRead.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestRead.java
new file mode 100644
index 000000000..6f16f6347
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestRead.java
@@ -0,0 +1,144 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.net.URI;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+
+import com.google.common.annotations.Beta;
+import com.google.common.net.UrlEscapers;
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath;
+import com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery;
+
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+/**
+ * The result type of the OData Read request.
+ */
+@Getter
+@EqualsAndHashCode( callSuper = true )
+public class ODataRequestRead extends ODataRequestGeneric
+{
+ @Nonnull
+ private final String queryString;
+
+ /**
+ * Convenience constructor for OData read requests on entity collections directly. For operations on nested entity
+ * collections use {@link #ODataRequestRead(String, ODataResourcePath, String, ODataProtocol)}.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param entityName
+ * The OData entity name.
+ * @param encodedQuery
+ * Optional: The encoded HTTP query, if any.
+ * @param protocol
+ * The OData protocol to use.
+ */
+ public ODataRequestRead(
+ @Nonnull final String servicePath,
+ @Nonnull final String entityName,
+ @Nullable final String encodedQuery,
+ @Nonnull final ODataProtocol protocol )
+ {
+ this(servicePath, ODataResourcePath.of(entityName), encodedQuery, protocol);
+ }
+
+ /**
+ * Default constructor for OData Read request.
+ *
+ * Note: The query string {@link #queryString} must not contain characters that are forbidden in URLs, like spaces.
+ * If forbidden characters are present, an {@link IllegalArgumentException} is thrown.
+ *
+ *
+ * Build an instance of {@link StructuredQuery} and pass the value of
+ * {@link StructuredQuery#getEncodedQueryString()} as {@link #queryString} to this method.
+ *
+ *
+ * Alternatively, use {@link UrlEscapers#urlFragmentEscaper()} from the Guava library to escape the query string
+ * before passing it here.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param entityPath
+ * The {@link ODataResourcePath} that identifies the collection of entities or properties to read.
+ * @param encodedQuery
+ * Optional: The encoded HTTP query, if any.
+ * @param protocol
+ * The OData protocol to use.
+ *
+ */
+ public ODataRequestRead(
+ @Nonnull final String servicePath,
+ @Nonnull final ODataResourcePath entityPath,
+ @Nullable final String encodedQuery,
+ @Nonnull final ODataProtocol protocol )
+ {
+ super(servicePath, entityPath, protocol);
+ this.queryString = encodedQuery != null ? encodedQuery : "";
+ }
+
+ /**
+ * Constructor with StructuredQuery for OData read requests on entity collections directly. For operations on nested
+ * entity collections use {@link #ODataRequestRead(String, ODataResourcePath, String, ODataProtocol)}.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param entityPath
+ * The {@link ODataResourcePath} that identifies the collection of entities or properties to read.
+ * @param query
+ * The structured query.
+ */
+ public ODataRequestRead(
+ @Nonnull final String servicePath,
+ @Nonnull final ODataResourcePath entityPath,
+ @Nonnull final StructuredQuery query )
+ {
+ this(
+ servicePath,
+ entityPath.addSegment(query.getEntityOrPropertyName()),
+ query.getEncodedQueryString(),
+ query.getProtocol());
+ }
+
+ @Nonnull
+ @Override
+ public URI getRelativeUri( @Nonnull final UriEncodingStrategy strategy )
+ {
+ return ODataUriFactory.createAndEncodeUri(getServicePath(), getResourcePath(), getRequestQuery(), strategy);
+ }
+
+ @Override
+ @Nonnull
+ public String getRequestQuery()
+ {
+ final String genericQueryString = super.getRequestQuery();
+ if( !genericQueryString.isEmpty() && !queryString.isEmpty() ) {
+ return queryString + "&" + genericQueryString;
+ }
+ return queryString + genericQueryString;
+ }
+
+ @Override
+ @Nonnull
+ public ODataRequestResultGeneric execute( @Nonnull final HttpClient httpClient )
+ {
+ final ODataHttpRequest request = ODataHttpRequest.withoutBody(this, httpClient);
+ return tryExecute(request::requestGet, httpClient).get();
+ }
+
+ /**
+ * Disable pre-buffering of http response entity.
+ */
+ @Beta
+ @Nonnull
+ public ODataRequestResultResource.Executable withoutResponseBuffering()
+ {
+ requestResultFactory = ODataRequestResultFactory.WITHOUT_BUFFER;
+ return httpClient -> (ODataRequestResultResource) this.execute(httpClient);
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestReadByKey.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestReadByKey.java
new file mode 100644
index 000000000..af8302cea
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestReadByKey.java
@@ -0,0 +1,141 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.net.URI;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+
+import com.google.common.annotations.Beta;
+import com.sap.cloud.sdk.datamodel.odata.client.ODataProtocol;
+import com.sap.cloud.sdk.datamodel.odata.client.expression.ODataResourcePath;
+import com.sap.cloud.sdk.datamodel.odata.client.query.StructuredQuery;
+
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+/**
+ * The result type of the OData read by key request.
+ */
+@Getter
+@EqualsAndHashCode( callSuper = true )
+public class ODataRequestReadByKey extends ODataRequestGeneric
+{
+ @Nonnull
+ private final String queryString;
+
+ /**
+ * Convenience constructor for OData read requests on entity collections directly. For operations on nested entities
+ * use {@link #ODataRequestReadByKey(String, ODataResourcePath, String, ODataProtocol)}.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param entityName
+ * The OData entity name.
+ * @param entityKey
+ * The entity key.
+ * @param encodedQuery
+ * Optional: The encoded HTTP query, if any.
+ * @param protocol
+ * The OData protocol to use.
+ */
+ public ODataRequestReadByKey(
+ @Nonnull final String servicePath,
+ @Nonnull final String entityName,
+ @Nonnull final ODataEntityKey entityKey,
+ @Nullable final String encodedQuery,
+ @Nonnull final ODataProtocol protocol )
+ {
+ this(servicePath, ODataResourcePath.of(entityName, entityKey), encodedQuery, protocol);
+ }
+
+ /**
+ * Default constructor for OData Read requests.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param entityPath
+ * The {@link ODataResourcePath} that identifies the entity to read.
+ * @param encodedQuery
+ * Optional: The encoded HTTP query, if any.
+ * @param protocol
+ * The OData protocol to use.
+ *
+ */
+ public ODataRequestReadByKey(
+ @Nonnull final String servicePath,
+ @Nonnull final ODataResourcePath entityPath,
+ @Nullable final String encodedQuery,
+ @Nonnull final ODataProtocol protocol )
+ {
+ super(servicePath, entityPath, protocol);
+ this.queryString = encodedQuery != null ? encodedQuery : "";
+ }
+
+ /**
+ * Constructor with StructuredQuery for OData read requests on entity collections directly. For operations on nested
+ * entity collections use
+ * {@link ODataRequestRead#ODataRequestRead(String, ODataResourcePath, String, ODataProtocol)}.
+ *
+ * @param servicePath
+ * The OData service path.
+ * @param entityPath
+ * The {@link ODataResourcePath} that identifies the collection of entities or properties to read.
+ * @param entityKey
+ * The entity key.
+ * @param query
+ * The structured query.
+ */
+ public ODataRequestReadByKey(
+ @Nonnull final String servicePath,
+ @Nonnull final ODataResourcePath entityPath,
+ @Nonnull final ODataEntityKey entityKey,
+ @Nonnull final StructuredQuery query )
+ {
+ this(
+ servicePath,
+ entityPath.addParameterToLastSegment(entityKey),
+ query.getEncodedQueryString(),
+ query.getProtocol());
+ }
+
+ @Nonnull
+ @Override
+ public URI getRelativeUri( @Nonnull final UriEncodingStrategy strategy )
+ {
+ return ODataUriFactory.createAndEncodeUri(getServicePath(), getResourcePath(), getRequestQuery(), strategy);
+ }
+
+ @Override
+ @Nonnull
+ public String getRequestQuery()
+ {
+ final String genericQueryString = super.getRequestQuery();
+ if( !genericQueryString.isEmpty() && !queryString.isEmpty() ) {
+ return queryString + "&" + genericQueryString;
+ }
+ return queryString + genericQueryString;
+ }
+
+ @Override
+ @Nonnull
+ public ODataRequestResultGeneric execute( @Nonnull final HttpClient httpClient )
+ {
+ final ODataHttpRequest request = ODataHttpRequest.withoutBody(this, httpClient);
+ return tryExecute(request::requestGet, httpClient).get();
+ }
+
+ /**
+ * Disable pre-buffering of http response entity.
+ *
+ * @since 5.21.0
+ */
+ @Beta
+ @Nonnull
+ public ODataRequestResultResource.Executable withoutResponseBuffering()
+ {
+ requestResultFactory = ODataRequestResultFactory.WITHOUT_BUFFER;
+ return httpClient -> (ODataRequestResultResource) this.execute(httpClient);
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResult.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResult.java
new file mode 100644
index 000000000..ad7e460b2
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResult.java
@@ -0,0 +1,97 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+import java.util.TreeMap;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.message.StatusLine;
+
+/**
+ * Generic type of an OData request result.
+ */
+public interface ODataRequestResult
+{
+ /**
+ * Get the original {@link ODataRequestExecutable} instance that was used for running the OData request.
+ *
+ * @return The original {@link ODataRequestExecutable} instance.
+ */
+ @Nonnull
+ ODataRequestGeneric getODataRequest();
+
+ /**
+ * Get the original OData {@link HttpResponse} instance, which holds the HttpEntity and header information.
+ *
+ * @return The HttpResponse.
+ */
+ @Nonnull
+ ClassicHttpResponse getHttpResponse();
+
+ /**
+ * Get the HTTP response object status line.
+ *
+ * @return the StatusLine.
+ */
+ @Nullable
+ default StatusLine getStatusLine()
+ {
+ return new StatusLine(getHttpResponse());
+ }
+
+ /**
+ * Get the iterable list of HTTP response header names.
+ *
+ * @return An iterable set of header names.
+ */
+ @Nonnull
+ default Iterable getHeaderNames()
+ {
+ return getAllHeaderValues().keySet();
+ }
+
+ /**
+ * Get the iterable HTTP header values for a specific header name. The lookup happens case-insensitively.
+ *
+ * @param headerName
+ * The header name to look for.
+ * @return An iterable set of header values.
+ */
+ @Nonnull
+ default Iterable getHeaderValues( @Nonnull final String headerName )
+ {
+ return getAllHeaderValues().getOrDefault(headerName, Collections.emptyList());
+ }
+
+ /**
+ * Get all HTTP header values, grouped by the name (case insensitive) of the HTTP header.
+ *
+ * @return A case insensitive map of HTTP header names, where each entry is an iterable set of values for the
+ * specific header name.
+ */
+ @Nonnull
+ default Map> getAllHeaderValues()
+ {
+ final Header[] allHeaders = getHttpResponse().getHeaders();
+ final Map> result = new TreeMap<>(String::compareToIgnoreCase);
+
+ for( final Header header : allHeaders ) {
+ final String headerName = header.getName();
+ final String headerValue = header.getValue();
+
+ if( headerValue == null ) {
+ continue;
+ }
+
+ result.computeIfAbsent(headerName, key -> new ArrayList<>()).add(headerValue);
+ }
+ return Collections.unmodifiableMap(result);
+ }
+}
diff --git a/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultDeserializable.java b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultDeserializable.java
new file mode 100644
index 000000000..9497bbf62
--- /dev/null
+++ b/datamodel/odata-client-apache-httpclient5/src/main/java/com/sap/cloud/sdk/datamodel/odata/client/request/ODataRequestResultDeserializable.java
@@ -0,0 +1,101 @@
+package com.sap.cloud.sdk.datamodel.odata.client.request;
+
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import javax.annotation.Nonnull;
+
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataDeserializationException;
+import com.sap.cloud.sdk.datamodel.odata.client.exception.ODataResponseException;
+import com.sap.cloud.sdk.result.ResultElement;
+
+/**
+ * Generic type of an OData request result.
+ */
+public interface ODataRequestResultDeserializable
+{
+ /**
+ * Converts ODataRequestResult into POJO.
+ *
+ * @param objectType
+ * type of POJO
+ * @param
+ * The generic type of POJO
+ * @return T - POJO
+ *
+ * @throws ODataResponseException
+ * When the HTTP status indicates an erroneous response.
+ * @throws ODataDeserializationException
+ * When deserialization process failed for the OData response object.
+ */
+ @Nonnull
+ T as( @Nonnull final Class objectType );
+
+ /**
+ * Converts ODataRequestResult into list of POJOs.
+ *
+ * @param objectType
+ * type of POJO
+ * @param
+ * Generic type of the POJO
+ * @return List - list of POJOs
+ *
+ * @throws ODataResponseException
+ * When the HTTP status indicates an erroneous response.
+ * @throws ODataDeserializationException
+ * When deserialization process failed for the OData response objects.
+ */
+ @Nonnull
+ List asList( @Nonnull final Class objectType );
+
+ /**
+ * Run a consumer for fluent API type ResultElement to iterate over the OData response with a continuous data
+ * stream. The HttpEntity will be consumed.
+ *
+ * @param handler
+ * The consumer for generic ResultElement.
+ *
+ * @throws ODataResponseException
+ * When the HTTP status indicates an erroneous response.
+ * @throws ODataDeserializationException
+ * When deserialization process failed for the OData response objects.
+ */
+ void streamElements( @Nonnull final Consumer handler );
+
+ /**
+ * Get the count of elements in the result set.
+ *
+ * @return The number of elements.
+ *
+ * @throws ODataResponseException
+ * When the HTTP status indicates an erroneous response.
+ */
+ long getInlineCount();
+
+ /**
+ * Construct and get a key-value map from the OData response. The HttpEntity will be consumed.
+ *
+ * @return The key-value map.
+ *
+ * @throws ODataResponseException
+ * When the HTTP status indicates an erroneous response.
+ * @throws ODataDeserializationException
+ * When deserialization process failed for the OData response object.
+ */
+ @Nonnull
+ Map asMap();
+
+ /**
+ * Construct and get a list of key-value maps from the OData response. The HttpEntity will be consumed.
+ *
+ * @return The list of key-value maps.
+ *
+ * @throws ODataResponseException
+ * When the HTTP status indicates an erroneous response.
+ * @throws ODataDeserializationException
+ * When deserialization process failed for the OData response objects.
+ */
+ @Nonnull
+ List