feat(manager-api): support searching collections for `@BlobResource` and other nested scenarios
test(manager-api): add tests for `@BlobResource` interceptor
Fix test fixture filename that was relying on OS being case insensitive by default
Ignore fields annotated by
MODIFY
manager/api/rest-impl/pom.xml
manager/api/rest-impl/pom.xml
<groupId>com.hazelcast</groupId>
<artifactId>hazelcast</artifactId>
</dependency>
-
+ <!-- Test -->
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.assertj</groupId>
+ <artifactId>assertj-core</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<build>

MODIFY
manager/api/rest-impl/src/main/java/io/apiman/manager/api/rest/interceptors/BlobResourceInterceptorProvider.java
manager/api/rest-impl/src/main/java/io/apiman/manager/api/rest/interceptors/BlobResourceInterceptorProvider.java
@@ -7,20 +7,29 @@ import io.apiman.manager.api.rest.IBlobResource;
import java.io.IOException;
import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
import java.util.ArrayDeque;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
+import java.util.stream.Stream;
+import javax.transaction.Transactional;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.ext.Provider;
import javax.ws.rs.ext.WriterInterceptor;
import javax.ws.rs.ext.WriterInterceptorContext;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import org.apache.commons.lang3.ClassUtils;
import org.jboss.resteasy.annotations.interception.ServerInterceptor;
-import org.reflections.ReflectionUtils;
+
+import static org.apache.commons.lang3.reflect.FieldUtils.getAllFields;
/**
* Rewrites a blob's UID into a URL that can be resolved by the browser.
@@ -45,6 +54,7 @@ import org.reflections.ReflectionUtils;
*/
@Provider
@ServerInterceptor
+@Transactional // TODO(msavy): Need this while we still return managed beans at the presentation layer, could consider removing when we go full DTO
public class BlobResourceInterceptorProvider implements WriterInterceptor {
private static final IApimanLogger LOGGER = ApimanLoggerFactory.getLogger(BlobResourceInterceptorProvider.class);
@@ -101,31 +111,70 @@ public class BlobResourceInterceptorProvider implements WriterInterceptor {
List<FieldAndEntity> results = new ArrayList<>();
Set<Object> objectsSeen = new HashSet<>();
Deque<Object> nodesToSearch = new ArrayDeque<>();
- nodesToSearch.push(root);
+ resolveValue(root).forEach(nodesToSearch::push);
while (!nodesToSearch.isEmpty()) {
Object currentNode = nodesToSearch.pop();
- for (Field f : ReflectionUtils.getAllFields(currentNode.getClass())) {
- f.setAccessible(true);
+
+ // If is collection-like, then don't scan fields. Just get the values and continue.
+ if (isCollectionLike(currentNode)) {
+ resolveValue(currentNode).forEach(nodesToSearch::add);
+ continue;
+ }
+
+ // If not collection-like, then we'll inspect the fields and see whether there's anything worth looking at.
+ // Must not have seen this before (i.e. simple cycle detection).
+ if (!currentNode.getClass().isEnum() && !objectsSeen.contains(currentNode)) {
+ for (Field f : getAllFields(currentNode.getClass())) {
+ boolean isAccessible = f.trySetAccessible();
if (f.isAnnotationPresent(BlobReference.class) && f.getType().equals(String.class)) {
results.add(new FieldAndEntity(f, currentNode));
- } else {
- if (!f.getType().isEnum()
- // Is an Apiman entity.
- && f.getType().getCanonicalName().startsWith("io.apiman")
- // Must not have seen this before (i.e. simple cycle detection).
- && !objectsSeen.contains(currentNode)) {
+ }
+ if (isAccessible && !shouldBeIgnored(f, currentNode)) {
Object value = f.get(currentNode);
if (value != null) {
- nodesToSearch.push(value);
- objectsSeen.add(value);
+ resolveValue(value).forEach(nodesToSearch::push);
}
}
}
+ objectsSeen.add(currentNode);
+
}
}
return results;
}
+ private boolean shouldBeIgnored(Field f, Object currentNode) {
+ int modifiers = f.getModifiers();
+ return Modifier.isStatic(modifiers)
+ || ClassUtils.isPrimitiveOrWrapper(f.getType())
+ || f.getType().isAnnotation()
+ || Modifier.isAbstract(modifiers)
+ || Modifier.isInterface(modifiers)
+ || Modifier.isTransient(modifiers)
+ || f.isEnumConstant()
+ || !f.canAccess(currentNode)
+ || f.isAnnotationPresent(JsonIgnore.class);
+ }
+
+ private boolean isCollectionLike(Object obj) {
+ return obj instanceof Map || obj instanceof Collection;
+ }
+
+ @SuppressWarnings("unchecked")
+ private Stream<Object> resolveValue(Object obj) {
+ if (obj == null) {
+ return Stream.empty();
+ } else if (obj instanceof Map) { // TODO not sure if we also need to inspect the keys. Seem a tad unlikely.
+ return ((Map<Object, Object>) obj).values().stream();
+ } else if (obj instanceof Collection) {
+ return ((Collection<Object>) obj).stream();
+ } else if (obj instanceof Object[]) {
+ return Arrays.stream((Object[])obj);
+ } else {
+ return Stream.of(obj);
+ }
+ }
+
private static final class FieldAndEntity {
public Field field;
public Object entity;

ADD
manager/api/rest-impl/src/test/java/io/apiman/manager/api/rest/interceptors/BlobResourceInterceptorProviderTest.java
+package io.apiman.manager.api.rest.interceptors;
+
+import io.apiman.common.util.Holder;
+import io.apiman.manager.api.beans.apis.ApiBean;
+import io.apiman.manager.api.beans.apis.ApiVersionBean;
+import io.apiman.manager.api.beans.search.SearchResultsBean;
+import io.apiman.manager.api.rest.interceptors.BlobResourceInterceptorProvider;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Type;
+import java.util.Collection;
+import java.util.List;
+import javax.ws.rs.WebApplicationException;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.ext.WriterInterceptorContext;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+
+/**
+ *
+ *
+ * @author Marc Savy {@literal <marc@blackparrotlabs.io>}
+ */
+public class BlobResourceInterceptorProviderTest {
+
+ @Test
+ public void simple_object() throws IOException {
+ ApiBean ab = new ApiBean();
+ ab.setImage("blobaroni/foo-bar-baz_jpeg");
+
+ BlobResourceInterceptorProvider interceptorProvider = new BlobResourceInterceptorProvider();
+ interceptorProvider.aroundWriteTo(new DummyWriterContext(ab));
+
+ assertThat(ab.getImage()).isEqualTo("blobs/blobaroni/foo-bar-baz_jpeg");
+ }
+
+ @Test
+ public void simple_nested_structures_with_collection_traversal() throws IOException {
+ ApiBean ab = new ApiBean();
+ ab.setImage("blobaroni/foo-bar-baz_jpeg");
+
+ ApiVersionBean avb = new ApiVersionBean();
+ avb.setApi(ab);
+
+ SearchResultsBean<ApiVersionBean> searchResults = new SearchResultsBean<ApiVersionBean>()
+ .setTotalSize(1)
+ .setBeans(List.of(avb));
+
+ BlobResourceInterceptorProvider interceptorProvider = new BlobResourceInterceptorProvider();
+ interceptorProvider.aroundWriteTo(new DummyWriterContext(searchResults));
+
+ assertThat(avb.getApi().getImage()).isEqualTo("blobs/blobaroni/foo-bar-baz_jpeg");
+ }
+
+ @Test
+ public void nested_collection_traversal() throws IOException {
+ ApiBean ab = new ApiBean();
+ ab.setImage("blobaroni/foo-bar-baz_jpeg");
+
+ List<ApiBean> listInner = List.of(ab);
+ List<List<ApiBean>> listOuter = List.of(listInner);
+
+ BlobResourceInterceptorProvider interceptorProvider = new BlobResourceInterceptorProvider();
+ interceptorProvider.aroundWriteTo(new DummyWriterContext(listOuter));
+
+ assertThat(ab.getImage()).isEqualTo("blobs/blobaroni/foo-bar-baz_jpeg");
+ }
+
+ @Test
+ public void nested_collection_traversal_with_object_intermediaries() throws IOException {
+ ApiBean ab = new ApiBean();
+ ab.setImage("blobaroni/foo-bar-baz_jpeg");
+
+ List<ApiBean> listInner = List.of(ab);
+
+ Holder<List<ApiBean>> intermediary = new Holder<>();
+ intermediary.setValue(listInner);
+
+ List<Holder<List<ApiBean>>> listOuter = List.of(intermediary);
+
+ BlobResourceInterceptorProvider interceptorProvider = new BlobResourceInterceptorProvider();
+ interceptorProvider.aroundWriteTo(new DummyWriterContext(listOuter));
+
+ assertThat(ab.getImage()).isEqualTo("blobs/blobaroni/foo-bar-baz_jpeg");
+ }
+
+ private static class DummyWriterContext implements WriterInterceptorContext {
+ private final Object anyObject;
+
+ public DummyWriterContext(Object anyObject) {
+ this.anyObject = anyObject;
+ }
+
+ @Override
+ public void proceed() throws IOException, WebApplicationException {
+
+ }
+
+ @Override
+ public Object getEntity() {
+ return anyObject;
+ }
+
+ @Override
+ public void setEntity(Object entity) {
+
+ }
+
+ @Override
+ public OutputStream getOutputStream() {
+ return null;
+ }
+
+ @Override
+ public void setOutputStream(OutputStream os) {
+
+ }
+
+ @Override
+ public MultivaluedMap<String, Object> getHeaders() {
+ return null;
+ }
+
+ @Override
+ public Object getProperty(String name) {
+ return null;
+ }
+
+ @Override
+ public Collection<String> getPropertyNames() {
+ return null;
+ }
+
+ @Override
+ public void setProperty(String name, Object object) {
+
+ }
+
+ @Override
+ public void removeProperty(String name) {
+
+ }
+
+ @Override
+ public Annotation[] getAnnotations() {
+ return new Annotation[0];
+ }
+
+ @Override
+ public void setAnnotations(Annotation[] annotations) {
+
+ }
+
+ @Override
+ public Class<?> getType() {
+ return null;
+ }
+
+ @Override
+ public void setType(Class<?> type) {
+
+ }
+
+ @Override
+ public Type getGenericType() {
+ return null;
+ }
+
+ @Override
+ public void setGenericType(Type genericType) {
+
+ }
+
+ @Override
+ public MediaType getMediaType() {
+ return null;
+ }
+
+ @Override
+ public void setMediaType(MediaType mediaType) {
+
+ }
+ }
+}
\ No newline at end of file

ADD
manager/test/api/src/test/resources/test-plans/devportal-testPlan.xml
+<?xml version="1.0" encoding="UTF-8"?>
+<testPlan xmlns="urn:io.apiman.test:2014:02:testPlan" name="Dev portal tests">
+
+ <!-- Create Organizations -->
+ <testGroup name="Initial Creates">
+ <!-- Setup:
+ Plans: Gold Plan, Silver Plan,
+ APIs: API 1 (both plans attached); PrivateAPI (both plans attached)
+ Api Versions:
+ - API1 - 1.0 - Gold = Exposed + featured
+ - API1 - 2 - No exposed plans
+ - API1 - 3 - Gold + Silver = exposed + featured
+ - PrivateAPI - 1.0 - Not exposed in any way. Should not be found on devportal!
+ -->
+ <test name="Setup via import">test-plan-data/devportal/create/000_setup_via_import.resttest</test>
+ </testGroup>
+
+ <!-- Test dev portal management -->
+ <testGroup name="Dev portal functionality">
+ <test name="Search for all APIs (only APIs with at least 1 exposed version should appear)">test-plan-data/devportal/devportal/000_search_exposed_apis.resttest</test>
+ <test name="Get all featured APIs">test-plan-data/devportal/devportal/001_get-featured-apis.resttest</test>
+ </testGroup>
+</testPlan>
\ No newline at end of file

feat(manager-api): support searching collections for `@BlobResource` and other nested scenarios

test(manager-api): add tests for `@BlobResource` interceptor

Fix test fixture filename that was relying on OS being case insensitive by default

Ignore fields annotated by @JsonIgnore