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" : "yyunikov@gmail.com",
      "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" : "yyunikov@gmail.com",
  "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?

Community
  • 1
  • 1
yyunikov
  • 5,719
  • 2
  • 43
  • 78
  • 1
    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 https://jira.spring.io/browse/DATAREST-428 – CollinD Oct 27 '16 at 16:25
  • @CollinD 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. – yyunikov Oct 27 '16 at 18:20
  • ResourceProcessor is now RepresentationModelProcessor: https://stackoverflow.com/a/56126713/11451863 – vahbuna Aug 05 '20 at 15:56

2 Answers2

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();
    }
}
yyunikov
  • 5,719
  • 2
  • 43
  • 78
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);
        }
    }
}
Alan Hay
  • 22,665
  • 4
  • 56
  • 110
  • 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. – yyunikov Oct 27 '16 at 18:24
  • 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. – Alan Hay Oct 27 '16 at 18:33
  • 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. – yyunikov Oct 27 '16 at 18:36
  • Yes, or you could inject the necessary SDR config to lookup resource paths dynamically possibly. – Alan Hay Oct 27 '16 at 18:42
  • I wrote the `ResourceProcessor` which does what expected, so you can check it out. – yyunikov Oct 28 '16 at 10:50
  • 1
    Thanks @Yuriy. Useful to know. – Alan Hay Oct 31 '16 at 20:23