简体   繁体   中英

Resolving entity URI in custom controller (Spring HATEOAS)

I have a project based on spring-data-rest and also it has some custom endpoints.

For sending POST data I'm using json like

{
 "action": "REMOVE",
 "customer": "http://localhost:8080/api/rest/customers/7"
}

That is fine for spring-data-rest, but does not work with a custom controller.

for example:

public class Action {
    public ActionType action;
    public Customer customer;
}

@RestController
public class ActionController(){
  @Autowired
  private ActionService actionService;

  @RestController
  public class ActionController {
  @Autowired
  private ActionService actionService;

  @RequestMapping(value = "/customer/action", method = RequestMethod.POST)
  public ResponseEntity<ActionResult> doAction(@RequestBody Action action){
    ActionType actionType = action.action;
    Customer customer = action.customer;//<------There is a problem
    ActionResult result = actionService.doCustomerAction(actionType, customer);
    return ResponseEntity.ok(result);
  }
}

When I call

curl -v -X POST -H "Content-Type: application/json" -d '{"action": "REMOVE","customer": "http://localhost:8080/api/rest/customers/7"}' http://localhost:8080/customer/action

I have an answer

{
"timestamp" : "2016-05-12T11:55:41.237+0000",
"status" : 400,
"error" : "Bad Request",
"exception" : "org.springframework.http.converter.HttpMessageNotReadableException",
"message" : "Could not read document: Can not instantiate value of type [simple type, class model.user.Customer] from String value ('http://localhost:8080/api/rest/customers/7'); no single-String constructor/factory method\n at [Source: java.io.PushbackInputStream@73af10c6; line: 1, column: 33] (through reference chain: api.controller.Action[\"customer\"]); nested exception is com.fasterxml.jackson.databind.JsonMappingException: Can not instantiate value of type [simple type, class logic.model.user.Customer] from String value ('http://localhost:8080/api/rest/customers/7'); no single-String constructor/factory method\n at [Source: java.io.PushbackInputStream@73af10c6; line: 1, column: 33] (through reference chain: api.controller.Action[\"customer\"])",
"path" : "/customer/action"
* Closing connection 0
}

bacause case spring can not convert a URI to a Customer entity.

Is there any way to use spring-data-rest mechanism for resolving entities by their URIs?

I have only one idea - to use custom JsonDeserializer with parsing URI for extracting entityId and making a request to a repository. But this strategy does not help me if I have URI like " http://localhost:8080/api/rest/customers/8/product " in that case I do not have product.Id value.

I have been having the same problem too for really long time now and solved it the following way. @Florian was on the right track and thanks to his suggestion I found a way to make the conversion work automatically. There are several pieces needed:

