Разрешение URI объекта в пользовательском контроллере (Spring HATEOAS)

У меня есть проект, основанный на spring-data-rest, и у него есть несколько пользовательских конечных точек.

Для отправки данных POST я использую json как

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

Это хорошо для spring-data-rest, но не работает с пользовательским контроллером.

например:

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);
  }
}

Когда я звоню

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

У меня есть ответ

{
"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
}

потому что Spring не может преобразовать URI в сущность Customer.

Есть ли способ использовать механизм Spring-Data-Rest для разрешения сущностей по их URI?

У меня есть только одна идея - использовать пользовательский JsonDeserializer с разбором URI для извлечения entityId и отправки запроса в хранилище. Но эта стратегия мне не поможет, если у меня есть URI, такой как " http://localhost:8080/api/rest/customers/8/product", в этом случае у меня нет значения product.Id.

6 ответов

У меня тоже очень долго была такая же проблема, и я решил ее следующим образом. @Florian был на правильном пути, и благодаря его предложению я нашел способ сделать преобразование работать автоматически. Есть несколько частей, необходимых:

  1. Служба преобразования, позволяющая преобразовать URI в объект (используя UriToEntityConverter, предоставляемый с платформой)
  2. Десериализатор, чтобы определить, когда уместно вызывать конвертер (мы не хотим портить поведение SDR по умолчанию)
  3. Пользовательский модуль Джексона, чтобы перенести все на SDR

Для пункта 1 реализация может быть сужена до следующего

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;
   }
}

Для пункта 2 это мое решение

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));
      }
   }
}

Затем для пункта 3 в пользовательском 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);
         }
      });
   }
}

Это работает для меня гладко и не мешает любому преобразованию из фреймворка (у нас есть много пользовательских конечных точек). В пункте 2 цель состояла в том, чтобы включить создание экземпляров из URI только в тех случаях, когда:

  1. Десериализованная сущность является корневой сущностью (поэтому нет свойств)
  2. Предоставленная строка является фактическим URI (в противном случае она просто возвращается к поведению по умолчанию)

Для HAL с @RequestBody использование Resource<T> в качестве параметра метода вместо сущности Action разрешить преобразование связанных ресурсов URI

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

Это скорее дополнительная заметка, а не реальный ответ, но некоторое время назад мне удалось скопировать и вставить класс, чтобы разрешить сущности из URL, используя методы, используемые в SDR (просто более грубые). Вероятно, есть намного лучший способ, но до тех пор, возможно, это поможет...

@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);
    }

}

И да, вполне вероятно, что части этого класса просто бесполезны. В мою защиту, это был всего лишь короткий взлом, и я так и не нашел его, потому что сначала нашел другие проблемы;-)

К сожалению, UriToEntityConverter(универсальный преобразователь, который может преобразовывать URI в объект), использующий Spring Data REST, не экспортируется как Bean или как служба.

Так что мы не можем @Autowiredон напрямую, но зарегистрирован как Converter в Службе преобразования форматирования по умолчанию.

Таким образом нам удается @Autowired Служба преобразования форматирования по умолчанию и используйте их для преобразования URI в сущность, например:

@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, ...)

        // ...

    }

}

Я не могу в это поверить. Обдумав это в течение МЕСЯЦА (!), Мне удалось РЕШИТЬ ЭТО!

Несколько слов введения:

Spring HATEOAS использует URI в качестве ссылок на сущности. И это обеспечивает большую поддержку для получения этих ссылок URI для данного объекта. Например, когда клиент запрашивает объект, который ссылается на другие дочерние объекты, клиент получит эти URI. Приятно работать.

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

Клиент REST работает только с этими Uris. Клиент REST НЕ ДОЛЖЕН знать структуру этих URI. Клиент REST не знает, что в конце строки URI находится внутренний идентификатор БД.

Все идет нормально. НО пружинные данные HATEOAS не предлагает никаких функций для преобразования URI обратно в соответствующий объект (загруженный из БД). Это нужно каждому в пользовательских контроллерах REST. (См. Вопрос выше)

Вспомните пример, где вы хотите работать с пользователем в пользовательском контроллере REST. Клиент отправит этот запрос

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

Как пользовательский контроллер REST должен десериализоваться из (String) URI в UserModel? Я нашел способ: Вы должны настроить десериализацию Джексона в вашем 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) Разбор этой строки ужасен. Я знаю. Но это только обратная сторона org.springframework.hateoas.EntityLinks и его реализаций. И автор пружинных hateos упорно отказывается предлагать служебные методы для обоих направлений.

Мое решение будет несколько компактным. Не уверен, что это может быть полезно для всех случаев, но для простых отношений, таких как .../entity/{id} это может разобрать. Я протестировал его на 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);
        }
    }
}

Использование:

@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;
    }
}

Запрос JSON, как:

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

Я пришел к следующему решению. Это немного хакерски, но работает.

Во-первых, сервис для преобразования URI в сущности.

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);
    }
}

Во-вторых, компонент, чтобы иметь возможность использовать EntityConverter вне контекста Spring.

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;
    }
}

В-третьих, конструктор сущностей, который принимает другую сущность в качестве входных данных.

MyEntity

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

В-четвертых, конструктор сущности, который принимает String в качестве ввода, который должен быть URI.

MyEntity

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

По желанию, я переместил часть кода выше в Utils учебный класс.

Я пришел к этому решению, посмотрев сообщение об ошибке в вопросе, которое я тоже получал. Spring не знает, как построить объект из String? Я покажу это как...

Однако, как сказано в комментарии, это не работает для URI вложенных объектов.

Другие вопросы по тегам