Customizing HATEOAS link generation for entities with composite ids
Asked Answered
I

5

10

I have configured a RepositoryRestResource on a PageAndSortingRepository that accesses an Entity that includes a composite Id:

@Entity
@IdClass(CustomerId.class)
public class Customer {
    @Id BigInteger id;
    @Id int startVersion;
    ...
}

public class CustomerId {
    BigInteger id;
    int startVersion;
    ...
}

@RepositoryRestResource(collectionResourceRel = "customers", path = "customers", itemResourceRel = "customers/{id}_{startVersion}")
public interface CustomerRepository extends PagingAndSortingRepository<Customer, CustomerId> {}

When i access the server at "http://<server>/api/customers/1_1" for instance, I get the correct resource back as json, but the href in the _links section for self is the wrong and also the same for any other customer i query: "http://<server>/api/customer/1"

i.e.:

{
  "id" : 1,
  "startVersion" : 1,
  ...
  "firstname" : "BOB",
  "_links" : {
    "self" : {
      "href" : "http://localhost:9081/reps/api/reps/1" <-- This should be /1_1
    }
  }
}

I suppose this is because of my composite Id, But I am chuffed as to how i can change this default behaviour.

I've had a look at the ResourceSupport and the ResourceProcessor class but am not sure how much i need to change in order fix this issue.

Can someone who knows spring lend me a hand?

Illconditioned answered 22/5, 2014 at 8:29 Comment(0)
A
13

Unfortunately, all Spring Data JPA/Rest versions up to 2.1.0.RELEASE are not able to serve your need out of the box. The source is buried inside Spring Data Commons/JPA itself. Spring Data JPA supports only Id and EmbeddedId as identifier.

Excerpt JpaPersistentPropertyImpl:

static {

    // [...]

    annotations = new HashSet<Class<? extends Annotation>>();
    annotations.add(Id.class);
    annotations.add(EmbeddedId.class);

    ID_ANNOTATIONS = annotations;
}

Spring Data Commons doesn't support the notion of combined properties. It treats every property of a class independently from each other.

Of course, you can hack Spring Data Rest. But this is cumbersome, doesn't solve the problem at its heart and reduces the flexibility of the framework.

Here's the hack. This should give you an idea how to tackle your problem.

In your configuration override repositoryExporterHandlerAdapter and return a CustomPersistentEntityResourceAssemblerArgumentResolver. Additionally, override backendIdConverterRegistry and add CustomBackendIdConverter to the list of known id converter:

import org.springframework.beans.factory.ListableBeanFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.data.rest.core.projection.ProxyProjectionFactory;
import org.springframework.data.rest.webmvc.RepositoryRestHandlerAdapter;
import org.springframework.data.rest.webmvc.config.RepositoryRestMvcConfiguration;
import org.springframework.data.rest.webmvc.spi.BackendIdConverter;
import org.springframework.data.rest.webmvc.support.HttpMethodHandlerMethodArgumentResolver;
import org.springframework.data.web.config.EnableSpringDataWebSupport;
import org.springframework.hateoas.ResourceProcessor;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.plugin.core.OrderAwarePluginRegistry;
import org.springframework.plugin.core.PluginRegistry;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

@Configuration
@Import(RepositoryRestMvcConfiguration.class)
@EnableSpringDataWebSupport
public class RestConfig extends RepositoryRestMvcConfiguration {
    @Autowired(required = false) List<ResourceProcessor<?>> resourceProcessors = Collections.emptyList();
    @Autowired
    ListableBeanFactory beanFactory;

    @Override
    @Bean
    public PluginRegistry<BackendIdConverter, Class<?>> backendIdConverterRegistry() {

        List<BackendIdConverter> converters = new ArrayList<BackendIdConverter>(3);
        converters.add(new CustomBackendIdConverter());
        converters.add(BackendIdConverter.DefaultIdConverter.INSTANCE);

        return OrderAwarePluginRegistry.create(converters);
    }

    @Bean
    public RequestMappingHandlerAdapter repositoryExporterHandlerAdapter() {

        List<HttpMessageConverter<?>> messageConverters = defaultMessageConverters();
        configureHttpMessageConverters(messageConverters);

        RepositoryRestHandlerAdapter handlerAdapter = new RepositoryRestHandlerAdapter(defaultMethodArgumentResolvers(),
                resourceProcessors);
        handlerAdapter.setMessageConverters(messageConverters);

        return handlerAdapter;
    }