  1. A conversion service to enable the conversion from a URI to an entity (leveraging the UriToEntityConverter provided with the framework)
  2. A deserializer to detect when it is appropriate to invoke the converter (we don't want to mess up with the default SDR behavior)
  3. A custom Jackson module to push everything to SDR

For point 1 the implementation can be narrowed to the following

import org.springframework.context.ApplicationContext;
import org.springframework.data.mapping.context.PersistentEntities;
import org.springframework.data.repository.support.DomainClassConverter;
import org.springframework.data.rest.core.UriToEntityConverter;
import org.springframework.format.support.DefaultFormattingConversionService;

public class UriToEntityConversionService extends DefaultFormattingConversionService {

   private UriToEntityConverter converter;

   public UriToEntityConversionService(ApplicationContext applicationContext, PersistentEntities entities) {
      new DomainClassConverter<>(this).setApplicationContext(applicationContext);

       converter = new UriToEntityConverter(entities, this);

       addConverter(converter);
   }

   public UriToEntityConverter getConverter() {
      return converter;
   }
}

For point 2 this is my solution

import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.DeserializationConfig;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.BeanDeserializerBuilder;
import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier;
import com.fasterxml.jackson.databind.deser.ValueInstantiator;
import com.fasterxml.jackson.databind.deser.std.StdValueInstantiator;
import your.domain.RootEntity; // <-- replace this with the import of the root class (or marker interface) of your domain
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.data.mapping.PersistentEntity;
import org.springframework.data.mapping.context.PersistentEntities;
import org.springframework.data.rest.core.UriToEntityConverter;
import org.springframework.util.Assert;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Optional;


public class RootEntityFromUriDeserializer extends BeanDeserializerModifier {

   private final UriToEntityConverter converter;
   private final PersistentEntities repositories;

   public RootEntityFromUriDeserializer(PersistentEntities repositories, UriToEntityConverter converter) {

       Assert.notNull(repositories, "Repositories must not be null!");
       Assert.notNull(converter, "UriToEntityConverter must not be null!");

       this.repositories = repositories;
       this.converter = converter;
   }

   @Override
   public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanDescription beanDesc, BeanDeserializerBuilder builder) {

       PersistentEntity<?, ?> entity = repositories.getPersistentEntity(beanDesc.getBeanClass());

       boolean deserializingARootEntity = entity != null && RootEntity.class.isAssignableFrom(entity.getType());

       if (deserializingARootEntity) {
           replaceValueInstantiator(builder, entity);
       }

       return builder;
   }

   private void replaceValueInstantiator(BeanDeserializerBuilder builder, PersistentEntity<?, ?> entity) {
      ValueInstantiator currentValueInstantiator = builder.getValueInstantiator();

       if (currentValueInstantiator instanceof StdValueInstantiator) {

          EntityFromUriInstantiator entityFromUriInstantiator =
                new EntityFromUriInstantiator((StdValueInstantiator) currentValueInstantiator, entity.getType(), converter);

          builder.setValueInstantiator(entityFromUriInstantiator);
       }
   }

   private class EntityFromUriInstantiator extends StdValueInstantiator {
      private final Class entityType;
      private final UriToEntityConverter converter;

      private EntityFromUriInstantiator(StdValueInstantiator src, Class entityType, UriToEntityConverter converter) {
         super(src);
         this.entityType = entityType;
         this.converter = converter;
      }

      @Override
      public Object createFromString(DeserializationContext ctxt, String value) throws IOException {
         URI uri;
         try {
            uri = new URI(value);
         } catch (URISyntaxException e) {
            return super.createFromString(ctxt, value);
         }

         return converter.convert(uri, TypeDescriptor.valueOf(URI.class), TypeDescriptor.valueOf(entityType));
      }
   }
}

Then for point 3, in the custom RepositoryRestConfigurerAdapter,

public class MyRepositoryRestConfigurer extends RepositoryRestConfigurerAdapter {
   @Override
   public void configureJacksonObjectMapper(ObjectMapper objectMapper) {
      objectMapper.registerModule(new SimpleModule("URIDeserializationModule"){

         @Override
         public void setupModule(SetupContext context) {
            UriToEntityConverter converter = conversionService.getConverter();

            RootEntityFromUriDeserializer rootEntityFromUriDeserializer = new RootEntityFromUriDeserializer(persistentEntities, converter);

            context.addBeanDeserializerModifier(rootEntityFromUriDeserializer);
         }
      });
   }
}

This works smoothly for me and does not interfere with any conversion from the framework (we have many custom endpoints). In the point 2 the intent was to enable the instantiation from a URI only in cases where:

  1. The entity being deserialized is a root entity (so no properties)
  2. The provided string is an actual URI (otherwise it just falls back to the default behavior)

This is more of an side note instead of a real answer, but a while ago I managed to copy&paste myself a class to resolve entities from an URL by using the methods used in SDR (just more crude). There probably is a much better way, but until then, perhaps this helps...

@Service
public class EntityConverter {

    @Autowired
    private MappingContext<?, ?> mappingContext;

    @Autowired
    private ApplicationContext applicationContext;

    @Autowired(required = false)
    private List<RepositoryRestConfigurer> configurers = Collections.emptyList();

    public <T> T convert(Link link, Class<T> target) {

        DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();

        PersistentEntities entities = new PersistentEntities(Arrays.asList(mappingContext));
        UriToEntityConverter converter = new UriToEntityConverter(entities, conversionService);
        conversionService.addConverter(converter);
        addFormatters(conversionService);
        for (RepositoryRestConfigurer configurer : configurers) {
            configurer.configureConversionService(conversionService);
        }

        URI uri = convert(link);
        T object = target.cast(conversionService.convert(uri, TypeDescriptor.valueOf(target)));
        if (object == null) {
            throw new IllegalArgumentException(String.format("%s '%s' was not found.", target.getSimpleName(), uri));
        }
        return object;
    }

    private URI convert(Link link) {
        try {
            return new URI(link.getHref());
        } catch (Exception e) {
            throw new IllegalArgumentException("URI from link is invalid", e);
        }
    }

