From 88cb66f59449542a16bd17a8afed4b4b2cb23305 Mon Sep 17 00:00:00 2001 From: Dapeng Wang Date: Thu, 23 Jun 2022 12:12:21 +0200 Subject: [PATCH] feat: support response hint --- .../java/io/neonbee/data/DataContext.java | 64 +++ .../java/io/neonbee/data/DataVerticle.java | 454 +++++++++--------- .../data/internal/DataContextImpl.java | 80 ++- .../CountEntityCollectionProcessor.java | 52 +- .../olingo/processor/ProcessorHelper.java | 44 +- .../io/neonbee/endpoint/raw/RawEndpoint.java | 6 +- .../internal/helper/CollectionHelper.java | 23 + .../data/internal/DataContextImplTest.java | 69 ++- .../ResponseMetadataIntegrationTest.java | 163 +++++++ .../endpoint/odatav4/ODataV4EndpointTest.java | 13 + .../olingo/processor/ProcessorHelperTest.java | 43 ++ 11 files changed, 765 insertions(+), 246 deletions(-) create mode 100644 src/test/java/io/neonbee/data/internal/ResponseMetadataIntegrationTest.java create mode 100644 src/test/java/io/neonbee/endpoint/odatav4/internal/olingo/processor/ProcessorHelperTest.java diff --git a/src/main/java/io/neonbee/data/DataContext.java b/src/main/java/io/neonbee/data/DataContext.java index a3445148..1fa125e4 100644 --- a/src/main/java/io/neonbee/data/DataContext.java +++ b/src/main/java/io/neonbee/data/DataContext.java @@ -1,7 +1,9 @@ package io.neonbee.data; import java.util.Iterator; +import java.util.List; import java.util.Map; +import java.util.Optional; import io.vertx.core.json.JsonObject; @@ -96,6 +98,68 @@ public interface DataContext { */ T remove(String key); + /** + * Arbitrary response data of this context, which is passed backwards from callee to caller. + * + * @return all the context response data as a map. + */ + Map responseData(); + + /** + * Merges a given map from a response into the current response data map. + *

+ * Note: This method does not perform a deep merge operation, but overrides already existing elements w/o merging + * + * @param data the response data to merge, must be compatible to {@link JsonObject#JsonObject(Map)} + * @return a reference to this DataContext for chaining + */ + DataContext mergeResponseData(Map data); + + /** + * Returns a map between {@link DataRequest} and received data map. Received data are response data returned by the + * verticles, which are called by the current data verticle. + * + * @return a map between {@link DataRequest} and received data map + */ + Map> receivedData(); + + /** + * Sets the response data map for all {@link DataRequest}s. + * + * @param map a map between {@link DataRequest} and received response data for the request. + * @return a reference to this DataContext for chaining + */ + DataContext setReceivedData(Map> map); + + /** + * Retrieves the received data map for one {@link DataRequest}. + * + * @param dataRequest a data request + * @return response data map for this request + */ + Map findReceivedData(DataRequest dataRequest); + + /** + * Retrieves the received data map for one verticle identified by the verticle name. + * + * @param qualifiedName qualified name of a Received verticle + * @return first received data for the request verticle name, if available + */ + Optional> findFirstReceivedData(String qualifiedName); + + /** + * Retrieves all received data maps for all verticles identified by the name. + * + * @param qualifiedName qualified name of a verticle + * @return all received data maps from verticles with the name + */ + List> findAllReceivedData(String qualifiedName); + + /** + * Propagate the received data from all invoked verticles into the current verticle's context. + */ + void propagateReceivedData(); + /** * Returns the path of verticle this context was involved in. * diff --git a/src/main/java/io/neonbee/data/DataVerticle.java b/src/main/java/io/neonbee/data/DataVerticle.java index a9808a4d..9fecd846 100644 --- a/src/main/java/io/neonbee/data/DataVerticle.java +++ b/src/main/java/io/neonbee/data/DataVerticle.java @@ -80,6 +80,200 @@ public abstract class DataVerticle extends AbstractVerticle implements DataAd private DataVerticleMetrics dataVerticleMetrics; + /** + * Requesting data from other DataSources or Data/EntityVerticles. + * + * @param vertx The Vertx instance + * @param request The DataRequest specifying the data to request + * @param context The {@link DataContext data context} which keeps track of all the request-level data during a + * request + * @param The type of the returned future + * @return a future to the data requested + */ + public static Future requestData(Vertx vertx, DataRequest request, DataContext context) { + DataSource dataSource = request.getDataSource(); + + if (dataSource != null) { + return dataSource.retrieveData(request.getQuery(), context).map(FunctionalHelper::uncheckedMapper); + } + + DataSink dataSink = request.getDataSink(); + if (dataSink != null) { + return dataSink.manipulateData(request.getQuery(), context).map(FunctionalHelper::uncheckedMapper); + } + + String qualifiedName = request.getQualifiedName(); + if (qualifiedName != null) { + /* + * Event bus outbound message handling. + */ + LOGGER.correlateWith(context).debug("Sending message via the event bus to {}", qualifiedName); + String address = getAddress(qualifiedName); + return vertx.eventBus() + .request(address, request.getQuery(), requestDeliveryOptions(vertx, request, context, address)) + .transform(asyncReply -> { + LOGGER.correlateWith(context).debug("Received event bus reply"); + + if (asyncReply.succeeded()) { + DataContext responseDataContext = + decodeContextFromString(asyncReply.result().headers().get(CONTEXT_HEADER)); + context.setData( + Optional.ofNullable(responseDataContext).map(DataContext::data).orElse(null)); + context.mergeResponseData(Optional.ofNullable(responseDataContext) + .map(DataContext::responseData).orElse(null)); + return succeededFuture(asyncReply.result().body()); + + } else { + Throwable cause = asyncReply.cause(); + if (LOGGER.isWarnEnabled()) { + LOGGER.correlateWith(context).warn("Failed to receive event bus reply from {}", + qualifiedName, cause); + } + return failedFuture(mapException(cause)); + } + }); + } + + FullQualifiedName entityTypeName = request.getEntityTypeName(); + if (entityTypeName != null) { + return requestEntity(vertx, request, context).map(FunctionalHelper::uncheckedMapper); + } + + return failedFuture(new IllegalArgumentException("Data request did not specify what data to request")); + } + + /** + * Convenience method for calling the {@link #requestData(Vertx, DataRequest, DataContext)} method. + * + * @param request The DataRequest specifying the data to request + * @param context The {@link DataContext data context} which keeps track of all the request-level information during + * the lifecycle of requesting data + * @param The type of the returned {@link Future} + * @return a future to the data requested + * @see #requestData(Vertx, DataRequest, DataContext) + */ + public Future requestData(DataRequest request, DataContext context) { + LOGGER.correlateWith(context).debug("Data verticle {} requesting data from {}", getQualifiedName(), request); + + Future future = requestData(vertx, request, context); + reportRequestDataMetrics(request, future); + return future; + } + + /** + * Return a qualified name string for a verticle under a namespace. + * + * @param namespace Namespace of the verticle + * @param verticleName Name of the verticle + * @return Qualified name of namespace and verticle name + */ + public static String createQualifiedName(String namespace, String verticleName) { + return String.format("%s/%s", namespace.toLowerCase(Locale.ROOT), verticleName); + } + + /** + * Computes the event bus address of this data verticle. + * + * @return A unique event bus address + */ + protected final String getAddress() { + return getAddress(getQualifiedName()); + } + + /** + * Computes the event bus address for a given data verticle. + * + * @param qualifiedName The qualified name of the verticle to compute the address for + * @return A unique event bus address + */ + protected static String getAddress(String qualifiedName) { + return String.format("%s[%s]", DataVerticle.class.getSimpleName(), qualifiedName); + } + + /** + * Creates a new delivery options object for any given data request and context. + * + * @param vertx the vertx instance + * @param request the data request + * @param context the data context + * @param address request address + * @return a new DeliveryOptions + */ + private static DeliveryOptions requestDeliveryOptions(Vertx vertx, DataRequest request, DataContext context, + String address) { + if (context instanceof DataContextImpl) { // will also perform a null check! + // before encoding the context header, add the current qualified name of the verticle to the path stack + ((DataContextImpl) context).pushVerticleToPath(request.getQualifiedName()); + } + DeliveryOptions deliveryOptions = deliveryOptions(vertx, null, context); + if (context instanceof DataContextImpl) { // will also perform a null check! + // remove the verticle right after, as the same context (w/o copying) may be reused for multiple requests + ((DataContextImpl) context).popVerticleFromPath(); + } + + // adapt further delivery options based on the request + boolean localOnly = request.isLocalOnly() + || (request.isLocalPreferred() && NeonBee.get(vertx).isLocalConsumerAvailable(address)); + deliveryOptions.setLocalOnly(localOnly); + if (request.getSendTimeout() > 0) { + deliveryOptions.setSendTimeout(request.getSendTimeout()); + } + + Optional.ofNullable(request.getResolutionStrategy()).map(ResolutionStrategy::name) + .ifPresent(value -> deliveryOptions.addHeader(RESOLUTION_STRATEGY_HEADER, value)); + + return deliveryOptions; + } + + /** + * Creates a new delivery options object for any given context. + * + * @param vertx the vertx instance + * @param codec the message codec to use (if any) + * @param context the data context + * @return a new DeliveryOptions + */ + private static DeliveryOptions deliveryOptions(Vertx vertx, MessageCodec codec, DataContext context) { + DeliveryOptions deliveryOptions = new DeliveryOptions(); + deliveryOptions.setSendTimeout(SECONDS.toMillis(NeonBee.get(vertx).getConfig().getEventBusTimeout())) + .setCodecName(Optional.ofNullable(codec).map(MessageCodec::name).orElse(null)); + Optional.ofNullable(context).map(DataContextImpl::encodeContextToString) + .ifPresent(value -> deliveryOptions.addHeader(CONTEXT_HEADER, value)); + return deliveryOptions; + } + + /** + * Creates a new data exception for any given throwable cause. + * + * @param cause any throwable cause + * @return a DataException passing the failure code in case it is a ReplyException + */ + private static DataException mapException(Throwable cause) { + if (cause instanceof DataException) { + return (DataException) cause; + } + + int failureCode = FAILURE_CODE_PROCESSING_FAILED; + String message = cause.getMessage(); + + if (cause instanceof ReplyException) { + ReplyException replyException = (ReplyException) cause; + switch (replyException.failureType()) { + case NO_HANDLERS: + failureCode = FAILURE_CODE_NO_HANDLERS; + break; + case TIMEOUT: + failureCode = FAILURE_CODE_TIMEOUT; + break; + default: + failureCode = replyException.failureCode(); + break; + } + } + + return new DataException(failureCode, message); + } + /** * The name of this data verticle (must be unique in one cluster) *

@@ -290,83 +484,6 @@ public Future retrieveData(DataQuery query, DataMap require, DataContext cont return retrieveData(query, context); } - /** - * Convenience method for calling the {@link #requestData(Vertx, DataRequest, DataContext)} method. - * - * @see #requestData(Vertx, DataRequest, DataContext) - * @param request The DataRequest specifying the data to request - * @param context The {@link DataContext data context} which keeps track of all the request-level information during - * the lifecycle of requesting data - * @param The type of the returned {@link Future} - * @return a future to the data requested - */ - public Future requestData(DataRequest request, DataContext context) { - LOGGER.correlateWith(context).debug("Data verticle {} requesting data from {}", getQualifiedName(), request); - - Future future = requestData(vertx, request, context); - reportRequestDataMetrics(request, future); - return future; - } - - /** - * Requesting data from other DataSources or Data/EntityVerticles. - * - * @param vertx The Vertx instance - * @param request The DataRequest specifying the data to request - * @param context The {@link DataContext data context} which keeps track of all the request-level data during a - * request - * @param The type of the returned future - * @return a future to the data requested - */ - public static Future requestData(Vertx vertx, DataRequest request, DataContext context) { - DataSource dataSource = request.getDataSource(); - - if (dataSource != null) { - return dataSource.retrieveData(request.getQuery(), context).map(FunctionalHelper::uncheckedMapper); - } - - DataSink dataSink = request.getDataSink(); - if (dataSink != null) { - return dataSink.manipulateData(request.getQuery(), context).map(FunctionalHelper::uncheckedMapper); - } - - String qualifiedName = request.getQualifiedName(); - if (qualifiedName != null) { - /* - * Event bus outbound message handling. - */ - LOGGER.correlateWith(context).debug("Sending message via the event bus to {}", qualifiedName); - String address = getAddress(qualifiedName); - return vertx.eventBus() - .request(address, request.getQuery(), requestDeliveryOptions(vertx, request, context, address)) - .transform(asyncReply -> { - LOGGER.correlateWith(context).debug("Received event bus reply"); - - if (asyncReply.succeeded()) { - context.setData(Optional - .ofNullable( - decodeContextFromString(asyncReply.result().headers().get(CONTEXT_HEADER))) - .map(DataContext::data).orElse(null)); - return succeededFuture(asyncReply.result().body()); - } else { - Throwable cause = asyncReply.cause(); - if (LOGGER.isWarnEnabled()) { - LOGGER.correlateWith(context).warn("Failed to receive event bus reply from {}", - qualifiedName, cause); - } - return failedFuture(mapException(cause)); - } - }); - } - - FullQualifiedName entityTypeName = request.getEntityTypeName(); - if (entityTypeName != null) { - return requestEntity(vertx, request, context).map(FunctionalHelper::uncheckedMapper); - } - - return failedFuture(new IllegalArgumentException("Data request did not specify what data to request")); - } - private void reportRequestDataMetrics(DataRequest request, Future future) { List tags; if (request.getQuery() == null || request.getQuery().getQuery() == null) { @@ -398,120 +515,6 @@ public final String getQualifiedName() { return namespace != null ? createQualifiedName(namespace, name) : name; } - /** - * Return a qualified name string for a verticle under a namespace. - * - * @param namespace Namespace of the verticle - * @param verticleName Name of the verticle - * @return Qualified name of namespace and verticle name - */ - public static String createQualifiedName(String namespace, String verticleName) { - return String.format("%s/%s", namespace.toLowerCase(Locale.ROOT), verticleName); - } - - /** - * Computes the event bus address of this data verticle. - * - * @return A unique event bus address - */ - protected final String getAddress() { - return getAddress(getQualifiedName()); - } - - /** - * Computes the event bus address for a given data verticle. - * - * @param qualifiedName The qualified name of the verticle to compute the address for - * @return A unique event bus address - */ - protected static String getAddress(String qualifiedName) { - return String.format("%s[%s]", DataVerticle.class.getSimpleName(), qualifiedName); - } - - /** - * Creates a new delivery options object for any given data request and context. - * - * @param vertx the vertx instance - * @param request the data request - * @param context the data context - * @param address request address - * @return a new DeliveryOptions - */ - private static DeliveryOptions requestDeliveryOptions(Vertx vertx, DataRequest request, DataContext context, - String address) { - if (context instanceof DataContextImpl) { // will also perform a null check! - // before encoding the context header, add the current qualified name of the verticle to the path stack - ((DataContextImpl) context).pushVerticleToPath(request.getQualifiedName()); - } - DeliveryOptions deliveryOptions = deliveryOptions(vertx, null, context); - if (context instanceof DataContextImpl) { // will also perform a null check! - // remove the verticle right after, as the same context (w/o copying) may be reused for multiple requests - ((DataContextImpl) context).popVerticleFromPath(); - } - - // adapt further delivery options based on the request - boolean localOnly = request.isLocalOnly() - || (request.isLocalPreferred() && NeonBee.get(vertx).isLocalConsumerAvailable(address)); - deliveryOptions.setLocalOnly(localOnly); - if (request.getSendTimeout() > 0) { - deliveryOptions.setSendTimeout(request.getSendTimeout()); - } - - Optional.ofNullable(request.getResolutionStrategy()).map(ResolutionStrategy::name) - .ifPresent(value -> deliveryOptions.addHeader(RESOLUTION_STRATEGY_HEADER, value)); - - return deliveryOptions; - } - - /** - * Creates a new delivery options object for any given context. - * - * @param vertx the vertx instance - * @param codec the message codec to use (if any) - * @param context the data context - * @return a new DeliveryOptions - */ - private static DeliveryOptions deliveryOptions(Vertx vertx, MessageCodec codec, DataContext context) { - DeliveryOptions deliveryOptions = new DeliveryOptions(); - deliveryOptions.setSendTimeout(SECONDS.toMillis(NeonBee.get(vertx).getConfig().getEventBusTimeout())) - .setCodecName(Optional.ofNullable(codec).map(MessageCodec::name).orElse(null)); - Optional.ofNullable(context).map(DataContextImpl::encodeContextToString) - .ifPresent(value -> deliveryOptions.addHeader(CONTEXT_HEADER, value)); - return deliveryOptions; - } - - /** - * Creates a new data exception for any given throwable cause. - * - * @param cause any throwable cause - * @return a DataException passing the failure code in case it is a ReplyException - */ - private static DataException mapException(Throwable cause) { - if (cause instanceof DataException) { - return (DataException) cause; - } - - int failureCode = FAILURE_CODE_PROCESSING_FAILED; - String message = cause.getMessage(); - - if (cause instanceof ReplyException) { - ReplyException replyException = (ReplyException) cause; - switch (replyException.failureType()) { - case NO_HANDLERS: - failureCode = FAILURE_CODE_NO_HANDLERS; - break; - case TIMEOUT: - failureCode = FAILURE_CODE_TIMEOUT; - break; - default: - failureCode = replyException.failureCode(); - break; - } - } - - return new DataException(failureCode, message); - } - /** * Get an instance of a resolution routine for a certain strategy. * @@ -548,18 +551,28 @@ public Future execute(DataQuery query, DataContext context) { // order, as the collection returned via requireData. This also favours the previous implementation of // requireData(), where any index of the requireData array corresponded with the indexes of the data array Map> requestResults = new LinkedHashMap<>(); + Map receivedDataContextMap = new LinkedHashMap<>(); return requireData(query, context).compose(requests -> { // ignore the result of the require data composite future (otherwiseEmpty), the retrieve data method // should decide if it needs to handle success or failure of any of the individual asynchronous results - return CompositeFuture.join(Optional.ofNullable(requests).map(Collection::stream).orElse(Stream.empty()) - .map(request -> requestResults.computeIfAbsent(request, mapRequest -> { - Future future = requestData(vertx, request, context.copy()); - reportRequestDataMetrics(request, future); - return future; - })).map(Future.class::cast).collect(Collectors.toList())).otherwiseEmpty(); + return CompositeFuture.join( + Optional.ofNullable(requests).map(Collection::stream).orElse(Stream.empty()).map(request -> { + // use one copy of DataContext for each request to avoid data clash + DataContext requestContext = context.copy(); + receivedDataContextMap.put(request, requestContext); + return requestResults.computeIfAbsent(request, mapRequest -> { + Future future = requestData(vertx, request, requestContext); + reportRequestDataMetrics(request, future); + return future; + }); + }).map(Future.class::cast).collect(Collectors.toList())).otherwiseEmpty(); }).compose(requiredCompositeOrNothing -> { List tags = retrieveDataTags(); try { + Map> receivedData = receivedDataContextMap.entrySet().stream() + .map(entry -> Map.entry(entry.getKey(), entry.getValue().responseData())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + context.setReceivedData(receivedData); Future future = retrieveData(query, new DataMap(requestResults), context); reportRetrieveDataMetrics(tags, future); return future; @@ -571,32 +584,33 @@ public Future execute(DataQuery query, DataContext context) { } }); } - } - /** - * @return tags for the retrieve data metrics. - */ - private List retrieveDataTags() { - List tags = new ArrayList<>(2); - String name = getName(); - if (name != null) { - tags.add(new ImmutableTag("name", name)); - } - String namespace = getNamespace(); - if (namespace != null) { - tags.add(new ImmutableTag("namespace", namespace)); + /** + * @return tags for the retrieve data metrics. + */ + private List retrieveDataTags() { + List tags = new ArrayList<>(2); + String name = getName(); + if (name != null) { + tags.add(new ImmutableTag("name", name)); + } + String namespace = getNamespace(); + if (namespace != null) { + tags.add(new ImmutableTag("namespace", namespace)); + } + return tags; } - return tags; - } - private void reportRetrieveDataMetrics(List tags, Future future) { - String address = getAddress(); - dataVerticleMetrics.reportTimingMetric("retrieve.data.timer." + address, "Time to retrieve data", tags, future); - dataVerticleMetrics.reportStatusCounter("retrieve.data.counter." + address, SUCCEEDED_RESPONSE_COUNT, tags, - future); - dataVerticleMetrics.reportActiveRequestsGauge("retrieve.data.active.requests." + address, - "Number of requests waiting for a response", tags, future); - dataVerticleMetrics.reportNumberOfRequests("retrieve.counter." + address, "Number of requests sent", tags); + private void reportRetrieveDataMetrics(List tags, Future future) { + String address = getAddress(); + dataVerticleMetrics.reportTimingMetric("retrieve.data.timer." + address, "Time to retrieve data", tags, + future); + dataVerticleMetrics.reportStatusCounter("retrieve.data.counter." + address, SUCCEEDED_RESPONSE_COUNT, tags, + future); + dataVerticleMetrics.reportActiveRequestsGauge("retrieve.data.active.requests." + address, + "Number of requests waiting for a response", tags, future); + dataVerticleMetrics.reportNumberOfRequests("retrieve.counter." + address, "Number of requests sent", tags); + } } private class OptimizedResolutionRoutine implements ResolutionRoutine { diff --git a/src/main/java/io/neonbee/data/internal/DataContextImpl.java b/src/main/java/io/neonbee/data/internal/DataContextImpl.java index 2636dd09..ab076f58 100644 --- a/src/main/java/io/neonbee/data/internal/DataContextImpl.java +++ b/src/main/java/io/neonbee/data/internal/DataContextImpl.java @@ -2,6 +2,7 @@ import static com.google.common.collect.Iterators.unmodifiableIterator; import static io.neonbee.internal.handler.CorrelationIdHandler.CORRELATION_ID; +import static io.neonbee.internal.helper.CollectionHelper.isNullOrEmpty; import static io.neonbee.internal.helper.CollectionHelper.mutableCopyOf; import static io.neonbee.internal.helper.HostHelper.getHostIp; @@ -24,6 +25,7 @@ import io.neonbee.data.DataContext; import io.neonbee.data.DataException; +import io.neonbee.data.DataRequest; import io.neonbee.internal.handler.CorrelationIdHandler; import io.neonbee.logging.LoggingFacade; import io.vertx.core.http.HttpHeaders; @@ -52,6 +54,8 @@ public class DataContextImpl implements DataContext { private static final Pattern BEARER_AUTHENTICATION_PATTERN = Pattern.compile("Bearer\\s(.+)"); + private static final String RESPONSE_METADATA_KEY = "responsedata"; + private final String correlationId; private final String bearerToken; @@ -62,8 +66,16 @@ public class DataContextImpl implements DataContext { private Map data; + private Map responseData; + private Deque pathStack; + /** + * This is a map between {@link DataRequest} to an invoked verticle and the received response data for the request. + * This map will not be propagated to the upstream verticles by default. + */ + private Map> receivedData; + public DataContextImpl() { // initialize an empty context (w/ will also create an empty path stack) this(null, null, null, null, null, null); @@ -104,9 +116,16 @@ public DataContextImpl(String correlationId, String bearerToken, JsonObject user this(correlationId, null, bearerToken, userPrincipal, data, paths); } - @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") + @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod", "ChainingConstructorIgnoresParameter", + "PMD.UnusedFormalParameter" }) public DataContextImpl(String correlationId, String sessionId, String bearerToken, JsonObject userPrincipal, Map data, Deque paths) { + this(correlationId, sessionId, bearerToken, userPrincipal, data, null, paths); + } + + @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod" }) + public DataContextImpl(String correlationId, String sessionId, String bearerToken, JsonObject userPrincipal, + Map data, Map responseData, Deque paths) { this.correlationId = correlationId; this.sessionId = sessionId; this.bearerToken = bearerToken; @@ -114,6 +133,7 @@ public DataContextImpl(String correlationId, String sessionId, String bearerToke this.userPrincipal = Optional.ofNullable(userPrincipal).map(JsonObject::getMap) .map(Collections::unmodifiableMap).map(JsonObject::new).orElse(null); this.setData(data); // create a mutable copy of the map + this.responseData = mutableCopyOf(responseData); this.setPath(paths); // create a mutable copy of the dequeue } @@ -228,6 +248,58 @@ public T remove(String key) { return (T) value; } + @Override + public Map responseData() { + if (this.responseData == null) { + this.responseData = new HashMap<>(); + } + return this.responseData; + } + + @Override + public DataContext mergeResponseData(Map data) { + if (!isNullOrEmpty(data)) { + // instead of putAll, might be worth it to write a more sophisticated logic using .merge() + this.responseData().putAll(mutableCopyOf(data)); + } + return this; + } + + @Override + public Map> receivedData() { + return this.receivedData; + } + + @Override + public DataContext setReceivedData(Map> map) { + this.receivedData = Collections.unmodifiableMap(map); + return this; + } + + @Override + public Map findReceivedData(DataRequest dataRequest) { + return this.receivedData.getOrDefault(dataRequest, Map.of()); + } + + @Override + public Optional> findFirstReceivedData(String qualifiedName) { + return this.receivedData.entrySet().stream() + .filter(entry -> qualifiedName.equals(entry.getKey().getQualifiedName())).findFirst() + .map(Map.Entry::getValue); + } + + @Override + public List> findAllReceivedData(String qualifiedName) { + return this.receivedData.entrySet().stream() + .filter(entry -> qualifiedName.equals(entry.getKey().getQualifiedName())).map(Map.Entry::getValue) + .collect(Collectors.toList()); + } + + @Override + public void propagateReceivedData() { + receivedData.values().stream().forEach(data -> this.mergeResponseData(data)); + } + /** * Encodes a given {@link DataContext} to string. * @@ -241,7 +313,9 @@ public static String encodeContextToString(DataContext context) { } return new JsonObject().put(CORRELATION_ID, context.correlationId()).put(SESSION_ID_KEY, context.sessionId()) .put(BEARER_TOKEN_KEY, context.bearerToken()).put(USER_PRINCIPAL_KEY, context.userPrincipal()) - .put(DATA_KEY, context.data()).put(PATH_KEY, pathToJson(context.path())).toString(); + .put(DATA_KEY, new JsonObject(context.data())) + .put(RESPONSE_METADATA_KEY, new JsonObject(context.responseData())) + .put(PATH_KEY, pathToJson(context.path())).toString(); } private static JsonArray pathToJson(Iterator path) { @@ -264,6 +338,8 @@ public static DataContext decodeContextFromString(String contextString) { return new DataContextImpl(contextJson.getString(CORRELATION_ID), contextJson.getString(SESSION_ID_KEY), contextJson.getString(BEARER_TOKEN_KEY), contextJson.getJsonObject(USER_PRINCIPAL_KEY), Optional.ofNullable(contextJson.getJsonObject(DATA_KEY)).map(JsonObject::getMap).orElse(null), + Optional.ofNullable(contextJson.getJsonObject(RESPONSE_METADATA_KEY)).map(JsonObject::getMap) + .orElse(null), Optional.ofNullable(contextJson.getJsonArray(PATH_KEY)).map(DataContextImpl::pathFromJson) .orElse(null)); } diff --git a/src/main/java/io/neonbee/endpoint/odatav4/internal/olingo/processor/CountEntityCollectionProcessor.java b/src/main/java/io/neonbee/endpoint/odatav4/internal/olingo/processor/CountEntityCollectionProcessor.java index 980b9451..fffee4dd 100644 --- a/src/main/java/io/neonbee/endpoint/odatav4/internal/olingo/processor/CountEntityCollectionProcessor.java +++ b/src/main/java/io/neonbee/endpoint/odatav4/internal/olingo/processor/CountEntityCollectionProcessor.java @@ -4,8 +4,16 @@ import static io.neonbee.endpoint.odatav4.internal.olingo.processor.EntityProcessor.findEntityByKeyPredicates; import static io.neonbee.endpoint.odatav4.internal.olingo.processor.NavigationPropertyHelper.chooseEntitySet; import static io.neonbee.endpoint.odatav4.internal.olingo.processor.NavigationPropertyHelper.fetchNavigationTargetEntities; +import static io.neonbee.endpoint.odatav4.internal.olingo.processor.ProcessorHelper.ODATA_EXPAND_KEY; +import static io.neonbee.endpoint.odatav4.internal.olingo.processor.ProcessorHelper.ODATA_FILTER_KEY; +import static io.neonbee.endpoint.odatav4.internal.olingo.processor.ProcessorHelper.ODATA_ORDER_BY_KEY; +import static io.neonbee.endpoint.odatav4.internal.olingo.processor.ProcessorHelper.ODATA_SKIP_KEY; +import static io.neonbee.endpoint.odatav4.internal.olingo.processor.ProcessorHelper.ODATA_TOP_KEY; +import static io.neonbee.endpoint.odatav4.internal.olingo.processor.ProcessorHelper.RESPONSE_HEADER_PREFIX; import static io.neonbee.endpoint.odatav4.internal.olingo.processor.ProcessorHelper.forwardRequest; import static io.neonbee.internal.helper.StringHelper.EMPTY; +import static io.vertx.core.Future.succeededFuture; +import static java.util.Optional.ofNullable; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; @@ -103,15 +111,36 @@ public void readEntityCollection(ODataRequest request, ODataResponse response, U // Fetch the data from backend forwardRequest(request, READ, uriInfo, vertx, routingContext, processPromise).onSuccess(ew -> { + boolean expandExecuted = ofNullable(routingContext.get(RESPONSE_HEADER_PREFIX + ODATA_EXPAND_KEY)) + .orElse(Boolean.FALSE); if (resourceParts.size() == 1) { try { - List resultEntityList = applyFilterQueryOption(uriInfo.getFilterOption(), ew.getEntities()); + boolean filterExecuted = + ofNullable(routingContext.get(RESPONSE_HEADER_PREFIX + ODATA_FILTER_KEY)) + .orElse(Boolean.FALSE); + List resultEntityList = filterExecuted ? ew.getEntities() + : applyFilterQueryOption(uriInfo.getFilterOption(), ew.getEntities()); applyCountOption(uriInfo.getCountOption(), resultEntityList, entityCollection); if (!resultEntityList.isEmpty()) { - applyOrderByQueryOption(uriInfo.getOrderByOption(), resultEntityList); - resultEntityList = applySkipQueryOption(uriInfo.getSkipOption(), resultEntityList); - resultEntityList = applyTopQueryOption(uriInfo.getTopOption(), resultEntityList); - applyExpandQueryOptions(uriInfo, resultEntityList).onComplete(responsePromise); + boolean orderByExecuted = + ofNullable(routingContext.get(RESPONSE_HEADER_PREFIX + ODATA_ORDER_BY_KEY)) + .orElse(Boolean.FALSE); + if (!orderByExecuted) { + applyOrderByQueryOption(uriInfo.getOrderByOption(), resultEntityList); + } + boolean skipExecuted = + ofNullable(routingContext.get(RESPONSE_HEADER_PREFIX + ODATA_SKIP_KEY)) + .orElse(Boolean.FALSE); + resultEntityList = skipExecuted ? resultEntityList + : applySkipQueryOption(uriInfo.getSkipOption(), resultEntityList); + boolean topExecuted = + ofNullable(routingContext.get(RESPONSE_HEADER_PREFIX + ODATA_TOP_KEY)) + .orElse(Boolean.FALSE); + resultEntityList = topExecuted ? resultEntityList + : applyTopQueryOption(uriInfo.getTopOption(), resultEntityList); + Future> resultEntityListFuture = expandExecuted ? succeededFuture(resultEntityList) + : applyExpandQueryOptions(uriInfo, resultEntityList); + resultEntityListFuture.onComplete(responsePromise); } else { responsePromise.complete(resultEntityList); } @@ -120,10 +149,15 @@ public void readEntityCollection(ODataRequest request, ODataResponse response, U } } else { try { - Entity foundEntity = - findEntityByKeyPredicates(routingContext, uriResourceEntitySet, ew.getEntities()); - fetchNavigationTargetEntities(resourceParts.get(1), foundEntity, vertx, routingContext) - .onComplete(responsePromise); + boolean keyPredicateExecuted = + ofNullable(routingContext.get(ProcessorHelper.ODATA_KEY_PREDICATE_KEY)) + .orElse(Boolean.FALSE); + Entity foundEntity = keyPredicateExecuted ? ew.getEntities().get(0) + : findEntityByKeyPredicates(routingContext, uriResourceEntitySet, ew.getEntities()); + if (!expandExecuted) { + fetchNavigationTargetEntities(resourceParts.get(1), foundEntity, vertx, routingContext) + .onComplete(responsePromise); + } } catch (ODataApplicationException e) { processPromise.fail(e); } diff --git a/src/main/java/io/neonbee/endpoint/odatav4/internal/olingo/processor/ProcessorHelper.java b/src/main/java/io/neonbee/endpoint/odatav4/internal/olingo/processor/ProcessorHelper.java index c8c41443..490dc048 100644 --- a/src/main/java/io/neonbee/endpoint/odatav4/internal/olingo/processor/ProcessorHelper.java +++ b/src/main/java/io/neonbee/endpoint/odatav4/internal/olingo/processor/ProcessorHelper.java @@ -10,7 +10,10 @@ import org.apache.olingo.server.api.uri.UriInfo; import org.apache.olingo.server.api.uri.UriResourceEntitySet; +import com.google.common.annotations.VisibleForTesting; + import io.neonbee.data.DataAction; +import io.neonbee.data.DataContext; import io.neonbee.data.DataQuery; import io.neonbee.data.DataRequest; import io.neonbee.data.internal.DataContextImpl; @@ -22,6 +25,28 @@ import io.vertx.ext.web.RoutingContext; public final class ProcessorHelper { + + /** Response prefix in routing context. */ + public static final String RESPONSE_HEADER_PREFIX = "response."; + + /** OData filter key. */ + public static final String ODATA_FILTER_KEY = "OData.filter"; + + /** OData orderBy key. */ + public static final String ODATA_ORDER_BY_KEY = "OData.orderby"; + + /** OData skip key. */ + public static final String ODATA_SKIP_KEY = "OData.skip"; + + /** OData top key. */ + public static final String ODATA_TOP_KEY = "OData.top"; + + /** OData expand key. */ + public static final String ODATA_EXPAND_KEY = "OData.expand"; + + /** OData key predicate key. */ + public static final String ODATA_KEY_PREDICATE_KEY = "OData.key"; + private ProcessorHelper() {} private static DataQuery odataRequestToQuery(ODataRequest request, DataAction action, Buffer body) { @@ -69,8 +94,23 @@ public static Future forwardRequest(ODataRequest request, DataAct Buffer body = Optional.ofNullable(entity) .map(e -> new EntityWrapper(entityType.getFullQualifiedName(), e).toBuffer(vertx)).orElse(null); DataQuery query = odataRequestToQuery(request, action, body); + DataContext dataContext = new DataContextImpl(routingContext); + return requestEntity(vertx, new DataRequest(entityType.getFullQualifiedName(), query), dataContext) + .map(result -> { + transferResponseHint(dataContext, routingContext); + return result; + }).onFailure(processPromise::fail); + } - return requestEntity(vertx, new DataRequest(entityType.getFullQualifiedName(), query), - new DataContextImpl(routingContext)).onFailure(processPromise::fail); + /** + * Transfer response hints from data context into routing context. + * + * @param dataContext data context + * @param routingContext routing context + */ + @VisibleForTesting + static void transferResponseHint(DataContext dataContext, RoutingContext routingContext) { + dataContext.responseData().entrySet() + .forEach(entry -> routingContext.put(RESPONSE_HEADER_PREFIX + entry.getKey(), entry.getValue())); } } diff --git a/src/main/java/io/neonbee/endpoint/raw/RawEndpoint.java b/src/main/java/io/neonbee/endpoint/raw/RawEndpoint.java index a4f1af0e..481ecbf1 100644 --- a/src/main/java/io/neonbee/endpoint/raw/RawEndpoint.java +++ b/src/main/java/io/neonbee/endpoint/raw/RawEndpoint.java @@ -27,6 +27,7 @@ import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.Locale; +import java.util.Optional; import java.util.regex.Pattern; import com.google.common.annotations.VisibleForTesting; @@ -145,6 +146,7 @@ public void handle(RoutingContext routingContext) { decodedQueryPath, multiMapToMap(request.headers()), routingContext.body().buffer()) .addHeader("X-HTTP-Method", request.method().name()); + DataContextImpl context = new DataContextImpl(routingContext); requestData(routingContext.vertx(), new DataRequest(qualifiedName, query), new DataContextImpl(routingContext)).onComplete(asyncResult -> { if (asyncResult.failed()) { @@ -176,7 +178,9 @@ decodedQueryPath, multiMapToMap(request.headers()), routingContext.body().buffer } HttpServerResponse response = routingContext.response()// - .putHeader("Content-Type", "application/json"); + .putHeader("Content-Type", + Optional.ofNullable(context.responseData().get("Content-Type")) + .map(String.class::cast).orElse("application/json")); if (result instanceof JsonObject) { result = ((JsonObject) result).toBuffer(); } else if (result instanceof JsonArray) { diff --git a/src/main/java/io/neonbee/internal/helper/CollectionHelper.java b/src/main/java/io/neonbee/internal/helper/CollectionHelper.java index 886aea0b..3ab930a2 100644 --- a/src/main/java/io/neonbee/internal/helper/CollectionHelper.java +++ b/src/main/java/io/neonbee/internal/helper/CollectionHelper.java @@ -162,6 +162,29 @@ public static Map> multiMapToMap(MultiMap multiMap) { return Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue); } + /** + * Returns true, when a collection is null or empty. + * + * @param the type of entries + * @param collection collection to check + * @return true, when a collection is null or empty + */ + public static boolean isNullOrEmpty(Collection collection) { + return collection == null || collection.isEmpty(); + } + + /** + * Returns true, when a map is null or empty. + * + * @param the type of key + * @param the type of entry + * @param map map to check + * @return true, when a map is null or empty + */ + public static boolean isNullOrEmpty(Map map) { + return map == null || map.isEmpty(); + } + /** * A merging {@link HashMap} which may take null values into merging. * diff --git a/src/test/java/io/neonbee/data/internal/DataContextImplTest.java b/src/test/java/io/neonbee/data/internal/DataContextImplTest.java index 01409265..f8252676 100644 --- a/src/test/java/io/neonbee/data/internal/DataContextImplTest.java +++ b/src/test/java/io/neonbee/data/internal/DataContextImplTest.java @@ -36,6 +36,8 @@ import io.neonbee.data.DataContext; import io.neonbee.data.DataContext.DataVerticleCoordinate; import io.neonbee.data.DataException; +import io.neonbee.data.DataQuery; +import io.neonbee.data.DataRequest; import io.vertx.core.http.HttpHeaders; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.JsonArray; @@ -326,7 +328,8 @@ static Stream withSessions() { @ParameterizedTest(name = "{index}: with session: {0}") @MethodSource("withSessions") @Timeout(value = 2, timeUnit = TimeUnit.SECONDS) - @SuppressWarnings("PMD.UnusedFormalParameter") // Required for display name + @SuppressWarnings("PMD.UnusedFormalParameter") + // Required for display name @DisplayName("Constructor that accepts RoutingContext works correct") void testWithRoutingContext(Session sessionMock, String expectedSessionValue) { RoutingContext routingContextMock = mock(RoutingContext.class); @@ -362,17 +365,59 @@ void testNullEncodeDecode() { @Test @DisplayName("test encoding / decoding context") void testEncodeDecode() { - DataContext dataContext = DataContextImpl - .decodeContextFromString(DataContextImpl.encodeContextToString(new DataContextImpl("expected1", - "expectedSessionId", "expected2", new JsonObject().put("expectedKey", "expectedValue"), - new JsonObject().put("expected1", "expected2").put("expectedArray", new JsonArray().add(0)) - .put("expectedNull", (Object) null).getMap()))); - assertThat(dataContext.correlationId()).isEqualTo("expected1"); - assertThat(dataContext.sessionId()).isEqualTo("expectedSessionId"); - assertThat(dataContext.bearerToken()).isEqualTo("expected2"); - assertThat(dataContext.userPrincipal()).isEqualTo(new JsonObject().put("expectedKey", "expectedValue")); - assertThat(new JsonObject(dataContext.data())).isEqualTo(new JsonObject().put("expected1", "expected2") - .put("expectedArray", new JsonArray().add(0)).put("expectedNull", (Object) null)); + DataContext dataContext = DataContextImpl.decodeContextFromString(DataContextImpl.encodeContextToString( + new DataContextImpl("correlationId", "sessionId", "token", new JsonObject().put("user", "pass"), + new JsonObject().put("data1", "data1").put("dataArray", new JsonArray().add(0)) + .put("dataNull", (Object) null).getMap(), + new JsonObject().put("responseData1", "data1").put("responseArray", new JsonArray().add(0)) + .put("responseNull", (Object) null).getMap(), + null))); + assertThat(dataContext.correlationId()).isEqualTo("correlationId"); + assertThat(dataContext.sessionId()).isEqualTo("sessionId"); + assertThat(dataContext.bearerToken()).isEqualTo("token"); + assertThat(dataContext.userPrincipal()).isEqualTo(new JsonObject().put("user", "pass")); + assertThat(new JsonObject(dataContext.data())).isEqualTo(new JsonObject().put("data1", "data1") + .put("dataArray", new JsonArray().add(0)).put("dataNull", (Object) null)); + assertThat(new JsonObject(dataContext.responseData())).isEqualTo(new JsonObject().put("responseData1", "data1") + .put("responseArray", new JsonArray().add(0)).put("responseNull", (Object) null)); + } + + @Test + @DisplayName("test response meta data handling") + void testResponseData() { + DataContext context = new DataContextImpl(); + assertThat(context.responseData()).isEmpty(); + context.mergeResponseData(Map.of("key", "value")); + assertThat(context.responseData()).hasSize(1); + assertThat(context.responseData().get("key")).isEqualTo("value"); + context.responseData().put("key", "newvalue"); + assertThat(context.responseData().get("key")).isEqualTo("newvalue"); + context.mergeResponseData(Map.of("key", "value")); + context.mergeResponseData(Map.of("key", "newvalue")); + assertThat(context.responseData()).hasSize(1); + assertThat(context.responseData().get("key")).isEqualTo("newvalue"); + DataRequest request1 = new DataRequest("fqn1"); + DataRequest request2 = new DataRequest("fqn2"); + context.setReceivedData( + Map.of(request1, Map.of("content", "pdf", "length", 125), request2, Map.of("content", "json"))); + assertThat(context.receivedData().get(request1).get("content")).isEqualTo("pdf"); + assertThat(context.receivedData().get(request1).get("length")).isEqualTo(125); + assertThat(context.receivedData().get(request2).get("content")).isEqualTo("json"); + } + + @Test + @DisplayName("test received data handling") + void testReceivedData() { + DataContext context = new DataContextImpl(); + DataRequest request1 = new DataRequest("target1", new DataQuery()); + DataRequest request2 = new DataRequest("target2", new DataQuery()); + DataRequest request3 = new DataRequest("target2", new DataQuery()); + + context.setReceivedData(Map.of(request1, Map.of("key1", "value1"), request2, Map.of("key2", "value2"), request3, + Map.of("key3", "value3"))); + assertThat(context.findReceivedData(request1).get("key1")).isEqualTo("value1"); + assertThat(context.findFirstReceivedData(request1.getQualifiedName()).get().get("key1")).isEqualTo("value1"); + assertThat(context.findAllReceivedData(request2.getQualifiedName())).hasSize(2); } private int contextPathSize(DataContext context) { diff --git a/src/test/java/io/neonbee/data/internal/ResponseMetadataIntegrationTest.java b/src/test/java/io/neonbee/data/internal/ResponseMetadataIntegrationTest.java new file mode 100644 index 00000000..d9c617a3 --- /dev/null +++ b/src/test/java/io/neonbee/data/internal/ResponseMetadataIntegrationTest.java @@ -0,0 +1,163 @@ +package io.neonbee.data.internal; + +import static com.google.common.truth.Truth.assertThat; +import static io.vertx.core.Future.succeededFuture; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.neonbee.NeonBeeDeployable; +import io.neonbee.data.DataContext; +import io.neonbee.data.DataMap; +import io.neonbee.data.DataQuery; +import io.neonbee.data.DataRequest; +import io.neonbee.data.DataVerticle; +import io.neonbee.test.base.DataVerticleTestBase; +import io.vertx.core.Future; +import io.vertx.core.json.JsonObject; +import io.vertx.junit5.Timeout; +import io.vertx.junit5.VertxTestContext; + +class ResponseDataIntegrationTest extends DataVerticleTestBase { + @Test + @Timeout(value = 2, timeUnit = TimeUnit.SECONDS) + @DisplayName("Check that response metadata is properly propagated") + void testResponseDataPropagation(VertxTestContext testContext) { + DataContext dataContext = + new DataContextImpl("corr", "sess", "bearer", new JsonObject(), Map.of("key", "value")); + DataRequest request = new DataRequest("Caller", new DataQuery()); + deployVerticle(new DataVerticleCallee(true)).compose(de -> deployVerticle(new DataVerticleCaller(true))) + .compose(de -> deployVerticle(new DataVerticleIntermediary(true))) + .compose(de -> requestData(request, dataContext)) + .onComplete(testContext.succeeding(result -> testContext.verify(() -> { + assertThat(result).isEqualTo("Response from caller"); + assertThat(dataContext.responseData().get("calleeHint")).isEqualTo("Callee"); + assertThat(dataContext.responseData().get("intermediaryHint")).isEqualTo("Intermediary"); + assertThat(dataContext.responseData().get("downstreamIntermediaryHint")).isEqualTo("Callee"); + assertThat(dataContext.responseData().get("callerHint")).isEqualTo("Caller"); + assertThat(dataContext.responseData().get("contentType")).isEqualTo("YML"); + testContext.completeNow(); + }))); + } + + @Test + @Timeout(value = 2, timeUnit = TimeUnit.SECONDS) + @DisplayName("Check that response metadata should not be propagated") + void testResponseDataNoPropagation(VertxTestContext testContext) { + DataContext dataContext = + new DataContextImpl("corr", "sess", "bearer", new JsonObject(), Map.of("key", "value")); + DataRequest request = new DataRequest("Caller", new DataQuery()); + deployVerticle(new DataVerticleCallee(false)).compose(de -> deployVerticle(new DataVerticleIntermediary(false))) + .compose(de -> deployVerticle(new DataVerticleCaller(true))) + .compose(de -> requestData(request, dataContext)) + .onComplete(testContext.succeeding(result -> testContext.verify(() -> { + assertThat(result).isEqualTo("Response from caller"); + assertThat(dataContext.responseData().get("calleeHint")).isNull(); + assertThat(dataContext.responseData().get("intermediaryHint")).isEqualTo("Intermediary"); + assertThat(dataContext.responseData().get("downstreamIntermediaryHint")).isEqualTo("Callee"); + assertThat(dataContext.responseData().get("callerHint")).isEqualTo("Caller"); + assertThat(dataContext.responseData().get("contentType")).isEqualTo("YML"); + + testContext.completeNow(); + }))); + } + + @NeonBeeDeployable + private static class DataVerticleCallee extends DataVerticle { + public static final String NAME = "Callee"; + + private final boolean propagateResponseData; + + DataVerticleCallee(boolean propagateResponseData) { + super(); + this.propagateResponseData = propagateResponseData; + } + + @Override + public String getName() { + return NAME; + } + + @Override + public Future retrieveData(DataQuery query, DataMap require, DataContext context) { + if (propagateResponseData) { + context.propagateReceivedData(); + } + context.responseData().put("calleeHint", "Callee"); + context.responseData().put("contentType", "JSON"); + return succeededFuture("Response from callee"); + } + } + + @NeonBeeDeployable + private static class DataVerticleIntermediary extends DataVerticle { + public static final String NAME = "Intermediary"; + + private final boolean propagateResponseData; + + DataVerticleIntermediary(boolean propagateResponseData) { + super(); + this.propagateResponseData = propagateResponseData; + } + + @Override + public String getName() { + return NAME; + } + + @Override + public Future> requireData(DataQuery query, DataContext context) { + return succeededFuture(List.of(new DataRequest("Callee"))); + } + + @Override + public Future retrieveData(DataQuery query, DataMap require, DataContext context) { + if (propagateResponseData) { + context.propagateReceivedData(); + } + context.responseData().put("intermediaryHint", "Intermediary"); + context.responseData().put("contentType", "XML"); + context.responseData().put("downstreamIntermediaryHint", + context.findFirstReceivedData("Callee").map(data -> data.get("calleeHint")).orElse("")); + return succeededFuture("Response from intermediary"); + } + } + + @NeonBeeDeployable + private static class DataVerticleCaller extends DataVerticle { + public static final String NAME = "Caller"; + + private final boolean propagateResponseData; + + DataVerticleCaller(boolean propagateResponseData) { + super(); + this.propagateResponseData = propagateResponseData; + } + + @Override + public String getName() { + return NAME; + } + + @Override + public Future> requireData(DataQuery query, DataContext context) { + return succeededFuture(List.of(new DataRequest("Intermediary"))); + } + + @Override + public Future retrieveData(DataQuery query, DataMap require, DataContext context) { + if (propagateResponseData) { + context.propagateReceivedData(); + } + context.responseData().put("callerHint", "Caller"); + context.responseData().put("contentType", "YML"); + return succeededFuture("Response from caller"); + } + } + +} diff --git a/src/test/java/io/neonbee/endpoint/odatav4/ODataV4EndpointTest.java b/src/test/java/io/neonbee/endpoint/odatav4/ODataV4EndpointTest.java index 77512358..d54867df 100644 --- a/src/test/java/io/neonbee/endpoint/odatav4/ODataV4EndpointTest.java +++ b/src/test/java/io/neonbee/endpoint/odatav4/ODataV4EndpointTest.java @@ -212,6 +212,19 @@ void testURIPathExtraction(VertxTestContext testContext) { .onComplete(testContext.succeeding(v -> {})); } + @Test + @Timeout(value = 200, timeUnit = TimeUnit.SECONDS) + @DisplayName("Test OData response hint") + void testODataResponseHint(VertxTestContext testContext) { + EntityVerticle dummy = createDummyEntityVerticle(TEST_USERS).withDynamicResponse((dataQuery, dataContext) -> { + dataContext.responseData().put("OData.filter", Boolean.TRUE); + return new EntityWrapper(TEST_USERS, (Entity) null); + }); + + deployVerticle(dummy).compose(v -> requestOData(new ODataRequest(TEST_USERS))) + .onComplete(testContext.succeeding(result -> testContext.verify(() -> testContext.completeNow()))); + } + private static void assertTS1Handler(Buffer body) { assertThat(body.toString()).contains("Namespace=\"io.neonbee.handler.TestService\""); } diff --git a/src/test/java/io/neonbee/endpoint/odatav4/internal/olingo/processor/ProcessorHelperTest.java b/src/test/java/io/neonbee/endpoint/odatav4/internal/olingo/processor/ProcessorHelperTest.java new file mode 100644 index 00000000..dbbb591f --- /dev/null +++ b/src/test/java/io/neonbee/endpoint/odatav4/internal/olingo/processor/ProcessorHelperTest.java @@ -0,0 +1,43 @@ +package io.neonbee.endpoint.odatav4.internal.olingo.processor; + +import static com.google.common.truth.Truth.assertThat; +import static io.neonbee.endpoint.odatav4.internal.olingo.processor.ProcessorHelper.ODATA_EXPAND_KEY; +import static io.neonbee.endpoint.odatav4.internal.olingo.processor.ProcessorHelper.ODATA_FILTER_KEY; +import static io.neonbee.endpoint.odatav4.internal.olingo.processor.ProcessorHelper.ODATA_SKIP_KEY; +import static io.neonbee.endpoint.odatav4.internal.olingo.processor.ProcessorHelper.ODATA_TOP_KEY; +import static io.neonbee.endpoint.odatav4.internal.olingo.processor.ProcessorHelper.RESPONSE_HEADER_PREFIX; + +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import io.neonbee.data.DataContext; +import io.neonbee.data.internal.DataContextImpl; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.impl.HttpServerRequestInternal; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.impl.RouterImpl; +import io.vertx.ext.web.impl.RoutingContextImpl; + +class ProcessorHelperTest { + + @Test + @DisplayName("Response hints should be transferred to routing context") + void transferResponseHint() { + HttpServerRequest request = Mockito.mock(HttpServerRequestInternal.class); + Mockito.when(request.path()).thenReturn("/path"); + RouterImpl router = Mockito.mock(RouterImpl.class); + Mockito.when(router.getAllowForward()).thenReturn(null); + RoutingContext routingContext = new RoutingContextImpl(null, router, request, Set.of()); + DataContext dataContext = new DataContextImpl(); + dataContext.responseData().put(ODATA_FILTER_KEY, Boolean.TRUE); + dataContext.responseData().put(ODATA_EXPAND_KEY, Boolean.FALSE); + ProcessorHelper.transferResponseHint(dataContext, routingContext); + assertThat(routingContext.get(RESPONSE_HEADER_PREFIX + ODATA_FILTER_KEY)).isTrue(); + assertThat(routingContext.get(RESPONSE_HEADER_PREFIX + ODATA_EXPAND_KEY)).isFalse(); + assertThat(routingContext.get(RESPONSE_HEADER_PREFIX + ODATA_SKIP_KEY)).isNull(); + assertThat(routingContext.get(RESPONSE_HEADER_PREFIX + ODATA_TOP_KEY)).isNull(); + } +}