Spring Data REST: projection representation of single resource
Asked Answered
F

2

2

I have a simple UserRepository which exposed using Spring Data REST. Here is the User entity class:

@Document(collection = User.COLLECTION_NAME)
@Setter
@Getter
public class User extends Entity {

    public static final String COLLECTION_NAME = "users";

    private String name;
    private String email;
    private String password;
    private Set<UserRole> roles = new HashSet<>(0);
}

I've created a UserProjection class which looks the following way:

@JsonInclude(JsonInclude.Include.NON_NULL)
@Projection(types = User.class)
public interface UserProjection {

    String getId();

    String getName();

    String getEmail();

    Set<UserRole> getRoles();
}

Here is the repository class:

@RepositoryRestResource(collectionResourceRel = User.COLLECTION_NAME, path = RestPath.Users.ROOT,
        excerptProjection = UserProjection.class)
public interface RestUserRepository extends MongoRepository<User, String> {

    // Not exported operations

    @RestResource(exported = false)
    @Override
    <S extends User> S insert(S entity);

    @RestResource(exported = false)
    @Override
    <S extends User> S save(S entity);

    @RestResource(exported = false)
    @Override
    <S extends User> List<S> save(Iterable<S> entites);
}

I've also specified user projection in configuration to make sure it will be used.

config.getProjectionConfiguration().addProjection(UserProjection.class, User.class);

So, when I do GET on /users path, I get the following response (projection is applied):

{
  "_embedded" : {
    "users" : [ {
      "name" : "Yuriy Yunikov",
      "id" : "5812193156aee116256a33d4",
      "roles" : [ "USER", "ADMIN" ],
      "email" : "[email protected]",
      "points" : 0,
      "_links" : {
        "self" : {
          "href" : "http://127.0.0.1:8080/users/5812193156aee116256a33d4"
        },
        "user" : {
          "href" : "http://127.0.0.1:8080/users/5812193156aee116256a33d4{?projection}",
          "templated" : true
        }
      }
    } ]
  },
  "_links" : {
    "self" : {
      "href" : "http://127.0.0.1:8080/users"
    },
    "profile" : {
      "href" : "http://127.0.0.1:8080/profile/users"
    }
  },
  "page" : {
    "size" : 20,
    "totalElements" : 1,
    "totalPages" : 1,
    "number" : 0
  }
}

However, when I try to make a GET call for single resource, e.g. /users/5812193156aee116256a33d4, I get the following response:

{
  "name" : "Yuriy Yunikov",
  "email" : "[email protected]",
  "password" : "123456",
  "roles" : [ "USER", "ADMIN" ],
  "_links" : {
    "self" : {
      "href" : "http://127.0.0.1:8080/users/5812193156aee116256a33d4"
    },
    "user" : {
      "href" : "http://127.0.0.1:8080/users/5812193156aee116256a33d4{?projection}",
      "templated" : true
    }
  }
}

As you may see, the password field is getting returned and projection is not applied. I know there is @JsonIgnore annotation which can be used to hide sensitive data of resource. However, my User object is located in different application module which does not know about API or JSON representation, so it does not make sense to mark fields with @JsonIgnore annotation there.

I've seen a post by @Oliver Gierke here about why excerpt projections are not applied to single resource automatically. However, it's still very inconvenient in my case and I would like to return the same UserProjection when I get a single resource. Is it somehow possible to do it without creating a custom controller or marking fields with @JsonIgnore?

Fahy answered 27/10, 2016 at 16:18 Comment(3)
You may be able to use a ResourceProcessor to accomplish this (or a similar effect). There's some potentially useful discussion in the comments at jira.spring.io/browse/DATAREST-428Professionalize
@Professionalize Thanks for a great link! Seems like solutions with ResourceProcessor or ResourceAssembler fits good for such cases. However I'm still wondering why there is no kind of annotation or configuration in Spring Data REST for this.Fahy
ResourceProcessor is now RepresentationModelProcessor: https://mcmap.net/q/506618/-migrating-resourceprocessor-to-hateoas-1-0-0-m1Ontiveros
F
6

I was able to create a ResourceProcessor class which applies projections on any resource as suggested in DATAREST-428. It works the following way: if projection parameter is specified in URL - the specified projection will be applied, if not - projection with name default will be returned, applied first found projection will be applied. Also, I had to add custom ProjectingResource which ignores the links, otherwise there are two _links keys in the returning JSON.

/**
 * Projecting resource used for {@link ProjectingProcessor}. Does not include empty links in JSON, otherwise two 
 * _links keys are present in returning JSON.
 *
 * @param <T>
 */
@JsonInclude(JsonInclude.Include.NON_EMPTY)
class ProjectingResource<T> extends Resource<T> {

    ProjectingResource(final T content) {
        super(content);
    }
}