    private void addFormatters(FormatterRegistry registry) {

        registry.addFormatter(DistanceFormatter.INSTANCE);
        registry.addFormatter(PointFormatter.INSTANCE);

        if (!(registry instanceof FormattingConversionService)) {
            return;
        }

        FormattingConversionService conversionService = (FormattingConversionService) registry;

        DomainClassConverter<FormattingConversionService> converter = new DomainClassConverter<FormattingConversionService>(
                conversionService);
        converter.setApplicationContext(applicationContext);
    }

}

And yes, it's likely that parts of this class are simply useless. In my defense, it was just a short hack and I never got around to actually need it, because I found other problems first ;-)

对于带有@RequestBody HAL,使用Resource<T>作为方法参数而不是实体Action以允许转换相关资源 URI

public ResponseEntity<ActionResult> doAction(@RequestBody Resource<Action> action){

I can't believe it. After wrapping my head around this for MONTH(!) I managed to SOLVE THIS !

Some words of introduction:

Spring HATEOAS uses URIs as references to entities. And it provides great support to obtain these URI Links for a given entity . For example, when a client requests an entity that references other child entities, then the client will receive those URIs. Nice to work with.

GET /users/1
{ 
  "username": "foobar",
  "_links": {
     "self": {
       "href": "http://localhost:8080/user/1"  //<<<== HATEOAS Link
      }
  }
}

The REST client only works with those uris. The REST client MUST NOT know the structure of these URIs. The REST client does not know, that there is a DB internal ID at the end of the URI string.

So far so good. BUT spring data HATEOAS does not offer any functionality to convert an URI back to the corresponding entity (loaded from the DB). Everyone needs that in custom REST controllers. (See question above)

Think of an example where you want to work with a user in a custom REST controller. The client would send this request

POST /checkAdress
{
   user: "/users/1"
   someMoreOtherParams: "...",
   [...]
}

How shall the custom REST controller deserialize from the (String) uri to the UserModel? I found a way: You must configure the Jackson deserialization in your RepositoryRestConfigurer:

RepositoryRestConfigurer.java

public class RepositoryRestConfigurer extends RepositoryRestConfigurerAdapter {
@Autowired
  UserRepo userRepo;

  @Override
  public void configureJacksonObjectMapper(ObjectMapper objectMapper) {
    SimpleModule module = new SimpleModule();
    module.addDeserializer(UserModel.class, new JsonDeserializer<UserModel>() {
    @Override
        public UserModel deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
            String uri = p.getValueAsString();
            //extract ID from URI, with regular expression (1)
            Pattern regex = Pattern.compile(".*\\/" + entityName + "\\/(\\d+)");
            Matcher matcher = regex.matcher(uri);
            if (!matcher.matches()) throw new RuntimeException("This does not seem to be an URI for an '"+entityName+"': "+uri);
            String userId = matcher.group(1);
            UserModel user = userRepo.findById(userId)   
              .orElseThrow(() -> new RuntimeException("User with id "+userId+" does not exist."))
            return user;
        }
    });
    objectMapper.registerModule(module);
}

}

(1) This string parsing is ugly. I know. But it's just the inverse of org.springframework.hateoas.EntityLinks and its implementations. And the author of spring-hateos stubbornly refuses to offer utility methods for both directions.

I arrived at the following solution. It's a bit hackish, but works.

First, the service to convert URIs into entities.

EntityConverter

import java.net.URI;
import java.util.Collections;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.data.geo.format.DistanceFormatter;
import org.springframework.data.geo.format.PointFormatter;
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.mapping.context.PersistentEntities;
import org.springframework.data.repository.support.DefaultRepositoryInvokerFactory;
import org.springframework.data.repository.support.DomainClassConverter;
import org.springframework.data.repository.support.Repositories;
import org.springframework.data.rest.core.UriToEntityConverter;
import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer;
import org.springframework.format.FormatterRegistry;
import org.springframework.format.support.DefaultFormattingConversionService;
import org.springframework.format.support.FormattingConversionService;
import org.springframework.hateoas.Link;
import org.springframework.stereotype.Service;

@Service
public class EntityConverter {

    @Autowired
    private MappingContext<?, ?> mappingContext;

    @Autowired
    private ApplicationContext applicationContext;

    @Autowired(required = false)
    private List<RepositoryRestConfigurer> configurers = Collections.emptyList();

    public <T> T convert(Link link, Class<T> target) {

        DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();

        Repositories repositories = new Repositories(applicationContext);
        UriToEntityConverter converter = new UriToEntityConverter(
            new PersistentEntities(Collections.singleton(mappingContext)),
            new DefaultRepositoryInvokerFactory(repositories),
            repositories);

        conversionService.addConverter(converter);
        addFormatters(conversionService);
        for (RepositoryRestConfigurer configurer : configurers) {
            configurer.configureConversionService(conversionService);
        }

        URI uri = convert(link);
        T object = target.cast(conversionService.convert(uri, TypeDescriptor.valueOf(target)));
        if (object == null) {
            throw new IllegalArgumentException(String.format("registerNotFound", target.getSimpleName(), uri));
        }
        return object;
    }

    private URI convert(Link link) {
        try {
            return new URI(link.getHref().replace("{?projection}", ""));
        } catch (Exception e) {
            throw new IllegalArgumentException("invalidURI", e);
        }
    }