    private List<HandlerMethodArgumentResolver> defaultMethodArgumentResolvers()
    {

        CustomPersistentEntityResourceAssemblerArgumentResolver peraResolver = new CustomPersistentEntityResourceAssemblerArgumentResolver(
                repositories(), entityLinks(), config().projectionConfiguration(), new ProxyProjectionFactory(beanFactory));

        return Arrays.asList(pageableResolver(), sortResolver(), serverHttpRequestMethodArgumentResolver(),
                repoRequestArgumentResolver(), persistentEntityArgumentResolver(),
                resourceMetadataHandlerMethodArgumentResolver(), HttpMethodHandlerMethodArgumentResolver.INSTANCE,
                peraResolver, backendIdHandlerMethodArgumentResolver());
    }
}

Create CustomBackendIdConverter. This class is responsible for rendering your custom entity ids:

import org.springframework.data.rest.webmvc.spi.BackendIdConverter;

import java.io.Serializable;

public class CustomBackendIdConverter implements BackendIdConverter {

    @Override
    public Serializable fromRequestId(String id, Class<?> entityType) {
        return id;
    }

    @Override
    public String toRequestId(Serializable id, Class<?> entityType) {
        if(entityType.equals(Customer.class)) {
            Customer c = (Customer) id;
            return c.getId() + "_" +c.getStartVersion();
        }
        return id.toString();

    }

    @Override
    public boolean supports(Class<?> delimiter) {
        return true;
    }
}

CustomPersistentEntityResourceAssemblerArgumentResolver in turn should return a CustomPersistentEntityResourceAssembler:

import org.springframework.core.MethodParameter;
import org.springframework.data.repository.support.Repositories;
import org.springframework.data.rest.core.projection.ProjectionDefinitions;
import org.springframework.data.rest.core.projection.ProjectionFactory;
import org.springframework.data.rest.webmvc.PersistentEntityResourceAssembler;
import org.springframework.data.rest.webmvc.config.PersistentEntityResourceAssemblerArgumentResolver;
import org.springframework.data.rest.webmvc.support.PersistentEntityProjector;
import org.springframework.hateoas.EntityLinks;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.ModelAndViewContainer;

public class CustomPersistentEntityResourceAssemblerArgumentResolver extends PersistentEntityResourceAssemblerArgumentResolver {
    private final Repositories repositories;
    private final EntityLinks entityLinks;
    private final ProjectionDefinitions projectionDefinitions;
    private final ProjectionFactory projectionFactory;

    public CustomPersistentEntityResourceAssemblerArgumentResolver(Repositories repositories, EntityLinks entityLinks,
                                                             ProjectionDefinitions projectionDefinitions, ProjectionFactory projectionFactory) {

        super(repositories, entityLinks,projectionDefinitions,projectionFactory);

        this.repositories = repositories;
        this.entityLinks = entityLinks;
        this.projectionDefinitions = projectionDefinitions;
        this.projectionFactory = projectionFactory;
    }

    public boolean supportsParameter(MethodParameter parameter) {
        return PersistentEntityResourceAssembler.class.isAssignableFrom(parameter.getParameterType());
    }

    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        String projectionParameter = webRequest.getParameter(projectionDefinitions.getParameterName());
        PersistentEntityProjector projector = new PersistentEntityProjector(projectionDefinitions, projectionFactory,
                projectionParameter);

        return new CustomPersistentEntityResourceAssembler(repositories, entityLinks, projector);
    }
}

CustomPersistentEntityResourceAssembler needs to override getSelfLinkFor. As you can see entity.getIdProperty() return either id or startVersion property of your Customer class which in turn gets used to retrieve the real value with the help of a BeanWrapper. Here we are short circuit the whole framework with the use of instanceof operator. Hence your Customer class should implement Serializable for further processing.

import org.springframework.data.mapping.PersistentEntity;
import org.springframework.data.mapping.model.BeanWrapper;
import org.springframework.data.repository.support.Repositories;
import org.springframework.data.rest.webmvc.PersistentEntityResourceAssembler;
import org.springframework.data.rest.webmvc.support.Projector;
import org.springframework.hateoas.EntityLinks;
import org.springframework.hateoas.Link;
import org.springframework.util.Assert;

public class CustomPersistentEntityResourceAssembler extends PersistentEntityResourceAssembler {

    private final Repositories repositories;
    private final EntityLinks entityLinks;

    public CustomPersistentEntityResourceAssembler(Repositories repositories, EntityLinks entityLinks, Projector projector) {
        super(repositories, entityLinks, projector);

        this.repositories = repositories;
        this.entityLinks = entityLinks;
    }

    public Link getSelfLinkFor(Object instance) {

        Assert.notNull(instance, "Domain object must not be null!");

        Class<? extends Object> instanceType = instance.getClass();
        PersistentEntity<?, ?> entity = repositories.getPersistentEntity(instanceType);

        if (entity == null) {
            throw new IllegalArgumentException(String.format("Cannot create self link for %s! No persistent entity found!",
                    instanceType));
        }

        Object id;

        //this is a hack for demonstration purpose. don't do this at home!
        if(instance instanceof Customer) {
            id = instance;
        } else {
            BeanWrapper<Object> wrapper = BeanWrapper.create(instance, null);
            id = wrapper.getProperty(entity.getIdProperty());
        }

        Link resourceLink = entityLinks.linkToSingleResource(entity.getType(), id);
        return new Link(resourceLink.getHref(), Link.REL_SELF);
    }
}

That's it! You should see this URIs:

{
  "_embedded" : {
    "customers" : [ {
      "name" : "test",
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/demo/customers/1_1"
        }
      }
    } ]
  }
}

Imho, if you are working on a green field project I would suggest to ditch IdClass entirely and go with technical simple ids based on Long class. This was tested with Spring Data Rest 2.1.0.RELEASE, Spring data JPA 1.6.0.RELEASE and Spring Framework 4.0.3.RELEASE.

Armchair answered 1/6, 2014 at 20:7 Comment(2)
no problem. it's an interesting problem. hope it helps. maybe you need to implement fromRequestId to deserialize your ids.Armchair
Good answer! I don't tried it still, but this repository will accept POST requests? How I can insert a data through REST API?Twosome
I
5

Although not desirable, I have worked around this issue by using an @EmbeddedId instead of a IdClass annotation on my JPA entity.

Like so:

@Entity
public class Customer {
    @EmbeddedId
    private CustomerId id;
    ...
}

public class CustomerId {

    @Column(...)
    BigInteger key;
    @Column(...)
    int startVersion;
    ...
}

I now see the correctly generated links 1_1 on my returned entities.

If anyone can still direct me to a solution that does not require I change the representation of my model, It would be highly appreciated. Luckily I had not progressed far in my application development for this to be of serious concern in changing, but I imagine that for others, there would be significant overhead in performing a change like this: (e.g. changing all queries that reference this model in JPQL queries).

Illconditioned answered 28/5, 2014 at 5:51 Comment(2)
I know this is an old post, but I thought I would add that you can add @Transient to a getKey method that will return id.key in Customer that will allow you to have quick access in your API, but will not affect your REST representation. It's ugly, but makes your API cleaner.Echolalia
IMHO best way to goColis
C
0

I had a similar problem where the composite key scenarios for data rest was not working. @ksokol detailed explanation provided the necessary inputs to solve the issue. changed my pom primarily for data-rest-webmvc and data-jpa as

    <dependency>
        <groupId>org.springframework.data</groupId>
        <artifactId>spring-data-rest-webmvc</artifactId>
        <version>2.2.1.RELEASE</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.data</groupId>
        <artifactId>spring-data-jpa</artifactId>
        <version>1.7.1.RELEASE</version>
    </dependency>

which solved all the issues related to composite key and I need not do the customization. Thanks ksokol for the detailed explanation.

Complicate answered 13/1, 2015 at 10:17 Comment(1)
Hello @Alagesan, do you know which is the Spring Data JPA or REST version that solved the problem? ThanksArundel
L
0

First, create a SpringUtil to get bean from spring.

import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

@Component
public class SpringUtil implements ApplicationContextAware {
    private static ApplicationContext applicationContext;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        if(SpringUtil.applicationContext == null) {
            SpringUtil.applicationContext = applicationContext;
        }
    }

    public static ApplicationContext getApplicationContext() {
        return applicationContext;
    }

    public static Object getBean(String name){
        return getApplicationContext().getBean(name);
    }

    public static <T> T getBean(Class<T> clazz){
        return getApplicationContext().getBean(clazz);
    }

    public static <T> T getBean(String name,Class<T> clazz){
        return getApplicationContext().getBean(name, clazz);
    }
}

Then, implement BackendIdConverter.

import com.alibaba.fastjson.JSON;
import com.example.SpringUtil;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.rest.webmvc.spi.BackendIdConverter;
import org.springframework.stereotype.Component;