/**
 * Resource processor for all resources which applies projection for single resource. By default, projections
 * are not
 * applied when working with single resource, e.g. http://127.0.0.1:8080/users/580793f642d54436e921f6ca. See
 * related issue <a href="https://jira.spring.io/browse/DATAREST-428">DATAREST-428</a>
 */
@Component
public class ProjectingProcessor implements ResourceProcessor<Resource<Object>> {

    private static final String PROJECTION_PARAMETER = "projection";

    private final ProjectionFactory projectionFactory;

    private final RepositoryRestConfiguration repositoryRestConfiguration;

    private final HttpServletRequest request;

    public ProjectingProcessor(@Autowired final RepositoryRestConfiguration repositoryRestConfiguration,
                               @Autowired final ProjectionFactory projectionFactory,
                               @Autowired final HttpServletRequest request) {
        this.repositoryRestConfiguration = repositoryRestConfiguration;
        this.projectionFactory = projectionFactory;
        this.request = request;
    }

    @Override
    public Resource<Object> process(final Resource<Object> resource) {
        if (AopUtils.isAopProxy(resource.getContent())) {
            return resource;
        }

        final Optional<Class<?>> projectionType = findProjectionType(resource.getContent());
        if (projectionType.isPresent()) {
            final Object projection = projectionFactory.createProjection(projectionType.get(), resource
                    .getContent());
            return new ProjectingResource<>(projection);
        }

        return resource;
    }

    private Optional<Class<?>> findProjectionType(final Object content) {
        final String projectionParameter = request.getParameter(PROJECTION_PARAMETER);
        final Map<String, Class<?>> projectionsForType = repositoryRestConfiguration.getProjectionConfiguration()
                .getProjectionsFor(content.getClass());

        if (!projectionsForType.isEmpty()) {
            if (!StringUtils.isEmpty(projectionParameter)) {
                // projection parameter specified
                final Class<?> projectionClass = projectionsForType.get(projectionParameter);
                if (projectionClass != null) {
                    return Optional.of(projectionClass);
                }
            } else if (projectionsForType.containsKey(ProjectionName.DEFAULT)) {
                // default projection exists
                return Optional.of(projectionsForType.get(ProjectionName.DEFAULT));
            }

            // no projection parameter specified
            return Optional.of(projectionsForType.values().iterator().next());
        }

        return Optional.empty();
    }
}
Fahy answered 28/10, 2016 at 10:48 Comment(0)
C
1

I was looking at something similar recently and ended up going round in circles when trying to approach it from the Spring Data /Jackson side of things.

An alternative, and very simple solution, then is to approach it from a different angle and ensure the Projection parameter in the HTTP request is always present. This can be done by using a Servlet Filter to modify the parameters of the incoming request.

This would look something like the below:

public class ProjectionResolverFilter extends GenericFilterBean {

    private static final String REQUEST_PARAM_PROJECTION_KEY = "projection";

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {

        HttpServletRequest request = (HttpServletRequest) req;

        if (shouldApply(request)) {
            chain.doFilter(new ResourceRequestWrapper(request), res);
        } else {
            chain.doFilter(req, res);
        }
    }

    /**
     * 
     * @param request
     * @return True if this filter should be applied for this request, otherwise
     *         false.
     */
    protected boolean shouldApply(HttpServletRequest request) {
        return request.getServletPath().matches("some-path");
    }

    /**
     * HttpServletRequestWrapper implementation which allows us to wrap and
     * modify the incoming request.
     *
     */
    public class ResourceRequestWrapper extends HttpServletRequestWrapper {

        public ResourceRequestWrapper(HttpServletRequest request) {
            super(request);
        }

        @Override
        public String getParameter(final String name) {
            if (name.equals(REQUEST_PARAM_PROJECTION_KEY)) {
                return "nameOfDefaultProjection";
            }

            return super.getParameter(name);
        }
    }
}
Contrary answered 27/10, 2016 at 17:39 Comment(6)
This solution is not quite fits what I'm looking for as what I'm interested in is the default resource representation without passing projection parameter. Also it looks more like a "hack" for me, would be much better to do it with ResourceProcessor, e.g. like in link provided by @CollinD. But thanks for suggestion anyway.Fahy
The solution prevents the need for passing the projection parameter as it is applying the default projection automatically- as if it had been passed in the request. Yes, it is a bit of a hack but it's simple and it works. I would be interested in seeing the custom resource processor working if you can post as an answer if you get a solution.Contrary
Yes, my bad, it does prevents the need for passing the projection parameter. However, you still need to hardcode the path in shouldApply method or it can apply to all resources? I'll check later with the resource processors and would post an answer if it will work.Fahy
Yes, or you could inject the necessary SDR config to lookup resource paths dynamically possibly.Contrary
I wrote the ResourceProcessor which does what expected, so you can check it out.Fahy
Thanks @Yuriy. Useful to know.Contrary

© 2022 - 2024 — McMap. All rights reserved.