    private void addFormatters(FormatterRegistry registry) {

        registry.addFormatter(DistanceFormatter.INSTANCE);
        registry.addFormatter(PointFormatter.INSTANCE);

        if (!(registry instanceof FormattingConversionService)) {
            return;
        }

        FormattingConversionService conversionService = (FormattingConversionService) registry;

        DomainClassConverter<FormattingConversionService> converter = new DomainClassConverter<FormattingConversionService>(
                conversionService);
        converter.setApplicationContext(applicationContext);
    }
}

Second, a component to be able to use the EntityConverter outside of the Spring context.

ApplicationContextHolder

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

@Component
public class ApplicationContextHolder implements ApplicationContextAware {

    private static ApplicationContext context;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        context = applicationContext;
    }

    public static ApplicationContext getContext() {
        return context;
    }
}

Third, entity constructor that takes another entity as input.

MyEntity

public MyEntity(MyEntity entity) {
    property1 = entity.property1;
    property2 = entity.property2;
    property3 = entity.property3;
    // ...
}

Fourth, entity constructor that takes a String as input, which should be the URI.

MyEntity

public MyEntity(String URI) {
    this(ApplicationContextHolder.getContext().getBean(EntityConverter.class).convert(new Link(URI.replace("{?projection}", "")), MyEntity.class));
}

Optionally, I have moved part of the code above to an Utils class.

I arrived at this solution by looking at the error message from the question post, which I was getting as well. Spring doesn't know how to construct an object from a String ? I'll show it how...

Like a said in a comment, however, doesn't work for nested entities' URIs.

My solution will be some compact. Not sure that it can be useful for all cases but for simple relation like .../entity/{id} it could parse. I've tested it on SDR & Spring Boot 2.0.3.RELEASE

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.mapping.context.PersistentEntities;
import org.springframework.data.repository.support.Repositories;
import org.springframework.data.repository.support.RepositoryInvokerFactory;
import org.springframework.data.rest.core.UriToEntityConverter;
import org.springframework.hateoas.Link;
import org.springframework.stereotype.Service;

import java.net.URI;
import java.util.Collections;

@Service
public class UriToEntityConversionService {

    @Autowired
    private MappingContext<?, ?> mappingContext; // OOTB

    @Autowired
    private RepositoryInvokerFactory invokerFactory; // OOTB

    @Autowired
    private Repositories repositories; // OOTB

    public <T> T convert(Link link, Class<T> target) {

        PersistentEntities entities = new PersistentEntities(Collections.singletonList(mappingContext));
        UriToEntityConverter converter = new UriToEntityConverter(entities, invokerFactory, repositories);

        URI uri = convert(link);
        Object o = converter.convert(uri, TypeDescriptor.valueOf(URI.class), TypeDescriptor.valueOf(target));
        T object = target.cast(o);
        if (object == null) {
            throw new IllegalArgumentException(String.format("%s '%s' was not found.", target.getSimpleName(), uri));
        }
        return object;
    }

    private URI convert(Link link) {
        try {
            return new URI(link.getHref());
        } catch (Exception e) {
            throw new IllegalArgumentException("URI from link is invalid", e);
        }
    }
}

Usage:

@Component
public class CategoryConverter implements Converter<CategoryForm, Category> {

    private UriToEntityConversionService conversionService;

    @Autowired
    public CategoryConverter(UriToEntityConversionService conversionService) {
            this.conversionService = conversionService;
    }

    @Override
    public Category convert(CategoryForm source) {
        Category category = new Category();
        category.setId(source.getId());
        category.setName(source.getName());
        category.setOptions(source.getOptions());

        if (source.getParent() != null) {
            Category parent = conversionService.convert(new Link(source.getParent()), Category.class);
            category.setParent(parent);
        }
        return category;
    }
}

Request JSON like:

{
    ...
    "parent": "http://localhost:8080/categories/{id}",
    ...
}

Unfortunately UriToEntityConverter (A Generic Converter that can convert a URI into an entity) which use Spring Data REST are not exported as a Bean or as a Service .

So we can not @Autowired it directly but it registered as Converter in Default Formatting Conversion Service .

Thus we manage to @Autowired Default Formatting Conversion Service and use them to convert a URI into an entity, for example:

@RestController
@RequiredArgsConstructor
public class InstanceController {

    private final DefaultFormattingConversionService formattingConversionService;

    @RequestMapping(path = "/api/instances", method = {RequestMethod.POST})
    public ResponseEntity<?> create(@RequestBody @Valid InstanceDTO instanceDTO) { // get something what you want from request

        // ...

        // extract URI from any string what you want to process
        final URI uri = "..."; // http://localhost:8080/api/instances/1
        // convert URI to Entity
        final Instance instance = formattingConversionService.convert(uri, Instance.class); // Instance(id=1, ...)

        // ...

    }

}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM