Merge pull request #2670 from atmire/DS-4433_Specify-embeds

DS-4433 Specify embeds
This commit is contained in:
Tim Donohue
2020-03-09 10:04:00 -05:00
committed by GitHub
14 changed files with 734 additions and 22 deletions

View File

@@ -32,6 +32,7 @@ import org.springframework.core.type.filter.AssignableTypeFilter;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.Resource; import org.springframework.hateoas.Resource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -155,9 +156,30 @@ public class ConverterService {
* @throws ClassCastException if the resource type is not compatible with the inferred return type. * @throws ClassCastException if the resource type is not compatible with the inferred return type.
*/ */
public <T extends HALResource> T toResource(RestModel restObject) { public <T extends HALResource> T toResource(RestModel restObject) {
return toResource(restObject, new Link[] {});
}
/**
* Converts the given rest object to a {@link HALResource} object.
* <p>
* If the rest object is a {@link RestAddressableModel}, the projection returned by
* {@link RestAddressableModel#getProjection()} will be used to determine which optional
* embeds and links will be added, and {@link Projection#transformResource(HALResource)}
* will be automatically called before returning the final, fully converted resource.
* </p><p>
* In all cases, the {@link HalLinkService} will be used immediately after the resource is constructed,
* to ensure all {@link HalLinkFactory}s have had a chance to add links as needed.
* </p>
*
* @param restObject the input rest object.
* @param oldLinks The old links fo the Resource Object
* @param <T> the return type, a subclass of {@link HALResource}.
* @return the fully converted resource, with all automatic links and embeds applied.
*/
public <T extends HALResource> T toResource(RestModel restObject, Link... oldLinks) {
T halResource = getResource(restObject); T halResource = getResource(restObject);
if (restObject instanceof RestAddressableModel) { if (restObject instanceof RestAddressableModel) {
utils.embedOrLinkClassLevelRels(halResource); utils.embedOrLinkClassLevelRels(halResource, oldLinks);
halLinkService.addLinks(halResource); halLinkService.addLinks(halResource);
Projection projection = ((RestAddressableModel) restObject).getProjection(); Projection projection = ((RestAddressableModel) restObject).getProjection();
return projection.transformResource(halResource); return projection.transformResource(halResource);

View File

@@ -8,8 +8,10 @@
package org.dspace.app.rest.projection; package org.dspace.app.rest.projection;
import org.dspace.app.rest.model.LinkRest; import org.dspace.app.rest.model.LinkRest;
import org.dspace.app.rest.model.RestAddressableModel;
import org.dspace.app.rest.model.RestModel; import org.dspace.app.rest.model.RestModel;
import org.dspace.app.rest.model.hateoas.HALResource; import org.dspace.app.rest.model.hateoas.HALResource;
import org.springframework.hateoas.Link;
/** /**
* Abstract base class for projections. * Abstract base class for projections.
@@ -34,7 +36,8 @@ public abstract class AbstractProjection implements Projection {
} }
@Override @Override
public boolean allowEmbedding(HALResource halResource, LinkRest linkRest) { public boolean allowEmbedding(HALResource<? extends RestAddressableModel> halResource, LinkRest linkRest,
Link... oldLinks) {
return false; return false;
} }

View File

@@ -0,0 +1,84 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.app.rest.projection;
import java.util.List;
import org.dspace.app.rest.model.LinkRest;
import org.dspace.app.rest.model.RestAddressableModel;
import org.dspace.app.rest.model.RestModel;
import org.dspace.app.rest.model.hateoas.HALResource;
import org.springframework.hateoas.Link;
/**
* A projection that combines the behavior of multiple projections.
*
* Model, rest, and resource transformations will be performed in the order of the projections given in
* the constructor. Embedding will be allowed if any of the given projections allow them. Linking will
* be allowed if all of the given projections allow them.
*/
public class CompositeProjection implements Projection {
public final static String NAME = "composite";
private final List<Projection> projections;
public CompositeProjection(List<Projection> projections) {
this.projections = projections;
}
@Override
public String getName() {
return NAME;
}
@Override
public <T> T transformModel(T modelObject) {
for (Projection projection : projections) {
modelObject = projection.transformModel(modelObject);
}
return modelObject;
}
@Override
public <T extends RestModel> T transformRest(T restObject) {
for (Projection projection : projections) {
restObject = projection.transformRest(restObject);
}
return restObject;
}
@Override
public <T extends HALResource> T transformResource(T halResource) {
for (Projection projection : projections) {
halResource = projection.transformResource(halResource);
}
return halResource;
}
@Override
public boolean allowEmbedding(HALResource<? extends RestAddressableModel> halResource, LinkRest linkRest,
Link... oldLinks) {
for (Projection projection : projections) {
if (projection.allowEmbedding(halResource, linkRest, oldLinks)) {
return true;
}
}
return false;
}
@Override
public boolean allowLinking(HALResource halResource, LinkRest linkRest) {
for (Projection projection : projections) {
if (!projection.allowLinking(halResource, linkRest)) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,65 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.app.rest.projection;
import java.util.Set;
import org.dspace.app.rest.model.LinkRest;
import org.dspace.app.rest.model.RestAddressableModel;
import org.dspace.app.rest.model.hateoas.HALResource;
import org.springframework.hateoas.Link;
/**
* Projection that allows a given set of rels to be embedded.
* A Rel refers to a Link Relation, this is an Embedded Object of the HalResource and the HalResource contains
* a link to this
*/
public class EmbedRelsProjection extends AbstractProjection {
public final static String NAME = "embedrels";
private final Set<String> embedRels;
public EmbedRelsProjection(Set<String> embedRels) {
this.embedRels = embedRels;
}
@Override
public String getName() {
return NAME;
}
@Override
public boolean allowEmbedding(HALResource<? extends RestAddressableModel> halResource, LinkRest linkRest,
Link... oldLinks) {
// If level 0, and the name is present, the link can be embedded (e.g. the logo on a collection page)
if (halResource.getContent().getEmbedLevel() == 0 && embedRels.contains(linkRest.name())) {
return true;
}
StringBuilder fullName = new StringBuilder();
for (Link oldLink : oldLinks) {
fullName.append(oldLink.getRel()).append("/");
}
fullName.append(linkRest.name());
// If the full name matches, the link can be embedded (e.g. mappedItems/owningCollection on a collection page)
if (embedRels.contains(fullName.toString())) {
return true;
}
fullName.append("/");
// If the full name starts with the allowed embed, but the embed goes deeper, the link can be embedded
// (e.g. making sure mappedItems/owningCollection also embeds mappedItems on a collection page)
for (String embedRel : embedRels) {
if (embedRel.startsWith(fullName.toString())) {
return true;
}
}
return false;
}
}

View File

@@ -8,7 +8,10 @@
package org.dspace.app.rest.projection; package org.dspace.app.rest.projection;
import org.dspace.app.rest.model.LinkRest; import org.dspace.app.rest.model.LinkRest;
import org.dspace.app.rest.model.RestAddressableModel;
import org.dspace.app.rest.model.hateoas.HALResource; import org.dspace.app.rest.model.hateoas.HALResource;
import org.dspace.services.factory.DSpaceServicesFactory;
import org.springframework.hateoas.Link;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
@@ -18,14 +21,17 @@ import org.springframework.stereotype.Component;
public class FullProjection extends AbstractProjection { public class FullProjection extends AbstractProjection {
public final static String NAME = "full"; public final static String NAME = "full";
private final int maxEmbed = DSpaceServicesFactory.getInstance().getConfigurationService()
.getIntProperty("rest.projections.full.max", 2);
public String getName() { public String getName() {
return NAME; return NAME;
} }
@Override @Override
public boolean allowEmbedding(HALResource halResource, LinkRest linkRest) { public boolean allowEmbedding(HALResource<? extends RestAddressableModel> halResource, LinkRest linkRest,
return true; Link... oldLinks) {
return halResource.getContent().getEmbedLevel() < maxEmbed;
} }
@Override @Override

View File

@@ -10,9 +10,12 @@ package org.dspace.app.rest.projection;
import javax.persistence.Entity; import javax.persistence.Entity;
import org.dspace.app.rest.model.LinkRest; import org.dspace.app.rest.model.LinkRest;
import org.dspace.app.rest.model.RestAddressableModel;
import org.dspace.app.rest.model.RestModel; import org.dspace.app.rest.model.RestModel;
import org.dspace.app.rest.model.hateoas.HALResource; import org.dspace.app.rest.model.hateoas.HALResource;
import org.dspace.app.rest.repository.DSpaceRestRepository; import org.dspace.app.rest.repository.DSpaceRestRepository;
import org.dspace.app.rest.utils.Utils;
import org.springframework.hateoas.Link;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -44,7 +47,7 @@ import org.springframework.web.bind.annotation.RestController;
* <li> After it is converted to a {@link RestModel}, the projection may modify it * <li> After it is converted to a {@link RestModel}, the projection may modify it
* via {@link #transformRest(RestModel)}.</li> * via {@link #transformRest(RestModel)}.</li>
* <li> During conversion to a {@link HALResource}, the projection may opt in to certain annotation-discovered * <li> During conversion to a {@link HALResource}, the projection may opt in to certain annotation-discovered
* HAL embeds and links via {@link #allowEmbedding(HALResource, LinkRest)} * HAL embeds and links via {@link #allowEmbedding(HALResource, LinkRest, Link...)}
* and {@link #allowLinking(HALResource, LinkRest)}</li> * and {@link #allowLinking(HALResource, LinkRest)}</li>
* <li> After conversion to a {@link HALResource}, the projection may modify it * <li> After conversion to a {@link HALResource}, the projection may modify it
* via {@link #transformResource(HALResource)}.</li> * via {@link #transformResource(HALResource)}.</li>
@@ -52,8 +55,7 @@ import org.springframework.web.bind.annotation.RestController;
* *
* <h2>How a projection is chosen</h2> * <h2>How a projection is chosen</h2>
* *
* When a REST request is made, the projection argument, if present, is used to look up the projection to use, * See {@link Utils#obtainProjection()}.
* by name. If no argument is present, {@link DefaultProjection} will be used.
*/ */
public interface Projection { public interface Projection {
@@ -115,16 +117,18 @@ public interface Projection {
* *
* @param halResource the resource from which the embed may or may not be made. * @param halResource the resource from which the embed may or may not be made.
* @param linkRest the LinkRest annotation through which the related resource was discovered on the rest object. * @param linkRest the LinkRest annotation through which the related resource was discovered on the rest object.
* @param oldLinks The previously traversed links
* @return true if allowed, false otherwise. * @return true if allowed, false otherwise.
*/ */
boolean allowEmbedding(HALResource halResource, LinkRest linkRest); boolean allowEmbedding(HALResource<? extends RestAddressableModel> halResource, LinkRest linkRest,
Link... oldLinks);
/** /**
* Tells whether this projection permits the linking of a particular linkable subresource. * Tells whether this projection permits the linking of a particular linkable subresource.
* *
* This gives the projection an opportunity to opt in to to certain links, by returning {@code true}. * This gives the projection an opportunity to opt in to to certain links, by returning {@code true}.
* *
* Note: If {@link #allowEmbedding(HALResource, LinkRest)} returns {@code true} for a given subresource, * Note: If {@link #allowEmbedding(HALResource, LinkRest, Link...)} returns {@code true} for a given subresource,
* it will be automatically linked regardless of what this method returns. * it will be automatically linked regardless of what this method returns.
* *
* @param halResource the resource from which the link may or may not be made. * @param halResource the resource from which the link may or may not be made.

View File

@@ -0,0 +1,69 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
package org.dspace.app.rest.projection;
import org.apache.commons.lang3.StringUtils;
import org.dspace.app.rest.exception.MissingParameterException;
import org.dspace.app.rest.model.LinkRest;
import org.dspace.app.rest.model.RestAddressableModel;
import org.dspace.app.rest.model.hateoas.HALResource;
import org.dspace.services.RequestService;
import org.dspace.services.factory.DSpaceServicesFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.hateoas.Link;
/**
* This Projection will allow us to specify how many levels deep we're going to embed resources onto the requested
* HalResource.
* The projection is used by using the name combined with the embedLevelDepth parameter to specify how deep the embeds
* have to go. There is an upperlimit in place for this, which is specified on the bean through the maxEmbed property
*/
public class SpecificLevelProjection extends AbstractProjection {
@Autowired
private RequestService requestService;
public final static String NAME = "level";
private int maxEmbed = DSpaceServicesFactory.getInstance().getConfigurationService()
.getIntProperty("rest.projections.full.max", 2);
public int getMaxEmbed() {
return maxEmbed;
}
public void setMaxEmbed(int maxEmbed) {
this.maxEmbed = maxEmbed;
}
@Override
public String getName() {
return NAME;
}
@Override
public boolean allowEmbedding(HALResource<? extends RestAddressableModel> halResource, LinkRest linkRest,
Link... oldLinks) {
String embedLevelDepthString = requestService.getCurrentRequest().getHttpServletRequest()
.getParameter("embedLevelDepth");
if (StringUtils.isBlank(embedLevelDepthString)) {
throw new MissingParameterException("The embedLevelDepth parameter needs to be specified" +
" for this Projection");
}
Integer embedLevelDepth = Integer.parseInt(embedLevelDepthString);
if (embedLevelDepth > maxEmbed) {
throw new IllegalArgumentException("The embedLevelDepth may not exceed the configured max: " + maxEmbed);
}
return halResource.getContent().getEmbedLevel() < embedLevelDepth;
}
@Override
public boolean allowLinking(HALResource halResource, LinkRest linkRest) {
return true;
}
}

View File

@@ -27,6 +27,7 @@ import java.sql.SQLException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -37,6 +38,7 @@ import java.util.Set;
import java.util.TreeSet; import java.util.TreeSet;
import java.util.UUID; import java.util.UUID;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
@@ -56,7 +58,9 @@ import org.dspace.app.rest.model.RestModel;
import org.dspace.app.rest.model.hateoas.DSpaceResource; import org.dspace.app.rest.model.hateoas.DSpaceResource;
import org.dspace.app.rest.model.hateoas.EmbeddedPage; import org.dspace.app.rest.model.hateoas.EmbeddedPage;
import org.dspace.app.rest.model.hateoas.HALResource; import org.dspace.app.rest.model.hateoas.HALResource;
import org.dspace.app.rest.projection.CompositeProjection;
import org.dspace.app.rest.projection.DefaultProjection; import org.dspace.app.rest.projection.DefaultProjection;
import org.dspace.app.rest.projection.EmbedRelsProjection;
import org.dspace.app.rest.projection.Projection; import org.dspace.app.rest.projection.Projection;
import org.dspace.app.rest.repository.DSpaceRestRepository; import org.dspace.app.rest.repository.DSpaceRestRepository;
import org.dspace.app.rest.repository.LinkRestRepository; import org.dspace.app.rest.repository.LinkRestRepository;
@@ -98,7 +102,7 @@ public class Utils {
/** /**
* The maximum number of embed levels to allow. * The maximum number of embed levels to allow.
*/ */
private static final int EMBED_MAX_LEVELS = 2; private static final int EMBED_MAX_LEVELS = 10;
@Autowired @Autowired
ApplicationContext applicationContext; ApplicationContext applicationContext;
@@ -439,28 +443,87 @@ public class Utils {
} }
/** /**
* Gets the projection requested by the current servlet request, or {@link DefaultProjection} if none * Gets the effective projection requested by the current servlet request, or {@link DefaultProjection} if none
* is specified. * is specified.
* <p>
* Any number of individual {@code Projections} that are spring-registered {@link Component}s may be specified
* by name via the {@code projection} parameter. If multiple projections are specified, they will be wrapped in a
* {@link CompositeProjection} and applied in order as described there.
* </p><p>
* In addition, any number of embeds may be specified by rel name via the {@code embed} parameter.
* When provided, these act as a whitelist of embeds that may be included in the response, as described
* and implemented by {@link EmbedRelsProjection}.
* </p>
* *
* @return the requested or default projection, never {@code null}. * @return the requested or default projection, never {@code null}.
* @throws IllegalArgumentException if the request specifies an unknown projection name. * @throws IllegalArgumentException if the request specifies an unknown projection name.
*/ */
public Projection obtainProjection() { public Projection obtainProjection() {
String projectionName = requestService.getCurrentRequest().getServletRequest().getParameter("projection"); ServletRequest servletRequest = requestService.getCurrentRequest().getServletRequest();
return converter.getProjection(projectionName); List<String> projectionNames = getValues(servletRequest, "projection");
Set<String> embedRels = new HashSet<>(getValues(servletRequest, "embed"));
List<Projection> projections = new ArrayList<>();
for (String projectionName : projectionNames) {
projections.add(converter.getProjection(projectionName));
}
if (!embedRels.isEmpty()) {
projections.add(new EmbedRelsProjection(embedRels));
}
if (projections.isEmpty()) {
return Projection.DEFAULT;
} else if (projections.size() == 1) {
return projections.get(0);
} else {
return new CompositeProjection(projections);
}
}
/**
* Gets zero or more values for the given servlet request parameter.
* <p>
* This convenience method reads multiple values that have been specified as request parameter in multiple ways:
* via * {@code ?paramName=value1&paramName=value2}, via {@code ?paramName=value1,value2},
* or a combination.
* </p><p>
* It provides the values in the order they were given in the request, and automatically de-dupes them.
* </p>
*
* @param servletRequest the servlet request.
* @param parameterName the parameter name.
* @return the ordered, de-duped values, possibly empty, never {@code null}.
*/
private List<String> getValues(ServletRequest servletRequest, String parameterName) {
String[] rawValues = servletRequest.getParameterValues(parameterName);
List<String> values = new ArrayList<>();
if (rawValues != null) {
for (String rawValue : rawValues) {
for (String value : rawValue.split(",")) {
String trimmedValue = value.trim();
if (trimmedValue.length() > 0 && !values.contains(trimmedValue)) {
values.add(trimmedValue);
}
}
}
}
return values;
} }
/** /**
* Adds embeds or links for all class-level LinkRel annotations for which embeds or links are allowed. * Adds embeds or links for all class-level LinkRel annotations for which embeds or links are allowed.
* *
* @param halResource the resource. * @param halResource the resource.
* @param oldLinks previously traversed links
*/ */
public void embedOrLinkClassLevelRels(HALResource<RestAddressableModel> halResource) { public void embedOrLinkClassLevelRels(HALResource<RestAddressableModel> halResource, Link... oldLinks) {
Projection projection = halResource.getContent().getProjection(); Projection projection = halResource.getContent().getProjection();
getLinkRests(halResource.getContent().getClass()).stream().forEach((linkRest) -> { getLinkRests(halResource.getContent().getClass()).stream().forEach((linkRest) -> {
Link link = linkToSubResource(halResource.getContent(), linkRest.name()); Link link = linkToSubResource(halResource.getContent(), linkRest.name());
if (projection.allowEmbedding(halResource, linkRest)) { if (projection.allowEmbedding(halResource, linkRest, oldLinks)) {
embedRelFromRepository(halResource, linkRest.name(), link, linkRest); embedRelFromRepository(halResource, linkRest.name(), link, linkRest, oldLinks);
halResource.add(link); // unconditionally link if embedding was allowed halResource.add(link); // unconditionally link if embedding was allowed
} else if (projection.allowLinking(halResource, linkRest)) { } else if (projection.allowLinking(halResource, linkRest)) {
halResource.add(link); halResource.add(link);
@@ -499,6 +562,32 @@ public class Utils {
*/ */
void embedRelFromRepository(HALResource<? extends RestAddressableModel> resource, void embedRelFromRepository(HALResource<? extends RestAddressableModel> resource,
String rel, Link link, LinkRest linkRest) { String rel, Link link, LinkRest linkRest) {
embedRelFromRepository(resource, rel, link, linkRest, new Link[] {});
}
/**
* Embeds a rel whose value comes from a {@link LinkRestRepository}, if the maximum embed level has not
* been exceeded yet.
* <p>
* The embed will be skipped if 1) the link repository reports that it is not embeddable or 2) the returned
* value is null and the LinkRest annotation has embedOptional = true.
* </p><p>
* Implementation note: The caller is responsible for ensuring that the projection allows the embed
* before calling this method.
* </p>
*
* @param resource the resource from which the embed will be made.
* @param rel the name of the rel.
* @param link the link.
* @param linkRest the LinkRest annotation (must have method defined).
* @param oldLinks The previously traversed links
* @throws RepositoryNotFoundException if the link repository could not be found.
* @throws IllegalArgumentException if the method specified by the LinkRest could not be found in the
* link repository.
* @throws RuntimeException if any other problem occurs when trying to invoke the method.
*/
void embedRelFromRepository(HALResource<? extends RestAddressableModel> resource,
String rel, Link link, LinkRest linkRest, Link... oldLinks) {
if (resource.getContent().getEmbedLevel() == EMBED_MAX_LEVELS) { if (resource.getContent().getEmbedLevel() == EMBED_MAX_LEVELS) {
return; return;
} }
@@ -510,7 +599,7 @@ public class Utils {
Object contentId = getContentIdForLinkMethod(resource.getContent(), method); Object contentId = getContentIdForLinkMethod(resource.getContent(), method);
try { try {
Object linkedObject = method.invoke(linkRepository, null, contentId, null, projection); Object linkedObject = method.invoke(linkRepository, null, contentId, null, projection);
resource.embedResource(rel, wrapForEmbedding(resource, linkedObject, link)); resource.embedResource(rel, wrapForEmbedding(resource, linkedObject, link, oldLinks));
} catch (InvocationTargetException e) { } catch (InvocationTargetException e) {
if (e.getTargetException() instanceof RuntimeException) { if (e.getTargetException() instanceof RuntimeException) {
throw (RuntimeException) e.getTargetException(); throw (RuntimeException) e.getTargetException();
@@ -615,17 +704,37 @@ public class Utils {
*/ */
private Object wrapForEmbedding(HALResource<? extends RestAddressableModel> resource, private Object wrapForEmbedding(HALResource<? extends RestAddressableModel> resource,
Object linkedObject, Link link) { Object linkedObject, Link link) {
return wrapForEmbedding(resource, linkedObject, link, new Link[] {});
}
/**
* Wraps the given linked object (retrieved from a link repository or link method on the rest item)
* in an object that is appropriate for embedding, if needed. Does not perform the actual embed; the
* caller is responsible for that.
*
* @param resource the resource from which the embed will be made.
* @param linkedObject the linked object.
* @param link the link, which is used if the linked object is a list or page, to determine the self link
* and embed property name to use for the subresource.
* @param oldLinks The previously traversed links
* @return the wrapped object, which will have an "embed level" one greater than the given parent resource.
*/
private Object wrapForEmbedding(HALResource<? extends RestAddressableModel> resource,
Object linkedObject, Link link, Link... oldLinks) {
int childEmbedLevel = resource.getContent().getEmbedLevel() + 1; int childEmbedLevel = resource.getContent().getEmbedLevel() + 1;
//Add the latest link to the list
Link[] newList = Arrays.copyOf(oldLinks, oldLinks.length + 1);
newList[oldLinks.length] = link;
if (linkedObject instanceof RestAddressableModel) { if (linkedObject instanceof RestAddressableModel) {
RestAddressableModel restObject = (RestAddressableModel) linkedObject; RestAddressableModel restObject = (RestAddressableModel) linkedObject;
restObject.setEmbedLevel(childEmbedLevel); restObject.setEmbedLevel(childEmbedLevel);
return converter.toResource(restObject); return converter.toResource(restObject, newList);
} else if (linkedObject instanceof Page) { } else if (linkedObject instanceof Page) {
// The first page has already been constructed by a link repository and we only need to wrap it // The first page has already been constructed by a link repository and we only need to wrap it
Page<RestAddressableModel> page = (Page<RestAddressableModel>) linkedObject; Page<RestAddressableModel> page = (Page<RestAddressableModel>) linkedObject;
return new EmbeddedPage(link.getHref(), page.map((restObject) -> { return new EmbeddedPage(link.getHref(), page.map((restObject) -> {
restObject.setEmbedLevel(childEmbedLevel); restObject.setEmbedLevel(childEmbedLevel);
return converter.toResource(restObject); return converter.toResource(restObject, newList);
}), null, link.getRel()); }), null, link.getRel());
} else if (linkedObject instanceof List) { } else if (linkedObject instanceof List) {
// The full list has been retrieved and we need to provide the first page for embedding // The full list has been retrieved and we need to provide the first page for embedding
@@ -637,7 +746,7 @@ public class Utils {
return new EmbeddedPage(link.getHref(), return new EmbeddedPage(link.getHref(),
page.map((restObject) -> { page.map((restObject) -> {
restObject.setEmbedLevel(childEmbedLevel); restObject.setEmbedLevel(childEmbedLevel);
return converter.toResource(restObject); return converter.toResource(restObject, newList);
}), }),
list, link.getRel()); list, link.getRel());
} else { } else {

View File

@@ -26,6 +26,7 @@ import org.dspace.app.rest.builder.CollectionBuilder;
import org.dspace.app.rest.builder.CommunityBuilder; import org.dspace.app.rest.builder.CommunityBuilder;
import org.dspace.app.rest.converter.ConverterService; import org.dspace.app.rest.converter.ConverterService;
import org.dspace.app.rest.matcher.CollectionMatcher; import org.dspace.app.rest.matcher.CollectionMatcher;
import org.dspace.app.rest.matcher.CommunityMatcher;
import org.dspace.app.rest.matcher.HalMatcher; import org.dspace.app.rest.matcher.HalMatcher;
import org.dspace.app.rest.matcher.MetadataMatcher; import org.dspace.app.rest.matcher.MetadataMatcher;
import org.dspace.app.rest.matcher.PageMatcher; import org.dspace.app.rest.matcher.PageMatcher;
@@ -940,4 +941,132 @@ public class CollectionRestRepositoryIT extends AbstractControllerIntegrationTes
.andExpect(jsonPath("$.page", PageMatcher.pageEntryWithTotalPagesAndElements(0, 20, .andExpect(jsonPath("$.page", PageMatcher.pageEntryWithTotalPagesAndElements(0, 20,
1, 2))); 1, 2)));
} }
@Test
public void projectonLevelTest() throws Exception {
//We turn off the authorization system in order to create the structure as defined below
context.turnOffAuthorisationSystem();
//** GIVEN **
//1. A community-collection structure with one parent community with sub-community and one collection.
parentCommunity = CommunityBuilder.createCommunity(context)
.withName("Parent Community")
.build();
Community child1 = CommunityBuilder.createSubCommunity(context, parentCommunity)
.withName("Sub Community")
.build();
Community child1child = CommunityBuilder.createSubCommunity(context, child1)
.withName("Sub Community Two")
.build();
Collection col1 = CollectionBuilder.createCollection(context, child1)
.withName("Collection 1")
.withLogo("TestingContentForLogo")
.build();
Collection col2 = CollectionBuilder.createCollection(context, child1child).withName("Collection 2").build();
getClient().perform(get("/api/core/collections/" + col1.getID())
.param("projection", "level")
.param("embedLevelDepth", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", CollectionMatcher.matchCollectionEntry(col1.getName(),
col1.getID(),
col1.getHandle())))
// .exists() makes sure that the embed is there, but it could be empty
.andExpect(jsonPath("$._embedded.mappedItems").exists())
// .isEmpty() makes sure that the embed is there, but that there's no actual data
.andExpect(jsonPath("$._embedded.mappedItems._embedded.mappedItems").isEmpty())
.andExpect(jsonPath("$._embedded.parentCommunity",
CommunityMatcher.matchCommunityEntry(child1.getName(),
child1.getID(),
child1.getHandle())))
// .doesNotExist() makes sure that this section is not embedded, it's not there at all
.andExpect(jsonPath("$._embedded.parentCommunity._embedded.subcommunities").doesNotExist())
.andExpect(jsonPath("$._embedded.logo", Matchers.not(Matchers.empty())))
// .doesNotExist() makes sure that this section is not embedded, it's not there at all
.andExpect(jsonPath("$._embedded.logo._embedded.format").doesNotExist());
getClient().perform(get("/api/core/collections/" + col1.getID())
.param("projection", "level")
.param("embedLevelDepth", "3"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", CollectionMatcher.matchCollectionEntry(col1.getName(),
col1.getID(),
col1.getHandle())))
// .exists() makes sure that the embed is there, but it could be empty
.andExpect(jsonPath("$._embedded.mappedItems").exists())
// .isEmpty() makes sure that the embed is there, but that there's no actual data
.andExpect(jsonPath("$._embedded.mappedItems._embedded.mappedItems").isEmpty())
.andExpect(jsonPath("$._embedded.parentCommunity",
CommunityMatcher.matchCommunityEntry(child1.getName(),
child1.getID(),
child1.getHandle())))
// .exists() makes sure that the embed is there, but it could be empty
.andExpect(jsonPath("$._embedded.parentCommunity._embedded.subcommunities").exists())
.andExpect(jsonPath("$._embedded.parentCommunity._embedded.subcommunities._embedded.subcommunities",
Matchers.contains(CommunityMatcher.matchCommunityEntry(child1child.getID(),
child1child.getHandle())
)))
.andExpect(jsonPath("$._embedded.parentCommunity._embedded.subcommunities" +
"._embedded.subcommunities[0]._embedded.collections._embedded.collections",
Matchers.contains(CollectionMatcher.matchCollectionEntry(col2.getName(),
col2.getID(),
col2.getHandle())
)))
// .doesNotExist() makes sure that this section is not embedded, it's not there at all
.andExpect(jsonPath("$._embedded.parentCommunity._embedded.subcommunities" +
"._embedded.subcommunities[0]._embedded.collections._embedded" +
".collections[0]._embedded.logo").doesNotExist())
.andExpect(jsonPath("$._embedded.logo", Matchers.not(Matchers.empty())))
// .exists() makes sure that the embed is there, but it could be empty
.andExpect(jsonPath("$._embedded.logo._embedded.format").exists());
}
@Test
public void projectonLevelEmbedLevelDepthHigherThanEmbedMaxBadRequestTest() throws Exception {
//We turn off the authorization system in order to create the structure as defined below
context.turnOffAuthorisationSystem();
//** GIVEN **
//1. A community-collection structure with one parent community with sub-community and one collection.
parentCommunity = CommunityBuilder.createCommunity(context)
.withName("Parent Community")
.build();
Community child1 = CommunityBuilder.createSubCommunity(context, parentCommunity)
.withName("Sub Community")
.build();
Community child1child = CommunityBuilder.createSubCommunity(context, child1)
.withName("Sub Community Two")
.build();
Collection col1 = CollectionBuilder.createCollection(context, child1)
.withName("Collection 1")
.withLogo("TestingContentForLogo")
.build();
getClient().perform(get("/api/core/collections/" + col1.getID())
.param("projection", "level")
.param("embedLevelDepth", "100"))
.andExpect(status().isBadRequest());
}
@Test
public void projectonLevelEmbedLevelDepthNotPresentBadRequestTest() throws Exception {
//We turn off the authorization system in order to create the structure as defined below
context.turnOffAuthorisationSystem();
//** GIVEN **
//1. A community-collection structure with one parent community with sub-community and one collection.
parentCommunity = CommunityBuilder.createCommunity(context)
.withName("Parent Community")
.build();
Community child1 = CommunityBuilder.createSubCommunity(context, parentCommunity)
.withName("Sub Community")
.build();
Community child1child = CommunityBuilder.createSubCommunity(context, child1)
.withName("Sub Community Two")
.build();
Collection col1 = CollectionBuilder.createCollection(context, child1)
.withName("Collection 1")
.withLogo("TestingContentForLogo")
.build();
getClient().perform(get("/api/core/collections/" + col1.getID())
.param("projection", "level"))
.andExpect(status().isBadRequest());
}
} }

View File

@@ -36,6 +36,8 @@ import org.dspace.app.rest.builder.EPersonBuilder;
import org.dspace.app.rest.builder.GroupBuilder; import org.dspace.app.rest.builder.GroupBuilder;
import org.dspace.app.rest.builder.ItemBuilder; import org.dspace.app.rest.builder.ItemBuilder;
import org.dspace.app.rest.builder.WorkspaceItemBuilder; import org.dspace.app.rest.builder.WorkspaceItemBuilder;
import org.dspace.app.rest.matcher.BitstreamMatcher;
import org.dspace.app.rest.matcher.CollectionMatcher;
import org.dspace.app.rest.matcher.HalMatcher; import org.dspace.app.rest.matcher.HalMatcher;
import org.dspace.app.rest.matcher.ItemMatcher; import org.dspace.app.rest.matcher.ItemMatcher;
import org.dspace.app.rest.matcher.MetadataMatcher; import org.dspace.app.rest.matcher.MetadataMatcher;
@@ -51,15 +53,20 @@ import org.dspace.content.Collection;
import org.dspace.content.Community; import org.dspace.content.Community;
import org.dspace.content.Item; import org.dspace.content.Item;
import org.dspace.content.WorkspaceItem; import org.dspace.content.WorkspaceItem;
import org.dspace.content.service.CollectionService;
import org.dspace.eperson.EPerson; import org.dspace.eperson.EPerson;
import org.dspace.eperson.Group; import org.dspace.eperson.Group;
import org.hamcrest.Matcher; import org.hamcrest.Matcher;
import org.hamcrest.Matchers; import org.hamcrest.Matchers;
import org.junit.Test; import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.MvcResult;
public class ItemRestRepositoryIT extends AbstractControllerIntegrationTest { public class ItemRestRepositoryIT extends AbstractControllerIntegrationTest {
@Autowired
private CollectionService collectionService;
@Test @Test
public void findAllTest() throws Exception { public void findAllTest() throws Exception {
context.turnOffAuthorisationSystem(); context.turnOffAuthorisationSystem();
@@ -252,6 +259,16 @@ public class ItemRestRepositoryIT extends AbstractControllerIntegrationTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$", HalMatcher.matchNoEmbeds())) .andExpect(jsonPath("$", HalMatcher.matchNoEmbeds()))
.andExpect(jsonPath("$", publicItem1Matcher)); .andExpect(jsonPath("$", publicItem1Matcher));
// When exact embeds are requested, response should include expected properties, links, and exact embeds.
getClient().perform(get("/api/core/items/" + publicItem1.getID())
.param("embed", "bundles,owningCollection"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", HalMatcher.matchEmbeds(
"bundles[]",
"owningCollection"
)))
.andExpect(jsonPath("$", publicItem1Matcher));
} }
@Test @Test
@@ -2355,4 +2372,178 @@ public class ItemRestRepositoryIT extends AbstractControllerIntegrationTest {
.content("https://localhost:8080/server/api/integration/externalsources/" + .content("https://localhost:8080/server/api/integration/externalsources/" +
"mock/entryValues/one")).andExpect(status().isUnauthorized()); "mock/entryValues/one")).andExpect(status().isUnauthorized());
} }
@Test
public void specificEmbedTestMultipleLevelOfLinks() throws Exception {
context.turnOffAuthorisationSystem();
//** GIVEN **
//1. A community-collection structure with one parent community with sub-community and two collections.
parentCommunity = CommunityBuilder.createCommunity(context)
.withName("Parent Community")
.build();
Community child1 = CommunityBuilder.createSubCommunity(context, parentCommunity)
.withName("Sub Community")
.build();
Collection col1 = CollectionBuilder.createCollection(context, child1).withName("Collection 1").build();
Collection col2 = CollectionBuilder.createCollection(context, child1).withName("Collection 2").build();
//2. Three public items that are readable by Anonymous with different subjects
Item publicItem1 = ItemBuilder.createItem(context, col1)
.withTitle("Public item 1")
.withIssueDate("2017-10-17")
.withAuthor("Smith, Donald").withAuthor("Doe, John")
.withSubject("ExtraEntry")
.build();
Item publicItem2 = ItemBuilder.createItem(context, col2)
.withTitle("Public item 2")
.withIssueDate("2016-02-13")
.withAuthor("Smith, Maria").withAuthor("Doe, Jane")
.withSubject("TestingForMore").withSubject("ExtraEntry")
.build();
Item publicItem3 = ItemBuilder.createItem(context, col2)
.withTitle("Public item 3")
.withIssueDate("2016-02-13")
.withAuthor("Smith, Maria").withAuthor("Doe, Jane")
.withSubject("AnotherTest").withSubject("TestingForMore")
.withSubject("ExtraEntry")
.build();
//Add a bitstream to an item
String bitstreamContent = "ThisIsSomeDummyText";
Bitstream bitstream1 = null;
try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) {
bitstream1 = BitstreamBuilder.
createBitstream(context, publicItem1, is)
.withName("Bitstream1")
.withMimeType("text/plain")
.build();
}
context.restoreAuthSystemState();
getClient().perform(get("/api/core/items/" + publicItem1.getID() +
"?embed=owningCollection/mappedItems/bundles/" +
"bitstreams&embed=owningCollection/logo"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", ItemMatcher.matchItemProperties(publicItem1)))
.andExpect(jsonPath("$._embedded.owningCollection",
CollectionMatcher.matchCollectionEntry(col1.getName(),
col1.getID(),
col1.getHandle())))
// .doesNotExist() makes sure that this section is not embedded, it's not there at all
.andExpect(jsonPath("$._embedded.bundles").doesNotExist())
// .doesNotExist() makes sure that this section is not embedded, it's not there at all
.andExpect(jsonPath("$._embedded.relationships").doesNotExist())
// .doesNotExist() makes sure that this section is not embedded, it's not there at all
.andExpect(jsonPath("$._embedded.owningCollection._embedded.defaultAccessConditions")
.doesNotExist())
// .nullValue() makes sure that it could be embedded, it's just null in this case
.andExpect(jsonPath("$._embedded.owningCollection._embedded.logo", Matchers.nullValue()))
// .empty() makes sure that the embed is there, but that there's no actual data
.andExpect(jsonPath("$._embedded.owningCollection._embedded.mappedItems._embedded.mappedItems",
Matchers.empty()))
;
}
@Test
public void specificEmbedTestMultipleLevelOfLinksWithData() throws Exception {
context.turnOffAuthorisationSystem();
//** GIVEN **
//1. A community-collection structure with one parent community with sub-community and two collections.
parentCommunity = CommunityBuilder.createCommunity(context)
.withName("Parent Community")
.build();
Community child1 = CommunityBuilder.createSubCommunity(context, parentCommunity)
.withName("Sub Community")
.build();
Collection col1 = CollectionBuilder.createCollection(context, child1).withName("Collection 1")
.withLogo("TestingContentForLogo").build();
Collection col2 = CollectionBuilder.createCollection(context, child1).withName("Collection 2").build();
//2. Three public items that are readable by Anonymous with different subjects
Item publicItem1 = ItemBuilder.createItem(context, col1)
.withTitle("Public item 1")
.withIssueDate("2017-10-17")
.withAuthor("Smith, Donald").withAuthor("Doe, John")
.withSubject("ExtraEntry")
.build();
Item publicItem2 = ItemBuilder.createItem(context, col2)
.withTitle("Public item 2")
.withIssueDate("2016-02-13")
.withAuthor("Smith, Maria").withAuthor("Doe, Jane")
.withSubject("TestingForMore").withSubject("ExtraEntry")
.build();
Item publicItem3 = ItemBuilder.createItem(context, col2)
.withTitle("Public item 3")
.withIssueDate("2016-02-13")
.withAuthor("Smith, Maria").withAuthor("Doe, Jane")
.withSubject("AnotherTest").withSubject("TestingForMore")
.withSubject("ExtraEntry")
.build();
collectionService.addItem(context, col1, publicItem2);
//Add a bitstream to an item
String bitstreamContent = "ThisIsSomeDummyText";
Bitstream bitstream1 = null;
try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) {
bitstream1 = BitstreamBuilder.
createBitstream(context, publicItem1, is)
.withName("Bitstream1")
.withMimeType("text/plain")
.build();
}
String bitstreamContent2 = "ThisIsSomeDummyText";
Bitstream bitstream2 = null;
try (InputStream is = IOUtils.toInputStream(bitstreamContent2, CharEncoding.UTF_8)) {
bitstream2 = BitstreamBuilder.
createBitstream(context, publicItem2, is)
.withName("Bitstream2")
.withMimeType("text/plain")
.build();
}
context.restoreAuthSystemState();
getClient().perform(get("/api/core/items/" + publicItem1.getID() +
"?embed=owningCollection/mappedItems/bundles/" +
"bitstreams&embed=owningCollection/logo"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", ItemMatcher.matchItemProperties(publicItem1)))
.andExpect(jsonPath("$._embedded.owningCollection",
CollectionMatcher.matchCollectionEntry(col1.getName(), col1.getID(),
col1.getHandle())))
// .doesNotExist() makes sure that this section is not embedded, it's not there at all
.andExpect(jsonPath("$._embedded.bundles").doesNotExist())
.andExpect(jsonPath("$._embedded.relationships").doesNotExist())
.andExpect(jsonPath("$._embedded.owningCollection._embedded.defaultAccessConditions")
.doesNotExist())
// .notNullValue() makes sure that it's there and that it does actually contain a value, but not null
.andExpect(jsonPath("$._embedded.owningCollection._embedded.logo", Matchers.notNullValue()))
.andExpect(jsonPath("$._embedded.owningCollection._embedded.logo._embedded").doesNotExist())
.andExpect(jsonPath("$._embedded.owningCollection._embedded.mappedItems._embedded.mappedItems",
Matchers.contains(ItemMatcher.matchItemProperties(publicItem2))))
.andExpect(jsonPath("$._embedded.owningCollection._embedded.mappedItems._embedded" +
".mappedItems[0]._embedded.bundles._embedded.bundles[0]._embedded" +
".bitstreams._embedded.bitstreams", Matchers.contains(
BitstreamMatcher.matchBitstreamEntryWithoutEmbed(bitstream2.getID(), bitstream2.getSizeBytes())
)))
.andExpect(jsonPath("$._embedded.owningCollection._embedded.mappedItems." +
"_embedded.mappedItems[0]_embedded.relationships").doesNotExist())
.andExpect(jsonPath("$._embedded.owningCollection._embedded.mappedItems" +
"._embedded.mappedItems[0]._embedded.bundles._embedded.bundles[0]." +
"_embedded.primaryBitstream").doesNotExist())
.andExpect(jsonPath("$._embedded.owningCollection._embedded.mappedItems." +
"_embedded.mappedItems[0]._embedded.bundles._embedded.bundles[0]." +
"_embedded.bitstreams._embedded.bitstreams[0]._embedded.format")
.doesNotExist())
;
}
} }

View File

@@ -66,6 +66,19 @@ public class BitstreamMatcher {
); );
} }
public static Matcher<? super Object> matchBitstreamEntryWithoutEmbed(UUID uuid, long size) {
return allOf(
//Check ID and size
hasJsonPath("$.uuid", is(uuid.toString())),
hasJsonPath("$.sizeBytes", is((int) size)),
//Make sure we have a checksum
hasJsonPath("$.checkSum", matchChecksum()),
//Make sure we have a valid format
//Check links
matchLinks(uuid)
);
}
private static Matcher<? super Object> matchChecksum() { private static Matcher<? super Object> matchChecksum() {
return allOf( return allOf(
hasJsonPath("$.checkSumAlgorithm", not(empty())), hasJsonPath("$.checkSumAlgorithm", not(empty())),

View File

@@ -12,6 +12,7 @@ import javax.annotation.Nullable;
import org.dspace.app.rest.model.LinkRest; import org.dspace.app.rest.model.LinkRest;
import org.dspace.app.rest.model.MockObject; import org.dspace.app.rest.model.MockObject;
import org.dspace.app.rest.model.MockObjectRest; import org.dspace.app.rest.model.MockObjectRest;
import org.dspace.app.rest.model.RestAddressableModel;
import org.dspace.app.rest.model.RestModel; import org.dspace.app.rest.model.RestModel;
import org.dspace.app.rest.model.hateoas.HALResource; import org.dspace.app.rest.model.hateoas.HALResource;
import org.springframework.hateoas.Link; import org.springframework.hateoas.Link;
@@ -87,8 +88,9 @@ public class MockProjection implements Projection {
return halResource; return halResource;
} }
public boolean allowEmbedding(HALResource halResource, LinkRest linkRest) { public boolean allowEmbedding(HALResource<? extends RestAddressableModel> halResource, LinkRest linkRest,
return true; Link... oldLinks) {
return halResource.getContent().getEmbedLevel() < 2;
} }
public boolean allowLinking(HALResource halResource, LinkRest linkRest) { public boolean allowLinking(HALResource halResource, LinkRest linkRest) {

View File

@@ -8,6 +8,12 @@
# (Requires reboot of servlet container, e.g. Tomcat, to reload) # (Requires reboot of servlet container, e.g. Tomcat, to reload)
rest.cors.allowed-origins = * rest.cors.allowed-origins = *
# This property determines the max embeddepth for a FullProjection. This is also used by the SpecificLevelProjection
# as a fallback incase the property is defined on the bean
rest.projections.full.max = 2
# This property determines the max embed depth for a SpecificLevelProjection
rest.projection.specificLevel.maxEmbed = 5
#---------------------------------------------------------------# #---------------------------------------------------------------#
# These configs are used by the deprecated REST (v4-6) module # # These configs are used by the deprecated REST (v4-6) module #

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-2.5.xsd">
<bean class="org.dspace.app.rest.projection.SpecificLevelProjection">
<property name="maxEmbed" value="${rest.projection.specificLevel.maxEmbed}" />
</bean>
</beans>