【问题标题】:RestTemplate POSTing entity with associations to Spring Data REST server与 Spring Data REST 服务器关联的 RestTemplate POSTing 实体
【发布时间】:2015-12-11 22:35:06
【问题描述】:

问题

我有三个实体(取自Spring Data REST Exporter Example):Person、Address 和 Profile。一个人可以有地址和个人资料。

@Entity
public class Person {

    @Id
    @GeneratedValue
    private Long id;
    private String name;
    @Version
    private Long version;
    @OneToMany
    private List<Address> addresses;
    @OneToMany
    private Map<String, Profile> profiles;

    // getters and setters
}

在客户端,我使用 Spring 的RestTemplate。我将Jackson2HalModule 添加到我的 RestTemplate 使用的 MappingJackson2HttpMessageConverter 使用的 ObjectMapper 中。

由于 Address 和 Profile 没有对其他实体的引用,我可以将它们 POST 到我的 Spring Data REST 服务器,并且它们已成功保存:

final ResponseEntity<Resource<Address>> response = restTemplate.postForEntity("http://localhost:8080/addresses",
                addressInstance, AddressResource.class);

AddressResource extends org.springframework.hateoas.Resource&lt;Address&gt;.

但是当我尝试发布一个 Person 实例时

final ResponseEntity<Resource<Person>> response = restTemplate.postForEntity("http://localhost:8080/people",
                personInstance, PersonResource.class);

我得到一个org.springframework.web.client.HttpClientErrorException: 400 Bad Request,我认为原因是相关的Addresses 和Profiles 被序列化为普通 POJO,而不是作为它们的资源 URI。

这是 POST 请求的实际正文:

{
   "id":null,
   "name":"Jongjin Han",
   "version":null,
   "addresses":[
      {
         "id":1,
         "lines":[
            "1111",
            "coder's street"
         ],
         "city":"San Diego",
         "province":"California",
         "postalCode":"60707"
      },
      {
         "id":2,
         "lines":[
            "1111",
            "coder's street"
         ],
         "city":"San Diego",
         "province":"California",
         "postalCode":"60707"
      }
   ],
   "profiles":{
      "key1":{
         "type":"a type of profile",
         "url":"http://www.profileurl.com"
      },
      "key2":{
         "type":"a type of profile",
         "url":"http://www.profileurl.com"
      }
   }
}

我认为应该是 --> 编辑:应该是

{
   "id":null,
   "name":"Jongjin Han",
   "version":null,
   "addresses":[
      "http://localhost:8080/addresses/1",
      "http://localhost:8080/addresses/2"
   ],
   "profiles":{
      "key1":"http://localhost:8080/profiles/1",
      "key2":"http://localhost:8080/profiles/2"
   }
}

实际上来自服务器的响应正文是

{
  "cause" : {
    "cause" : {
      "cause" : {
        "cause" : null,
        "message" : "Cannot resolve URI id. Is it local or remote? Only local URIs are resolvable."
      },
      "message" : "Failed to convert from type java.net.URI to type org.springframework.data.rest.example.model.Address for value 'id'; nested exception is java.lang.IllegalArgumentException: Cannot resolve URI id. Is it local or remote? Only local URIs are resolvable."
    },
    "message" : "Failed to convert from type java.net.URI to type org.springframework.data.rest.example.model.Address for value 'id'; nested exception is java.lang.IllegalArgumentException: Cannot resolve URI id. Is it local or remote? Only local URIs are resolvable. (through reference chain: org.springframework.data.rest.example.model.Person[\"addresses\"]->java.util.ArrayList[1])"
  },
  "message" : "Could not read document: Failed to convert from type java.net.URI to type org.springframework.data.rest.example.model.Address for value 'id'; nested exception is java.lang.IllegalArgumentException: Cannot resolve URI id. Is it local or remote? Only local URIs are resolvable. (through reference chain: org.springframework.data.rest.example.model.Person[\"addresses\"]->java.util.ArrayList[1]); nested exception is com.fasterxml.jackson.databind.JsonMappingException: Failed to convert from type java.net.URI to type org.springframework.data.rest.example.model.Address for value 'id'; nested exception is java.lang.IllegalArgumentException: Cannot resolve URI id. Is it local or remote? Only local URIs are resolvable. (through reference chain: org.springframework.data.rest.example.model.Person[\"addresses\"]->java.util.ArrayList[1])"
}

我想实施的可能解决方案

由于我可以从客户端访问 REST 存储库,我正在寻找一种方法来自定义 Jackson Json 序列化器,以便:

  • 检查我正在序列化的对象是否是 REST 导出的实体(很容易通过反射,只要我知道将代码放在哪里)和
  • 如果我序列化一个实体,像往常一样序列化非关联字段(例如人名)和关联字段作为资源 URI(例如人的地址)(通过反射应该很容易从实体到它的资源 URI,但我不知道再把代码放在哪里

我尝试使用 Jackson 的 JsonSerializer 和 PropertyFilters 来获取地址和配置文件,但我想要只有当它们处于关联时才将它们序列化为资源 URI 的序列化器

任何提示或替代解决方案都会有所帮助。

【问题讨论】:

    标签: json rest jackson resttemplate spring-data-rest


    【解决方案1】:

    配置不正确。

    您不必发布 HAL 格式的数据即可使其正常工作,序列化为 JSON 的普通旧 POJO 应该可以在默认配置下正常工作。

    我建议使用代理来拦截请求并确认结构。

    【讨论】:

    • 以这种方式,我应该在发布新的无关联 Person 实体之前,然后 PUT 关联引用(所以是链接,而不是 POJO)。带有关联 URI 的 JSON 序列化 is actually the right way(问题已编辑)
    【解决方案2】:

    我遇到了同样的问题,并尝试使用多种技术来解决它。 实施的工作解决方案 - 这是一个肮脏的解决方法,所以不要因为代码的质量而责备我,也许我会稍后清理它:) 我想测试 Spring Data REST API 并意识到 MappingJackson2HttpMessageConverter 忽略了 @Entity 关系。 设置序列化器修饰符无法正常工作:空值序列化器无法工作,并且使用深度属性序列化序列化了关系。

    解决方法的想法是提供 CustomSerializerModifier,它为项目 @Entities(在此示例中继承自 BaseEntity)返回 CustomSerializer。 CustomSerializer 执行以下操作:

    1. 写入空值(因为省略它们)
    2. 以 Spring Data REST 样式 (//) 提供相关 @Entities 数组作为列表
    3. 执行默认 MappingJackson2HttpMessageConverter 的序列化(...),但提供 NameTransformer 重命名关系键(添加“_@”),然后应用过滤器排除所有以“_@”开头的字段

    我不喜欢这个怪物,但它有效,遗憾的是我没有找到任何解决方案:/

    工作解决方案:

    BasicRestTest

    import com.fasterxml.jackson.core.Version;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.fasterxml.jackson.databind.module.SimpleModule;
    import com.fasterxml.jackson.databind.ser.FilterProvider;
    import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
    import com.meddis.util.serializer.CustomIgnorePropertyFilter;
    import com.meddis.util.serializer.CustomSerializerModifier;
    import org.junit.Before;
    import org.junit.runner.RunWith;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.hateoas.MediaTypes;
    import org.springframework.http.MediaType;
    import org.springframework.http.converter.HttpMessageConverter;
    import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
    import org.springframework.mock.http.MockHttpOutputMessage;
    import org.springframework.security.authentication.encoding.Md5PasswordEncoder;
    import org.springframework.test.context.ActiveProfiles;
    import org.springframework.test.context.TestPropertySource;
    import org.springframework.test.context.junit4.SpringRunner;
    import org.springframework.test.web.servlet.MockMvc;
    import org.springframework.web.context.WebApplicationContext;
    
    import java.io.IOException;
    import java.nio.charset.Charset;
    
    import static org.junit.Assert.assertNotNull;
    import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup;
    
    @RunWith(SpringRunner.class)
    @ActiveProfiles({"test"})
    @TestPropertySource(properties = {
            "timezone = UTC"
    })
    @SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
    public abstract class BasicRestTest {
    
        protected String host = "localhost";
    
        @Value("${local.server.port}")
        protected int port;
    
        @Value("${spring.data.rest.basePath}")
        protected String springDataRestBasePath;
    
        protected MediaType contentType = new MediaType("application", "hal+json", Charset.forName("utf8"));
    
        protected MockMvc mockMvc;
    
        private static HttpMessageConverter mappingJackson2HttpMessageConverter;
    
        protected ObjectMapper objectMapper;
    
        @Autowired
        private WebApplicationContext webApplicationContext;
    
        @Autowired
        void setConverters(HttpMessageConverter<?>[] converters) {
    
            this.objectMapper = new ObjectMapper();
    
            if (this.mappingJackson2HttpMessageConverter == null) {
                this.mappingJackson2HttpMessageConverter = new MappingJackson2HttpMessageConverter();
                SimpleModule simpleModule = new SimpleModule("CUSTOM", Version.unknownVersion());
                simpleModule.setSerializerModifier(new CustomSerializerModifier(springDataRestBasePath));
                ((MappingJackson2HttpMessageConverter) this.mappingJackson2HttpMessageConverter).getObjectMapper()
                        .registerModule(simpleModule);
    
                FilterProvider fp = new SimpleFilterProvider().addFilter("CUSTOM", new CustomIgnorePropertyFilter());
                ((MappingJackson2HttpMessageConverter) this.mappingJackson2HttpMessageConverter).getObjectMapper()
                        .setFilterProvider(fp);
    
                ((MappingJackson2HttpMessageConverter) this.mappingJackson2HttpMessageConverter).setPrettyPrint(true);
            }
    
            assertNotNull("the JSON message converter must not be null", this.mappingJackson2HttpMessageConverter);
        }
    
        @Before
        public void setup() throws Exception {
            this.mockMvc = webAppContextSetup(webApplicationContext).build();
        }
    
        protected String json(final Object o) throws IOException {
            MockHttpOutputMessage mockHttpOutputMessage = new MockHttpOutputMessage();
            this.mappingJackson2HttpMessageConverter.write(o, MediaTypes.HAL_JSON, mockHttpOutputMessage);
            return mockHttpOutputMessage.getBodyAsString();
        }
    }
    

    CustomSerializerModifier

    import com.fasterxml.jackson.databind.BeanDescription;
    import com.fasterxml.jackson.databind.JsonSerializer;
    import com.fasterxml.jackson.databind.SerializationConfig;
    import com.fasterxml.jackson.databind.ser.BeanSerializerModifier;
    import com.meddis.model.BaseEntity;
    
    public class CustomSerializerModifier extends BeanSerializerModifier {
    
        private final String springDataRestBasePath;
    
        public CustomSerializerModifier(final String springDataRestBasePath) {
            this.springDataRestBasePath = springDataRestBasePath;
        }
    
        @Override
        public JsonSerializer<?> modifySerializer(SerializationConfig config, BeanDescription beanDesc, JsonSerializer<?> serializer) {
            if (BaseEntity.class.isAssignableFrom(beanDesc.getBeanClass())) {
                return new CustomSerializer((JsonSerializer<Object>) serializer, springDataRestBasePath);
            }
            return serializer;
        }
    }
    

    CustomSerializer

    import com.fasterxml.jackson.core.JsonGenerator;
    import com.fasterxml.jackson.core.JsonProcessingException;
    import com.fasterxml.jackson.databind.JsonSerializer;
    import com.fasterxml.jackson.databind.SerializerProvider;
    import com.fasterxml.jackson.databind.util.NameTransformer;
    import com.google.common.base.Preconditions;
    import com.meddis.model.BaseEntity;
    
    import java.io.IOException;
    import java.lang.reflect.InvocationTargetException;
    import java.util.*;
    
    public class CustomSerializer extends JsonSerializer<Object> {
    
        private final JsonSerializer<Object> defaultSerializer;
    
        private final String springDataRestBasePath;
    
        public CustomSerializer(JsonSerializer<Object> defaultSerializer, final String springDataRestBasePath) {
            this.defaultSerializer = Preconditions.checkNotNull(defaultSerializer);
            this.springDataRestBasePath = springDataRestBasePath;
        }
    
        @SuppressWarnings("unchecked")
        @Override
        public void serialize(Object baseEntity, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)
                throws IOException, JsonProcessingException {
    
            jsonGenerator.writeStartObject();
    
            Set<String> nestedEntityKeys = new HashSet<>();
    
            Arrays.asList(baseEntity.getClass().getMethods()).stream()
                    .filter(field -> field.getName().startsWith("get"))
                    .filter(field -> !Arrays.asList("getClass", "getVersion").contains(field.getName()))
                    .forEach(field -> {
                        try {
                            Object value = field.invoke(baseEntity, new Object[]{});
                            String fieldName = field.getName().replaceAll("^get", "");
                            fieldName = fieldName.substring(0, 1).toLowerCase() + fieldName.substring(1);
                            if (value == null) {
                                jsonGenerator.writeObjectField(fieldName, null);
                            } else if (Iterable.class.isAssignableFrom(value.getClass())) {
                                Iterator it = ((Iterable) value).iterator();
                                // System.out.println(field.getName() + field.invoke(baseEntity, new Object[]{}));
                                List<String> nestedUris = new ArrayList<>();
                                it.forEachRemaining(nestedValue -> {
                                    if (BaseEntity.class.isAssignableFrom(nestedValue.getClass())) {
                                        try {
                                            String nestedEntityStringDataName = nestedValue.getClass().getSimpleName() + "s";
                                            nestedEntityStringDataName = nestedEntityStringDataName.substring(0, 1).toLowerCase() + nestedEntityStringDataName.substring(1);
                                            Long nestedId = (long) nestedValue.getClass().getMethod("getId").invoke(nestedValue, new Object[]{});
                                            String nestedEntitySpringDataPath = springDataRestBasePath + "/" + nestedEntityStringDataName + "/" + Long.toString(nestedId);
                                            nestedUris.add(nestedEntitySpringDataPath);
                                        } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException ignored) {
                                        }
                                    }
                                });
                                nestedEntityKeys.add(fieldName);
                                jsonGenerator.writeObjectField(fieldName, nestedUris);
                            }
                        } catch (Throwable ignored) {
                        }
                    });
    
            // Apply default serializer
            ((JsonSerializer<Object>) defaultSerializer.unwrappingSerializer(new NameTransformer() {
                @Override
                public String transform(String s) {
                    if (nestedEntityKeys.contains(s)) {
                        return "_@" + s;
                    }
                    return s;
                }
    
                @Override
                public String reverse(String s) {
                    if (nestedEntityKeys.contains(s.substring(2))) {
                        return s.substring(2);
                    }
                    return s;
                }
            }).withFilterId("CUSTOM")).serialize(baseEntity, jsonGenerator, serializerProvider);
    
            jsonGenerator.writeEndObject();
        }
    }
    

    CustomIgnorePropertyFilter

    import com.fasterxml.jackson.core.JsonGenerator;
    import com.fasterxml.jackson.databind.JsonMappingException;
    import com.fasterxml.jackson.databind.SerializerProvider;
    import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonObjectFormatVisitor;
    import com.fasterxml.jackson.databind.node.ObjectNode;
    import com.fasterxml.jackson.databind.ser.PropertyWriter;
    import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
    
    public class CustomIgnorePropertyFilter extends SimpleBeanPropertyFilter {
    
        @Override
        public void serializeAsField(Object o, JsonGenerator jsonGenerator, SerializerProvider serializerProvider, PropertyWriter propertyWriter) throws Exception {
            if (propertyWriter.getName().startsWith("_@")) {
                return;
            }
            super.serializeAsField(o, jsonGenerator, serializerProvider, propertyWriter);
        }
    
        @Override
        public void serializeAsElement(Object o, JsonGenerator jsonGenerator, SerializerProvider serializerProvider, PropertyWriter propertyWriter) throws Exception {
            if (propertyWriter.getName().startsWith("_@")) {
                return;
            }
            super.serializeAsElement(o, jsonGenerator, serializerProvider, propertyWriter);
        }
    
        @Override
        public void depositSchemaProperty(PropertyWriter propertyWriter, ObjectNode objectNode, SerializerProvider serializerProvider) throws JsonMappingException {
            if (propertyWriter.getName().startsWith("_@")) {
                return;
            }
            super.depositSchemaProperty(propertyWriter, objectNode, serializerProvider);
        }
    
        @Override
        public void depositSchemaProperty(PropertyWriter propertyWriter, JsonObjectFormatVisitor jsonObjectFormatVisitor, SerializerProvider serializerProvider) throws JsonMappingException {
            if (propertyWriter.getName().startsWith("_@")) {
                return;
            }
            super.depositSchemaProperty(propertyWriter, jsonObjectFormatVisitor, serializerProvider);
        }
    }
    

    VideoStreamRestTest

    import com.meddis.AdminApiTest;
    import com.meddis.model.VideoStream;
    import com.meddis.repository.SpecialistRepository;
    import com.meddis.repository.VideoStreamTagRepository;
    import org.junit.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.test.web.servlet.MvcResult;
    
    import java.util.stream.Collectors;
    import java.util.stream.StreamSupport;
    
    import static org.hamcrest.Matchers.*;
    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
    
    /**
     * <a href="https://spring.io/guides/tutorials/bookmarks/">example</a>
     */
    public class VideoStreamRestTest extends AdminApiTest {
    
        @Autowired
        private SpecialistRepository specialistRepository;
    
        @Autowired
        private VideoStreamTagRepository videoStreamTagRepository;
    
        @Test
        public void springDataRestVideoStreams() throws Exception {
            String requestBody;
            String newEntityTitle = md5("VIDEO_STREAM_");
            MvcResult create = mockMvc.perform(post(springDataRestBasePath + "/videoStreams").headers(authenticationHeader)
                    .content(requestBody = json(new VideoStream()
                            .setTitle(newEntityTitle)
                            .setType(VideoStream.Type.BROADCAST)
                            .setPrice(10.0)
                            .setDurationInMinutes(70)
                            .setDescription("broadcast description")
                            .setPreviewUrl("http://example.com")
                            .setSpecialists(StreamSupport.stream(specialistRepository.findAll().spliterator(), false).collect(Collectors.toList()))
                            .setTags(StreamSupport.stream(videoStreamTagRepository.findAll().spliterator(), false).collect(Collectors.toList())))))
                    .andExpect(status().isCreated())
                    .andReturn();
            String createdLocation = create.getResponse().getHeader("Location");
            logger.info("Created new entity: {}", createdLocation);
            logger.info("Sent: {}", requestBody);
    
            MvcResult list = mockMvc.perform(get(springDataRestBasePath + "/videoStreams").headers(authenticationHeader))
                    .andExpect(status().isOk())
                    .andExpect(content().contentType(contentType))
                    .andExpect(jsonPath("$._embedded.videoStreams", hasSize(greaterThanOrEqualTo(1))))
                    .andExpect(jsonPath("$._embedded.videoStreams[*].title", hasItem(newEntityTitle)))
                    .andExpect(jsonPath("$._embedded.videoStreams[*]._links.self.href", hasItem(createdLocation)))
                    .andReturn();
            logger.info("Got list containing new entity:\n{}", list.getResponse().getContentAsString());
    
            MvcResult createdEntity = mockMvc.perform(get(createdLocation).headers(authenticationHeader))
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$._links.self.href", equalTo(createdLocation)))
                    .andExpect(jsonPath("$.title", equalTo(newEntityTitle)))
                    .andReturn();
            logger.info("Got new entity:\n{}", createdEntity.getResponse().getContentAsString());
        }
    
    }
    

    AdminApiTest

    import com.fasterxml.jackson.databind.JsonNode;
    import org.junit.Before;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.boot.test.web.client.TestRestTemplate;
    import org.springframework.http.HttpHeaders;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.ResponseEntity;
    import org.springframework.test.web.servlet.MvcResult;
    
    import java.io.IOException;
    import java.util.ArrayList;
    import java.util.List;
    
    import static org.junit.Assert.assertEquals;
    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
    
    public abstract class AdminApiTest extends BasicRestTest {
    
        protected final Logger logger = LoggerFactory.getLogger(this.getClass());
    
        protected HttpHeaders authenticationHeader;
    
        @Before
        @Override
        public void setup() throws Exception {
            super.setup();
            this.authenticationHeader = createHeaderWithAuthentication();
        }
    
        protected HttpHeaders createHeaderWithAuthentication() throws IOException {
            String user = "pasha@pasha.ru";
            String password = "pasha";
            ResponseEntity<String> response = new TestRestTemplate()
                    .postForEntity(
                            "http://" + host + ":" + port
                                    + "login?"
                                    + "&username=" + user
                                    + "&password=" + password,
                            null,
                            String.class
                    );
            assertEquals(HttpStatus.FOUND, response.getStatusCode());
            List<String> authenticationCookie = response.getHeaders().get("Set-Cookie");
            assertEquals(1, authenticationCookie.size());
            HttpHeaders headers = new HttpHeaders();
            headers.set("Cookie", authenticationCookie.get(0));
            return headers;
        }
    
    }
    

    【讨论】:

      【解决方案3】:

      我也遇到过类似的问题,我的解决方案是将@RestResource(exported = false) 添加到关联属性中。

      @Entity
      @Setter
      @Getter
      @EqualsAndHashCode(onlyExplicitlyIncluded = true)
      @ToString
      @AllArgsConstructor
      @NoArgsConstructor
      public class Developer {
      
          @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @EqualsAndHashCode.Include
          private Long id;
      
          @Column(length=20, nullable = false)
          @Size(min = 3, message = "insufficient.size")
          @Size(max = 20, message = "maximum.size.exceeded")
          @NotEmpty(message = "compulsory.field")
          private String nickname;
      
          @Column(length=100, nullable = false)
          @Email(message = "invalid.field.value")
          @NotEmpty(message = "compulsory.field")
          private String email;
      
          private LocalDate dob;
      
          @ManyToMany
          @JoinTable(name = "developer_skills",
                  joinColumns = @JoinColumn(name = "developer_id"),
                  inverseJoinColumns = @JoinColumn(name = "skills_uuid"))
          @RestResource(exported = false)
          private List<Skill> skills;
      
      }
      
      
      @Entity
      @Setter
      @Getter
      @EqualsAndHashCode(onlyExplicitlyIncluded = true)
      @ToString
      @AllArgsConstructor
      @NoArgsConstructor
      public class Skill {
          @Id @EqualsAndHashCode.Include
          @Column(name = "uuid", nullable = false)
          private String uuid;
      
          @Column(length=20, nullable = false)
          @Size(min = 1, message = "insufficient.size")
          @Size(max = 20, message = "maximum.size.exceeded")
          @NotEmpty(message = "compulsory.field")
          private String shortName;
      
          @Column(length=50, nullable = false)
          @NotEmpty(message = "compulsory.field")
          @Size(max = 50, message = "maximum.size.exceeded")
          private String name;
      
          @Column(columnDefinition="TEXT", nullable = false)
          @Size(max = 500, message = "maximum.size.exceeded")
          private String description;
      }
      

      【讨论】:

        猜你喜欢
        • 2019-01-07
        • 2016-01-19
        • 2012-10-04
        • 2015-09-09
        • 1970-01-01
        • 2015-02-09
        • 1970-01-01
        • 2018-10-08
        • 1970-01-01
        相关资源
        最近更新 更多