From 2c5823e961ea737fb5eec71683f215772c71fda7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Wed, 6 Jun 2018 10:36:27 +0200 Subject: [PATCH] Add json enricher and json field filter --- pom.xml | 1 + scm-core/pom.xml | 12 +- .../main/java/sonia/scm/web/JsonEnricher.java | 10 ++ .../sonia/scm/web/JsonEnricherContext.java | 31 +++++ .../main/java/sonia/scm/web/VndMediaType.java | 28 ++++ scm-webapp/pom.xml | 1 - .../main/java/sonia/scm/ScmServletModule.java | 61 +++------ .../scm/api/rest/JSONContextResolver.java | 33 +---- .../scm/api/rest/ObjectMapperProvider.java | 40 ++++++ .../api/v2/FieldContainerResponseFilter.java | 70 ++++++++++ .../java/sonia/scm/api/v2/JsonFilters.java | 85 ++++++++++++ .../api/v2/JsonMarshallingResponseFilter.java | 60 +++++++++ .../scm/api/v2/resources/ScmMediaType.java | 5 - .../v2/resources/UserCollectionResource.java | 4 +- .../scm/api/v2/resources/UserSubResource.java | 4 +- .../scm/api/rest/JSONContextResolverTest.java | 2 +- .../v2/FieldContainerResponseFilterTest.java | 85 ++++++++++++ .../sonia/scm/api/v2/JsonFiltersTest.java | 83 ++++++++++++ .../v2/JsonMarshallingResponseFilterTest.java | 122 ++++++++++++++++++ .../sonia/scm/it/IntegrationTestUtil.java | 31 ++--- .../sonia/scm/api/v2/filter-test-001.json | 5 + .../sonia/scm/api/v2/filter-test-002.json | 8 ++ .../sonia/scm/api/v2/filter-test-003.json | 13 ++ .../sonia/scm/api/v2/filter-test-004.json | 8 ++ 24 files changed, 699 insertions(+), 103 deletions(-) create mode 100644 scm-core/src/main/java/sonia/scm/web/JsonEnricher.java create mode 100644 scm-core/src/main/java/sonia/scm/web/JsonEnricherContext.java create mode 100644 scm-core/src/main/java/sonia/scm/web/VndMediaType.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/rest/ObjectMapperProvider.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/FieldContainerResponseFilter.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/JsonFilters.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/JsonMarshallingResponseFilter.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/FieldContainerResponseFilterTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/JsonFiltersTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/JsonMarshallingResponseFilterTest.java create mode 100644 scm-webapp/src/test/resources/sonia/scm/api/v2/filter-test-001.json create mode 100644 scm-webapp/src/test/resources/sonia/scm/api/v2/filter-test-002.json create mode 100644 scm-webapp/src/test/resources/sonia/scm/api/v2/filter-test-003.json create mode 100644 scm-webapp/src/test/resources/sonia/scm/api/v2/filter-test-004.json diff --git a/pom.xml b/pom.xml index 8a6874fd9a..636418bb69 100644 --- a/pom.xml +++ b/pom.xml @@ -484,6 +484,7 @@ 2.0.1 1.19.4 + 2.8.6 4.0 diff --git a/scm-core/pom.xml b/scm-core/pom.xml index 5dfba770e6..56901d6d55 100644 --- a/scm-core/pom.xml +++ b/scm-core/pom.xml @@ -82,7 +82,17 @@ javax.ws.rs-api ${jaxrs.version} - + + com.fasterxml.jackson.core + jackson-core + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + diff --git a/scm-core/src/main/java/sonia/scm/web/JsonEnricher.java b/scm-core/src/main/java/sonia/scm/web/JsonEnricher.java new file mode 100644 index 0000000000..9b0f078f8b --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/web/JsonEnricher.java @@ -0,0 +1,10 @@ +package sonia.scm.web; + +import sonia.scm.plugin.ExtensionPoint; + +@ExtensionPoint +public interface JsonEnricher { + + void enrich(JsonEnricherContext context); + +} diff --git a/scm-core/src/main/java/sonia/scm/web/JsonEnricherContext.java b/scm-core/src/main/java/sonia/scm/web/JsonEnricherContext.java new file mode 100644 index 0000000000..6dd88d326c --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/web/JsonEnricherContext.java @@ -0,0 +1,31 @@ +package sonia.scm.web; + +import com.fasterxml.jackson.databind.JsonNode; + +import javax.ws.rs.core.MediaType; +import java.net.URI; + +public class JsonEnricherContext { + + private URI requestUri; + private MediaType responseMediaType; + private JsonNode responseEntity; + + public JsonEnricherContext(URI requestUri, MediaType responseMediaType, JsonNode responseEntity) { + this.requestUri = requestUri; + this.responseMediaType = responseMediaType; + this.responseEntity = responseEntity; + } + + public URI getRequestUri() { + return requestUri; + } + + public MediaType getResponseMediaType() { + return responseMediaType; + } + + public JsonNode getResponseEntity() { + return responseEntity; + } +} diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java new file mode 100644 index 0000000000..92b85b022f --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -0,0 +1,28 @@ +package sonia.scm.web; + +import javax.ws.rs.core.MediaType; + +public class VndMediaType { + private static final String VERSION = "2"; + private static final String TYPE = "application"; + private static final String SUBTYPE_PREFIX = "vnd.scmm-"; + private static final String PREFIX = TYPE + "/" + SUBTYPE_PREFIX; + private static final String SUFFIX = "+json;v=" + VERSION; + + public static final String USER = PREFIX + "user" + SUFFIX; + + private VndMediaType() { + } + + public static MediaType jsonType(String resource) { + return MediaType.valueOf(json(resource)); + } + + public static String json(String resource) { + return PREFIX + resource + SUFFIX;// ".v2+json"; + } + + public static boolean isVndType(MediaType type) { + return type.getType().equals(TYPE) && type.getSubtype().startsWith(SUBTYPE_PREFIX); + } +} diff --git a/scm-webapp/pom.xml b/scm-webapp/pom.xml index de837dd322..4a01375ef4 100644 --- a/scm-webapp/pom.xml +++ b/scm-webapp/pom.xml @@ -560,7 +560,6 @@ 1.0 0.8.17 3.1.3.Final - 2.8.6 Tomcat e1 javascript:S3827 diff --git a/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java index 8d89f2b2eb..2f6e7341bb 100644 --- a/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java +++ b/scm-webapp/src/main/java/sonia/scm/ScmServletModule.java @@ -35,16 +35,16 @@ package sonia.scm; //~--- non-JDK imports -------------------------------------------------------- +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.inject.Provider; import com.google.inject.multibindings.Multibinder; import com.google.inject.name.Names; import com.google.inject.servlet.RequestScoped; import com.google.inject.servlet.ServletModule; import com.google.inject.throwingproviders.ThrowingProviderBinder; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; - +import sonia.scm.api.rest.ObjectMapperProvider; import sonia.scm.cache.CacheManager; import sonia.scm.cache.GuavaCacheManager; import sonia.scm.config.ScmConfiguration; @@ -56,18 +56,13 @@ import sonia.scm.group.GroupManagerProvider; import sonia.scm.group.xml.XmlGroupDAO; import sonia.scm.io.DefaultFileSystem; import sonia.scm.io.FileSystem; +import sonia.scm.net.SSLContextProvider; +import sonia.scm.net.ahc.*; import sonia.scm.plugin.DefaultPluginLoader; import sonia.scm.plugin.DefaultPluginManager; import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginManager; -import sonia.scm.repository.DefaultRepositoryManager; -import sonia.scm.repository.DefaultRepositoryProvider; -import sonia.scm.repository.HealthCheckContextListener; -import sonia.scm.repository.Repository; -import sonia.scm.repository.RepositoryDAO; -import sonia.scm.repository.RepositoryManager; -import sonia.scm.repository.RepositoryManagerProvider; -import sonia.scm.repository.RepositoryProvider; +import sonia.scm.repository.*; import sonia.scm.repository.api.HookContextFactory; import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.repository.spi.HookEventFacade; @@ -76,28 +71,15 @@ import sonia.scm.resources.DefaultResourceManager; import sonia.scm.resources.DevelopmentResourceManager; import sonia.scm.resources.ResourceManager; import sonia.scm.resources.ScriptResourceServlet; -import sonia.scm.security.CipherHandler; -import sonia.scm.security.CipherUtil; -import sonia.scm.security.DefaultKeyGenerator; -import sonia.scm.security.DefaultSecuritySystem; -import sonia.scm.security.KeyGenerator; -import sonia.scm.security.SecuritySystem; -import sonia.scm.store.BlobStoreFactory; -import sonia.scm.store.ConfigurationEntryStoreFactory; -import sonia.scm.store.DataStoreFactory; -import sonia.scm.store.FileBlobStoreFactory; -import sonia.scm.store.JAXBConfigurationEntryStoreFactory; -import sonia.scm.store.JAXBDataStoreFactory; -import sonia.scm.store.JAXBConfigurationStoreFactory; +import sonia.scm.schedule.QuartzScheduler; +import sonia.scm.schedule.Scheduler; +import sonia.scm.security.*; +import sonia.scm.store.*; import sonia.scm.template.MustacheTemplateEngine; import sonia.scm.template.TemplateEngine; import sonia.scm.template.TemplateEngineFactory; import sonia.scm.template.TemplateServlet; -import sonia.scm.url.RestJsonUrlProvider; -import sonia.scm.url.RestXmlUrlProvider; -import sonia.scm.url.UrlProvider; -import sonia.scm.url.UrlProviderFactory; -import sonia.scm.url.WebUIUrlProvider; +import sonia.scm.url.*; import sonia.scm.user.DefaultUserManager; import sonia.scm.user.UserDAO; import sonia.scm.user.UserManager; @@ -105,31 +87,17 @@ import sonia.scm.user.UserManagerProvider; import sonia.scm.user.xml.XmlUserDAO; import sonia.scm.util.DebugServlet; import sonia.scm.util.ScmConfigurationUtil; +import sonia.scm.web.UserAgentParser; import sonia.scm.web.cgi.CGIExecutorFactory; import sonia.scm.web.cgi.DefaultCGIExecutorFactory; import sonia.scm.web.filter.LoggingFilter; import sonia.scm.web.security.AdministrationContext; import sonia.scm.web.security.DefaultAdministrationContext; -//~--- JDK imports ------------------------------------------------------------ - - -import javax.servlet.ServletContext; -import sonia.scm.store.ConfigurationStoreFactory; - import javax.net.ssl.SSLContext; -import sonia.scm.net.SSLContextProvider; -import sonia.scm.net.ahc.AdvancedHttpClient; -import sonia.scm.net.ahc.ContentTransformer; -import sonia.scm.net.ahc.DefaultAdvancedHttpClient; -import sonia.scm.net.ahc.JsonContentTransformer; -import sonia.scm.net.ahc.XmlContentTransformer; -import sonia.scm.schedule.QuartzScheduler; -import sonia.scm.schedule.Scheduler; -import sonia.scm.security.ConfigurableLoginAttemptHandler; -import sonia.scm.security.LoginAttemptHandler; -import sonia.scm.security.AuthorizationChangedEventProducer; -import sonia.scm.web.UserAgentParser; +import javax.servlet.ServletContext; + +//~--- JDK imports ------------------------------------------------------------ /** * @@ -354,6 +322,7 @@ public class ScmServletModule extends ServletModule bind(TemplateEngine.class).annotatedWith(Default.class).to( MustacheTemplateEngine.class); bind(TemplateEngineFactory.class); + bind(ObjectMapper.class).toProvider(ObjectMapperProvider.class); // bind events // bind(LastModifiedUpdateListener.class); diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/JSONContextResolver.java b/scm-webapp/src/main/java/sonia/scm/api/rest/JSONContextResolver.java index 8a72911bc9..1dc1090604 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/rest/JSONContextResolver.java +++ b/scm-webapp/src/main/java/sonia/scm/api/rest/JSONContextResolver.java @@ -30,18 +30,9 @@ */ package sonia.scm.api.rest; -import com.fasterxml.jackson.databind.AnnotationIntrospector; -import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.introspect.AnnotationIntrospectorPair; -import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector; -import com.fasterxml.jackson.databind.type.TypeFactory; -import com.fasterxml.jackson.databind.util.ISO8601DateFormat; -import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.fasterxml.jackson.module.jaxb.JaxbAnnotationIntrospector; +import javax.inject.Inject; import javax.ws.rs.Produces; import javax.ws.rs.ext.ContextResolver; import javax.ws.rs.ext.Provider; @@ -58,27 +49,13 @@ public final class JSONContextResolver implements ContextResolver private final ObjectMapper mapper; - public JSONContextResolver() { - mapper = new ObjectMapper() - .registerModule(new Jdk8Module()) - .registerModule(new JavaTimeModule()); - mapper.setAnnotationIntrospector(createAnnotationIntrospector()); - mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); - mapper.configure(SerializationFeature.WRITE_DATES_WITH_ZONE_ID, true); - mapper.setDateFormat(new ISO8601DateFormat()); + @Inject + public JSONContextResolver(ObjectMapper mapper) { + this.mapper = mapper; } - - private AnnotationIntrospector createAnnotationIntrospector() { - return new AnnotationIntrospectorPair( - new JaxbAnnotationIntrospector(TypeFactory.defaultInstance()), - new JacksonAnnotationIntrospector() - ); - } - + @Override public ObjectMapper getContext(Class type) { return mapper; } - } diff --git a/scm-webapp/src/main/java/sonia/scm/api/rest/ObjectMapperProvider.java b/scm-webapp/src/main/java/sonia/scm/api/rest/ObjectMapperProvider.java new file mode 100644 index 0000000000..68522234d3 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/rest/ObjectMapperProvider.java @@ -0,0 +1,40 @@ +package sonia.scm.api.rest; + +import com.fasterxml.jackson.databind.AnnotationIntrospector; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.introspect.AnnotationIntrospectorPair; +import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.fasterxml.jackson.databind.util.ISO8601DateFormat; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.module.jaxb.JaxbAnnotationIntrospector; + +import javax.inject.Provider; +import javax.inject.Singleton; + +@Singleton +public class ObjectMapperProvider implements Provider { + + @Override + public ObjectMapper get() { + ObjectMapper mapper = new ObjectMapper() + .registerModule(new Jdk8Module()) + .registerModule(new JavaTimeModule()); + mapper.setAnnotationIntrospector(createAnnotationIntrospector()); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + mapper.configure(SerializationFeature.WRITE_DATES_WITH_ZONE_ID, true); + mapper.setDateFormat(new ISO8601DateFormat()); + return mapper; + } + + private AnnotationIntrospector createAnnotationIntrospector() { + return new AnnotationIntrospectorPair( + new JaxbAnnotationIntrospector(TypeFactory.defaultInstance()), + new JacksonAnnotationIntrospector() + ); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/FieldContainerResponseFilter.java b/scm-webapp/src/main/java/sonia/scm/api/v2/FieldContainerResponseFilter.java new file mode 100644 index 0000000000..4ba93a31d5 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/FieldContainerResponseFilter.java @@ -0,0 +1,70 @@ +package sonia.scm.api.v2; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.collect.Lists; + +import javax.annotation.Priority; +import javax.ws.rs.Priorities; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerResponseContext; +import javax.ws.rs.container.ContainerResponseFilter; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.ext.Provider; +import java.util.List; +import java.util.Optional; + +@Provider +@Priority(Priorities.USER) +public class FieldContainerResponseFilter implements ContainerResponseFilter { + + private static final String PARAMETER_FIELDS = "fields"; + private static final String FIELD_SEPARATOR = ","; + + @Override + public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) { + Optional entity = getJsonEntity(responseContext); + if (entity.isPresent()) { + List fields = extractFieldsFrom(requestContext); + if (!fields.isEmpty()) { + JsonFilters.filterByFields(entity.get(), fields); + } + } + } + + private Optional getJsonEntity(ContainerResponseContext responseContext) { + Object entity = responseContext.getEntity(); + if (isJsonEntity(entity)) { + return Optional.of((JsonNode) entity); + } + return Optional.empty(); + } + + private boolean isJsonEntity(Object entity) { + return entity instanceof JsonNode; + } + + private List extractFieldsFrom(ContainerRequestContext requestContext) { + List fields = Lists.newArrayList(); + + List fieldParameters = getFieldParameterFrom(requestContext); + if (fieldParameters != null && !fieldParameters.isEmpty()) { + for (String fieldParameter : fieldParameters) { + appendFieldsFromParameter(fields, fieldParameter); + } + } + + return fields; + } + + private List getFieldParameterFrom(ContainerRequestContext requestContext) { + MultivaluedMap queryParameters = requestContext.getUriInfo().getQueryParameters(); + return queryParameters.get(PARAMETER_FIELDS); + } + + private void appendFieldsFromParameter(List fields, String fieldParameter) { + for (String field : fieldParameter.split(FIELD_SEPARATOR)) { + fields.add(field); + } + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/JsonFilters.java b/scm-webapp/src/main/java/sonia/scm/api/v2/JsonFilters.java new file mode 100644 index 0000000000..a06e3118b7 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/JsonFilters.java @@ -0,0 +1,85 @@ +package sonia.scm.api.v2; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.collect.Maps; + +import java.util.Iterator; +import java.util.Map; + +public final class JsonFilters { + + private JsonFilters() { + } + + public static void filterByFields(JsonNode root, Iterable fields) { + filterNode(createJsonFilterNode(fields), root); + } + + private static JsonFilterNode createJsonFilterNode(Iterable fields) { + JsonFilterNode rootFilterNode = new JsonFilterNode(); + for (String field : fields) { + appendFilterNode(rootFilterNode, field); + } + return rootFilterNode; + } + + private static void appendFilterNode(JsonFilterNode rootFilterNode, String field) { + JsonFilterNode filterNode = rootFilterNode; + for (String part : field.split("\\.")) { + filterNode = filterNode.addOrGet(part); + } + } + + private static void filterNode(JsonFilterNode filter, JsonNode node) { + if (node.isObject()) { + filterObjectNode(filter, (ObjectNode) node); + } else if (node.isArray()) { + filterArrayNode(filter, (ArrayNode) node); + } + } + + private static void filterObjectNode(JsonFilterNode filter, ObjectNode objectNode) { + Iterator> entryIterator = objectNode.fields(); + while (entryIterator.hasNext()) { + Map.Entry entry = entryIterator.next(); + + JsonFilterNode childFilter = filter.get(entry.getKey()); + if (childFilter == null) { + entryIterator.remove(); + } else if (!childFilter.isLeaf()) { + filterNode(childFilter, entry.getValue()); + } + } + } + + private static void filterArrayNode(JsonFilterNode filter, ArrayNode arrayNode) { + for (int i=0; i children = Maps.newHashMap(); + + JsonFilterNode addOrGet(String name) { + JsonFilterNode child = children.get(name); + if (child == null) { + child = new JsonFilterNode(); + children.put(name, child); + } + return child; + } + + JsonFilterNode get(String name) { + return children.get(name); + } + + boolean isLeaf() { + return children.isEmpty(); + } + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/JsonMarshallingResponseFilter.java b/scm-webapp/src/main/java/sonia/scm/api/v2/JsonMarshallingResponseFilter.java new file mode 100644 index 0000000000..3be159dbf8 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/JsonMarshallingResponseFilter.java @@ -0,0 +1,60 @@ +package sonia.scm.api.v2; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import sonia.scm.web.JsonEnricher; +import sonia.scm.web.JsonEnricherContext; +import sonia.scm.web.VndMediaType; + +import javax.annotation.Priority; +import javax.inject.Inject; +import javax.ws.rs.Priorities; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerResponseContext; +import javax.ws.rs.container.ContainerResponseFilter; +import javax.ws.rs.ext.Provider; +import java.util.Set; + +@Provider +@Priority(Priorities.USER + 1000) +public class JsonMarshallingResponseFilter implements ContainerResponseFilter { + + private final ObjectMapper objectMapper; + private final Set enrichers; + + @Inject + public JsonMarshallingResponseFilter(ObjectMapper objectMapper, Set enrichers) { + this.objectMapper = objectMapper; + this.enrichers = enrichers; + } + + @Override + public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) { + if (hasVndEntity(responseContext)) { + JsonNode node = getJsonEntity(responseContext); + callEnrichers(requestContext, responseContext, node); + responseContext.setEntity(node); + } + } + + private void callEnrichers(ContainerRequestContext requestContext, ContainerResponseContext responseContext, JsonNode node) { + JsonEnricherContext context = new JsonEnricherContext( + requestContext.getUriInfo().getRequestUri(), + responseContext.getMediaType(), + node + ); + + for (JsonEnricher enricher : enrichers) { + enricher.enrich(context); + } + } + + private JsonNode getJsonEntity(ContainerResponseContext responseContext) { + Object entity = responseContext.getEntity(); + return objectMapper.valueToTree(entity); + } + + private boolean hasVndEntity(ContainerResponseContext responseContext) { + return responseContext.hasEntity() && VndMediaType.isVndType(responseContext.getMediaType()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ScmMediaType.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ScmMediaType.java index 81310dd4df..d9d8f0ecf1 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ScmMediaType.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ScmMediaType.java @@ -1,9 +1,4 @@ package sonia.scm.api.v2.resources; public class ScmMediaType { - private static final String VERSION = "2"; - private static final String PREFIX = "application/vnd.scmm-"; - private static final String SUFFIX = "+json;v=" + VERSION; - - public static final String USER = PREFIX + "user" + SUFFIX; } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserCollectionResource.java index f3462eadda..f26c90b957 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserCollectionResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserCollectionResource.java @@ -11,6 +11,7 @@ import sonia.scm.api.rest.resources.AbstractManagerResource; import sonia.scm.user.User; import sonia.scm.user.UserException; import sonia.scm.user.UserManager; +import sonia.scm.web.VndMediaType; import javax.ws.rs.*; import javax.ws.rs.core.*; @@ -20,10 +21,9 @@ import java.util.List; import java.util.stream.Collectors; import static de.otto.edison.hal.paging.NumberedPaging.zeroBasedNumberedPaging; -import static sonia.scm.api.v2.resources.ScmMediaType.USER; @Singleton -@Produces(USER) +@Produces(VndMediaType.USER) public class UserCollectionResource extends AbstractManagerResource { public static final int DEFAULT_PAGE_SIZE = 10; private final UserDto2UserMapper dtoToUserMapper; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserSubResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserSubResource.java index 46789b2deb..3f5a1752b5 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserSubResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserSubResource.java @@ -11,15 +11,15 @@ import sonia.scm.security.Role; import sonia.scm.user.User; import sonia.scm.user.UserException; import sonia.scm.user.UserManager; +import sonia.scm.web.VndMediaType; import javax.ws.rs.*; import javax.ws.rs.core.*; import java.util.Collection; -import static sonia.scm.api.v2.resources.ScmMediaType.USER; @Singleton -@Produces(USER) +@Produces(VndMediaType.USER) public class UserSubResource extends AbstractManagerResource { private final UserDto2UserMapper dtoToUserMapper; private final User2UserDtoMapper userToDtoMapper; diff --git a/scm-webapp/src/test/java/sonia/scm/api/rest/JSONContextResolverTest.java b/scm-webapp/src/test/java/sonia/scm/api/rest/JSONContextResolverTest.java index dabf75b2b0..8e3b39edc7 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/rest/JSONContextResolverTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/rest/JSONContextResolverTest.java @@ -59,7 +59,7 @@ import static org.junit.Assert.*; */ public class JSONContextResolverTest { - private final ObjectMapper mapper = new JSONContextResolver().getContext(Object.class); + private final ObjectMapper mapper = new ObjectMapperProvider().get(); /** * Tests json unmarshalling with unknown properties. diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/FieldContainerResponseFilterTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/FieldContainerResponseFilterTest.java new file mode 100644 index 0000000000..3855c380b2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/FieldContainerResponseFilterTest.java @@ -0,0 +1,85 @@ +package sonia.scm.api.v2; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.Lists; +import com.google.common.io.Resources; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerResponseContext; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.UriInfo; +import java.io.IOException; +import java.net.URL; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class FieldContainerResponseFilterTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Mock + private ContainerRequestContext requestContext; + + @Mock + private ContainerResponseContext responseContext; + + private FieldContainerResponseFilter filter = new FieldContainerResponseFilter(); + + @Test + public void testFilter() throws IOException { + applyFields("one"); + JsonNode node = applyEntity("filter-test-002"); + + filter.filter(requestContext, responseContext); + + assertEquals("{\"one\":1}", objectMapper.writeValueAsString(node)); + } + + @Test + public void testFilterWithMultiple() throws IOException { + applyFields("one", "five"); + JsonNode node = applyEntity("filter-test-002"); + + filter.filter(requestContext, responseContext); + + assertEquals("{\"one\":1,\"five\":5}", objectMapper.writeValueAsString(node)); + } + + @Test + public void testFilterCommaSeparated() throws IOException { + applyFields("one,five"); + JsonNode node = applyEntity("filter-test-002"); + + filter.filter(requestContext, responseContext); + + assertEquals("{\"one\":1,\"five\":5}", objectMapper.writeValueAsString(node)); + } + + private void applyFields(String... fields) { + UriInfo info = mock(UriInfo.class); + MultivaluedMap queryParameters = mock(MultivaluedMap.class); + when(queryParameters.get("fields")).thenReturn(Lists.newArrayList(fields)); + when(info.getQueryParameters()).thenReturn(queryParameters); + when(requestContext.getUriInfo()).thenReturn(info); + } + + private JsonNode applyEntity(String name) throws IOException { + JsonNode node = readJson(name); + when(responseContext.hasEntity()).thenReturn(Boolean.TRUE); + when(responseContext.getEntity()).thenReturn(node); + return node; + } + + private JsonNode readJson(String name) throws IOException { + URL resource = Resources.getResource("sonia/scm/api/v2/" + name + ".json"); + return objectMapper.readTree(resource); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/JsonFiltersTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/JsonFiltersTest.java new file mode 100644 index 0000000000..bfa280d821 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/JsonFiltersTest.java @@ -0,0 +1,83 @@ +package sonia.scm.api.v2; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.google.common.collect.Lists; +import com.google.common.io.Resources; +import org.junit.Test; + +import java.io.IOException; +import java.net.URL; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class JsonFiltersTest { + + private ObjectMapper objectMapper = new ObjectMapper(); + + @Test + public void testFilterByFields() throws IOException { + JsonNode node = readJson("filter-test-001"); + + JsonFilters.filterByFields(node, Lists.newArrayList("one")); + + assertEquals(1, node.get("one").intValue()); + assertFalse(node.has("two")); + assertFalse(node.has("three")); + } + + @Test + public void testFilterByFieldsWithMultipleFields() throws IOException { + JsonNode node = readJson("filter-test-001"); + + JsonFilters.filterByFields(node, Lists.newArrayList("one", "three")); + + assertEquals(1, node.get("one").intValue()); + assertFalse(node.has("two")); + assertEquals(3, node.get("three").intValue()); + } + + @Test + public void testFilterByFieldsWithNonPrimitive() throws IOException { + JsonNode node = readJson("filter-test-002"); + JsonFilters.filterByFields(node, Lists.newArrayList("two")); + assertEquals("{\"two\":{\"three\":3,\"four\":4}}", objectMapper.writeValueAsString(node)); + } + + @Test + public void testFilterByFieldsWithDeepField() throws IOException { + JsonNode node = readJson("filter-test-002"); + JsonFilters.filterByFields(node, Lists.newArrayList("two.three")); + assertEquals("{\"two\":{\"three\":3}}", objectMapper.writeValueAsString(node)); + } + + @Test + public void testFilterByFieldsWithVeryDeepField() throws IOException { + JsonNode node = readJson("filter-test-003"); + JsonFilters.filterByFields(node, Lists.newArrayList("two.three.four.five")); + assertFalse(node.has("one")); + String json = objectMapper.writeValueAsString(node.get("two").get("three").get("four").get("five")); + assertEquals("{\"six\":6,\"seven\":7}", json); + } + + @Test + public void testFilterByFieldsWithArray() throws IOException { + JsonNode node = readJson("filter-test-004"); + JsonFilters.filterByFields(node, Lists.newArrayList("one.two")); + ArrayNode one = (ArrayNode) node.get("one"); + assertEquals(one.size(), 2); + for (int i=0; i jsonNodeCaptor; + + private final ObjectMapper mapper = new ObjectMapper(); + + private Set enrichers; + + private JsonMarshallingResponseFilter filter; + + @Before + public void setUpObjectUnderTest() throws URISyntaxException { + this.enrichers = new HashSet<>(); + filter = new JsonMarshallingResponseFilter(mapper, enrichers); + + when(requestContext.getUriInfo()).thenReturn(uriInfo); + when(uriInfo.getRequestUri()).thenReturn(new URI("https://www.scm-manager.org/scm/api/v2/repositories")); + } + + @Test + public void testFilter() { + when(responseContext.hasEntity()).thenReturn(Boolean.TRUE); + when(responseContext.getEntity()).thenReturn(new JsonMarshallingResponseFilterTest.Sample("one-two-three")); + when(responseContext.getMediaType()).thenReturn(VndMediaType.jsonType("sample")); + + filter.filter(requestContext, responseContext); + + verify(responseContext).setEntity(jsonNodeCaptor.capture()); + + JsonNode node = jsonNodeCaptor.getValue(); + assertEquals("one-two-three", node.get("value").asText()); + } + + @Test + public void testFilterWithEnricher() { + enrichers.add(context -> { + JsonNode node = context.getResponseEntity(); + if (node.isObject()) { + ((ObjectNode)node).put("version", 2); + } + }); + + when(responseContext.hasEntity()).thenReturn(Boolean.TRUE); + when(responseContext.getEntity()).thenReturn(new JsonMarshallingResponseFilterTest.Sample("one-two-three")); + when(responseContext.getMediaType()).thenReturn(VndMediaType.jsonType("sample")); + + filter.filter(requestContext, responseContext); + + verify(responseContext).setEntity(jsonNodeCaptor.capture()); + + JsonNode node = jsonNodeCaptor.getValue(); + assertEquals(2, node.get("version").asInt()); + } + + @Test + public void testFilterWithoutEntity() { + filter.filter(requestContext, responseContext); + verify(responseContext, never()).setEntity(Mockito.anyObject()); + } + + @Test + public void testFilterWithNonVndEntity() { + when(responseContext.hasEntity()).thenReturn(Boolean.TRUE); + when(responseContext.getEntity()).thenReturn(new JsonMarshallingResponseFilterTest.Sample("one-two-three")); + when(responseContext.getMediaType()).thenReturn(MediaType.APPLICATION_JSON_TYPE); + + filter.filter(requestContext, responseContext); + verify(responseContext, never()).setEntity(Mockito.anyObject()); + } + + public static class Sample { + + private String value; + + public Sample(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/it/IntegrationTestUtil.java b/scm-webapp/src/test/java/sonia/scm/it/IntegrationTestUtil.java index dbafbb44f7..e12057e47a 100644 --- a/scm-webapp/src/test/java/sonia/scm/it/IntegrationTestUtil.java +++ b/scm-webapp/src/test/java/sonia/scm/it/IntegrationTestUtil.java @@ -35,37 +35,34 @@ package sonia.scm.it; //~--- non-JDK imports -------------------------------------------------------- -import sonia.scm.ScmState; -import sonia.scm.Type; -import sonia.scm.user.User; -import sonia.scm.util.IOUtil; - -import static org.junit.Assert.*; - -//~--- JDK imports ------------------------------------------------------------ - import com.sun.jersey.api.client.Client; import com.sun.jersey.api.client.ClientResponse; import com.sun.jersey.api.client.WebResource; -import com.sun.jersey.api.client.filter.LoggingFilter; import com.sun.jersey.client.apache.ApacheHttpClient; import com.sun.jersey.client.apache.config.ApacheHttpClientConfig; import com.sun.jersey.client.apache.config.DefaultApacheHttpClientConfig; import com.sun.jersey.core.util.MultivaluedMapImpl; +import sonia.scm.ScmState; +import sonia.scm.Type; +import sonia.scm.api.rest.JSONContextResolver; +import sonia.scm.api.rest.ObjectMapperProvider; +import sonia.scm.repository.Person; +import sonia.scm.repository.client.api.ClientCommand; +import sonia.scm.repository.client.api.RepositoryClient; +import sonia.scm.user.User; +import sonia.scm.util.IOUtil; +import javax.ws.rs.core.MultivaluedMap; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; - import java.util.ArrayList; import java.util.Collection; import java.util.UUID; -import javax.ws.rs.core.MultivaluedMap; -import sonia.scm.api.rest.JSONContextResolver; -import sonia.scm.repository.Person; -import sonia.scm.repository.client.api.ClientCommand; -import sonia.scm.repository.client.api.RepositoryClient; +import static org.junit.Assert.*; + +//~--- JDK imports ------------------------------------------------------------ /** * @@ -175,7 +172,7 @@ public final class IntegrationTestUtil public static Client createClient() { DefaultApacheHttpClientConfig config = new DefaultApacheHttpClientConfig(); - config.getSingletons().add(new JSONContextResolver()); + config.getSingletons().add(new JSONContextResolver(new ObjectMapperProvider().get())); config.getProperties().put(ApacheHttpClientConfig.PROPERTY_HANDLE_COOKIES, true); return ApacheHttpClient.create(config); diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/filter-test-001.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/filter-test-001.json new file mode 100644 index 0000000000..120cbb2840 --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/filter-test-001.json @@ -0,0 +1,5 @@ +{ + "one": 1, + "two": 2, + "three": 3 +} diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/filter-test-002.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/filter-test-002.json new file mode 100644 index 0000000000..489d211a17 --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/filter-test-002.json @@ -0,0 +1,8 @@ +{ + "one": 1, + "two": { + "three": 3, + "four": 4 + }, + "five": 5 +} diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/filter-test-003.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/filter-test-003.json new file mode 100644 index 0000000000..8262837427 --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/filter-test-003.json @@ -0,0 +1,13 @@ +{ + "one": 1, + "two": { + "three": { + "four": { + "five": { + "six": 6, + "seven": 7 + } + } + } + } +} diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/filter-test-004.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/filter-test-004.json new file mode 100644 index 0000000000..2a8cb40db7 --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/filter-test-004.json @@ -0,0 +1,8 @@ +{ + "one": [{ + "two": 2 + }, { + "two": 2, + "three": 3 + }] +}