import javax.persistence.EmbeddedId;
import javax.persistence.Id;
import java.io.Serializable;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Method;
import java.net.URLDecoder;
import java.net.URLEncoder;

@Component
public class CustomBackendIdConverter implements BackendIdConverter {

    @Override
    public boolean supports(Class<?> delimiter) {
        return true;
    }

    @Override
    public Serializable fromRequestId(String id, Class<?> entityType) {
        if (id == null) {
            return null;
        }

        //first decode url string
        if (!id.contains(" ") && id.toUpperCase().contains("%7B")) {
            try {
                id = URLDecoder.decode(id, "UTF-8");
            } catch (UnsupportedEncodingException e) {
                e.printStackTrace();
            }
        }

        //deserialize json string to ID object
        Object idObject = null;
        for (Method method : entityType.getDeclaredMethods()) {
            if (method.isAnnotationPresent(Id.class) || method.isAnnotationPresent(EmbeddedId.class)) {
                idObject = JSON.parseObject(id, method.getGenericReturnType());
                break;
            }
        }

        //get dao class from spring
        Object daoClass = null;
        try {
            daoClass = SpringUtil.getBean(Class.forName("com.example.db.dao." + entityType.getSimpleName() + "DAO"));
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }

        //get the entity with given primary key
        JpaRepository simpleJpaRepository = (JpaRepository) daoClass;
        Object entity = simpleJpaRepository.findOne((Serializable) idObject);
        return (Serializable) entity;

    }

    @Override
    public String toRequestId(Serializable id, Class<?> entityType) {
        if (id == null) {
            return null;
        }

        String jsonString = JSON.toJSONString(id);

        String encodedString = "";
        try {
            encodedString = URLEncoder.encode(jsonString, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return encodedString;
    }
}

After that. you can do what you want.

There is a sample below.

  • If the entity has single property pk, you can use localhost:8080/demo/1 as normal. According to my code, suppose the pk has annotation "@Id".
  • If the entity has composed pk, suppose the pk is demoId type, and has annotation "@EmbeddedId", you can use localhost:8080/demo/{demoId json} to get/put/delete. And your self link will be the same.
Lea answered 7/5, 2017 at 14:20 Comment(0)
H
0

The answers provides above are helpful, but if you need a more generic approach that would be following -

package com.pratham.persistence.config;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.istack.NotNull;
import lombok.RequiredArgsConstructor;
import org.springframework.data.rest.webmvc.spi.BackendIdConverter;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;

import javax.persistence.EmbeddedId;
import java.io.Serializable;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.Base64;
import java.util.Optional;

import static java.nio.charset.StandardCharsets.UTF_8;

/**
 * Customization of how composite ids are exposed in URIs.
 * The implementation will convert the Ids marked with {@link EmbeddedId} to base64 encoded json
 * in order to expose them properly within URI.
 *
 * @author im-pratham
 */
@Component
@RequiredArgsConstructor
public class EmbeddedBackendIdConverter implements BackendIdConverter {
    private final ObjectMapper objectMapper;

    @Override
    public Serializable fromRequestId(String id, Class<?> entityType) {
        return getFieldWithEmbeddedAnnotation(entityType)
                .map(Field::getType)
                .map(ret -> {
                    try {
                        String decodedId = new String(Base64.getUrlDecoder().decode(id));
                        return (Serializable) objectMapper.readValue(decodedId, (Class) ret);
                    } catch (JsonProcessingException ignored) {
                        return null;
                    }
                })
                .orElse(id);
    }

    @Override
    public String toRequestId(Serializable id, Class<?> entityType) {
        try {
            String json = objectMapper.writeValueAsString(id);
            return Base64.getUrlEncoder().encodeToString(json.getBytes(UTF_8));
        } catch (JsonProcessingException ignored) {
            return id.toString();
        }
    }

    @Override
    public boolean supports(@NonNull Class<?> entity) {
        return isEmbeddedIdAnnotationPresent(entity);
    }

    private boolean isEmbeddedIdAnnotationPresent(Class<?> entity) {
        return getFieldWithEmbeddedAnnotation(entity)
                .isPresent();
    }

    @NotNull
    private static Optional<Field> getFieldWithEmbeddedAnnotation(Class<?> entity) {
        return Arrays.stream(entity.getDeclaredFields())
                .filter(method -> method.isAnnotationPresent(EmbeddedId.class))
                .findFirst();
    }
}
Housemaster answered 28/9, 2022 at 12:51 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.