spring-data-rest icon indicating copy to clipboard operation
spring-data-rest copied to clipboard

Posting to a nested collection resource does not work as expected [DATAREST-608]

Open spring-projects-issues opened this issue 10 years ago • 5 comments

Chris Beams opened DATAREST-608 and commented

Given entities Person and Posession, where Person has a collection of Possessions and both entities are exposed via SD REST repositories respectively, I would expect to be able to do something like the following:

POST /person/1/posessions
{ "name": "car" }

However, such a POST returns 405 or 204 depending on the context in which I call it (haven't debugged this fully).

It does of course work to post the new possession directly against its root collection resource:

POST /posessions
{ "name": "car", "person": "/person/1" }

I don't see anything in the documentation stating that mutative operations on nested collection resources are (or are not) supported. Am I missing something?

Note that it does seem like this use case is intended to be supported, based on tracing my way through RepositoryPropertyReferenceController#createPropertyReference (which does get called in the cases where the request does not immediately return 405). What I don't understand here is why this method calls #doWithReferencedProperty but then always returns 204 even for POSTs.


Affects: 2.4 M1 (Gosling)

3 votes, 4 watchers

spring-projects-issues avatar Jul 04 '15 01:07 spring-projects-issues

Chris Beams commented

And note that in any case, my changes do not get persisted. It looks like it's going to happen in RepositoryPropertyReferenceController, but then never does (and again, I haven't completely debugged this—just want to check in at this point and see if I'm barking up the wrong tree trying to do this in the first place)

spring-projects-issues avatar Jul 04 '15 01:07 spring-projects-issues

Christopher Smith commented

Ran into this same problem. I believe that the functionality should be supported, though it might be complicated to parse it into an appropriate entity time. However, in the interim, the behavior of silently dropping the POST on the floor and returning a success status is clearly buggy--some sort of error code needs to be returned

spring-projects-issues avatar Oct 10 '16 04:10 spring-projects-issues

Daniele Renda commented

I've the same problem but I guess this is documented here https://docs.spring.io/spring-data/rest/docs/current/reference/html/#_post_2: , where it says "Only supported for collection associations. Adds a new element to the collection. Supported media types - text/uri-list - URIs pointing to the resource to add to the association.".

My understanding is that are supported just link and not the entire bean. If that's correct, I would really like the idea to add an element passing the json representation rather just the link. In several scenario the new elements cannot be insert "before" because the link to the parent entity is mandatory.

I would appreciate someone of SDR makes some consideration about this. Would be very useful

spring-projects-issues avatar Oct 13 '17 23:10 spring-projects-issues

It would be great if this issue gets resolved, configuring custom converters to be able to post entire bean instead of link is not very nice to say at least

valb3r avatar Aug 03 '22 13:08 valb3r

@odrotbohm It is possible to achieve this feature now (Spring Boot 2.7.2) using following configuration:

package com.my.project.config;

import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.DeserializationConfig;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.deser.BeanDeserializerBuilder;
import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier;
import com.fasterxml.jackson.databind.deser.CreatorProperty;
import com.fasterxml.jackson.databind.deser.SettableBeanProperty;
import com.fasterxml.jackson.databind.deser.ValueInstantiator;
import com.fasterxml.jackson.databind.deser.std.CollectionDeserializer;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import com.fasterxml.jackson.databind.deser.std.StdValueInstantiator;
import com.fasterxml.jackson.databind.module.SimpleModule;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
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.data.rest.core.config.RepositoryRestConfiguration;
import org.springframework.data.rest.core.mapping.ResourceMappings;
import org.springframework.data.rest.core.support.EntityLookup;
import org.springframework.data.rest.webmvc.EmbeddedResourcesAssembler;
import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer;
import org.springframework.data.rest.webmvc.json.PersistentEntityJackson2Module;
import org.springframework.data.rest.webmvc.mapping.Associations;
import org.springframework.data.rest.webmvc.mapping.LinkCollector;
import org.springframework.data.rest.webmvc.support.ExcerptProjector;
import org.springframework.data.util.StreamUtils;
import org.springframework.hateoas.server.mvc.RepresentationModelProcessorInvoker;
import org.springframework.plugin.core.PluginRegistry;
import org.springframework.util.ReflectionUtils;

import java.io.IOException;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

import static com.fasterxml.jackson.core.JsonToken.START_OBJECT;

// Allows POST'ing nested objects and not only links
@Configuration
public class CustomRepositoryRestMvcConfiguration implements RepositoryRestConfigurer {

    private final ApplicationContext context;
    private final PersistentEntities entities;
    private final RepositoryInvokerFactory invokerFactory;
    private final Repositories repositories;
    private final Associations associations;
    private final ExcerptProjector projector;
    private final ObjectProvider<RepresentationModelProcessorInvoker> modelInvoker;
    private final LinkCollector linkCollector;
    private final RepositoryRestConfiguration repositoryRestConfiguration;

    public CustomRepositoryRestMvcConfiguration(
            ApplicationContext context,
            PersistentEntities entities,
            @Lazy RepositoryInvokerFactory invokerFactory,
            Repositories repositories,
            @Lazy Associations associations,
            @Lazy ExcerptProjector projector,
            @Lazy ObjectProvider<RepresentationModelProcessorInvoker> modelInvoker,
            @Lazy LinkCollector linkCollector,
            @Lazy RepositoryRestConfiguration repositoryRestConfiguration) {
        this.context = context;
        this.entities = entities;
        this.invokerFactory = invokerFactory;
        this.repositories = repositories;
        this.associations = associations;
        this.projector = projector;
        this.modelInvoker = modelInvoker;
        this.linkCollector = linkCollector;
        this.repositoryRestConfiguration = repositoryRestConfiguration;
    }

    @Override
    public void configureJacksonObjectMapper(ObjectMapper objectMapper) {
        objectMapper.registerModule(persistentEntityJackson2Module(linkCollector));
    }

    protected Module persistentEntityJackson2Module(LinkCollector linkCollector) {
        List<EntityLookup<?>> lookups = new ArrayList<>();
        lookups.addAll(repositoryRestConfiguration.getEntityLookups(repositories));
        lookups.addAll((Collection) beansOfType(context, EntityLookup.class).get());

        EmbeddedResourcesAssembler assembler = new EmbeddedResourcesAssembler(entities, associations, projector);
        PersistentEntityJackson2Module.LookupObjectSerializer lookupObjectSerializer = new PersistentEntityJackson2Module.LookupObjectSerializer(PluginRegistry.of(lookups));

        // AssociationUriResolvingDeserializerModifier delegates
        return new NestedSupportPersistentEntityJackson2Module(associations,
                entities,
                new UriToEntityConverter(entities, invokerFactory, repositories),
                linkCollector,
                invokerFactory,
                lookupObjectSerializer,
                modelInvoker.getObject(),
                assembler
        );
    }

    public static class NestedSupportPersistentEntityJackson2Module extends PersistentEntityJackson2Module {

        public NestedSupportPersistentEntityJackson2Module(Associations associations,
                                                           PersistentEntities entities,
                                                           UriToEntityConverter converter,
                                                           LinkCollector collector,
                                                           RepositoryInvokerFactory factory,
                                                           LookupObjectSerializer lookupObjectSerializer,
                                                           RepresentationModelProcessorInvoker invoker,
                                                           EmbeddedResourcesAssembler assembler) {
            super(associations, entities, converter, collector, factory, lookupObjectSerializer, invoker, assembler);
        }

        @Override
        public SimpleModule setDeserializerModifier(BeanDeserializerModifier mod) {
            super.setDeserializerModifier(new NestedObjectSuppAssociationUriResolvingDeserializerModifier(
                    (PersistentEntityJackson2Module.AssociationUriResolvingDeserializerModifier) mod)
            );
            return this;
        }
    }

    @RequiredArgsConstructor
    public static class NestedObjectSuppAssociationUriResolvingDeserializerModifier extends BeanDeserializerModifier {

        private final PersistentEntityJackson2Module.AssociationUriResolvingDeserializerModifier uriDelegate;

        @SneakyThrows
        @Override
        public BeanDeserializerBuilder updateBuilder(DeserializationConfig config,
                                                     BeanDescription beanDesc,
                                                     BeanDeserializerBuilder builder) {
            // Pushes Uri* deserializer
            uriDelegate.updateBuilder(config, beanDesc, builder);
            // Replace Uri* deserializers with delegates
            var customizer = new ValueInstantiatorCustomizer(builder.getValueInstantiator(), config);
            var properties = builder.getProperties();
            while (properties.hasNext()) {
                var prop = properties.next();
                if (!prop.hasValueDeserializer()) {
                    continue;
                }

                if (prop.getValueDeserializer() instanceof PersistentEntityJackson2Module.UriStringDeserializer) {
                    customizer.replacePropertyIfNeeded(
                            builder,
                            prop.withValueDeserializer(new ObjectOrUriStringDeserializer(
                                    prop.getValueDeserializer().handledType(),
                                    prop.getValueDeserializer(),
                                    new LateDelegatingDeser(prop.getType())
                            ))
                    );
                }

                if ((Object) prop.getValueDeserializer() instanceof CollectionDeserializer) {
                    var collDeser = (CollectionDeserializer) ((Object) prop.getValueDeserializer());
                    if (!(collDeser.getContentDeserializer() instanceof PersistentEntityJackson2Module.UriStringDeserializer)) {
                        continue;
                    }

                    customizer.replacePropertyIfNeeded(
                            builder,
                            prop.withValueDeserializer(
                                    new CollectionDeserializer(
                                            collDeser.getValueType(),
                                            new ObjectOrUriStringDeserializer(
                                                    prop.getValueDeserializer().handledType(),
                                                    ((CollectionDeserializer) (Object) prop.getValueDeserializer()).getContentDeserializer(),
                                                    new LateDelegatingDeser(prop.getType().getContentType())
                                            ),
                                            null,
                                            collDeser.getValueInstantiator()
                                    )
                            )
                    );
                }

            }
            return customizer.conclude(builder);
        }

        @Getter
        @RequiredArgsConstructor
        public static class LateDelegatingDeser extends JsonDeserializer<Object> {

            private final JavaType type;

            @Override
            public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JacksonException {
                return ctxt.findNonContextualValueDeserializer(type).deserialize(p, ctxt);
            }
        }
    }

    public static class ObjectOrUriStringDeserializer extends StdDeserializer<Object> {

        private final JsonDeserializer<Object> uriDelegate;
        private final JsonDeserializer<Object> vanillaDelegate;

        public ObjectOrUriStringDeserializer(Class<?> type, JsonDeserializer<Object> uriDelegate, JsonDeserializer<Object> vanillaDelegate) {
            super(type);
            this.uriDelegate = uriDelegate;
            this.vanillaDelegate = vanillaDelegate;
        }

        @Override
        public Object deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JacksonException {
            if (START_OBJECT == jp.getCurrentToken()) {
                return vanillaDelegate.deserialize(jp, ctxt);
            }

            return uriDelegate.deserialize(jp, ctxt);
        }
    }

    // Copied from original ValueInstantiatorCustomizer
    public static class ValueInstantiatorCustomizer {

        private final SettableBeanProperty[] properties;
        private final StdValueInstantiator instantiator;

        ValueInstantiatorCustomizer(ValueInstantiator instantiator, DeserializationConfig config) {

            this.instantiator = StdValueInstantiator.class.isInstance(instantiator) //
                    ? StdValueInstantiator.class.cast(instantiator) //
                    : null;

            this.properties = this.instantiator == null || this.instantiator.getFromObjectArguments(config) == null //
                    ? new SettableBeanProperty[0] //
                    : this.instantiator.getFromObjectArguments(config).clone(); //
        }

        /**
         * Replaces the logically same property with the given {@link SettableBeanProperty} on the given
         * {@link BeanDeserializerBuilder}. In case we get a {@link CreatorProperty} we also register that one to be later
         * exposed via the {@link ValueInstantiator} backing the {@link BeanDeserializerBuilder}.
         *
         * @param builder must not be {@literal null}.
         * @param property must not be {@literal null}.
         */
        void replacePropertyIfNeeded(BeanDeserializerBuilder builder, SettableBeanProperty property) {

            builder.addOrReplaceProperty(property, false);

            if (!CreatorProperty.class.isInstance(property)) {
                return;
            }

            properties[((CreatorProperty) property).getCreatorIndex()] = property;
        }

        /**
         * Concludes the setup of the given {@link BeanDeserializerBuilder} by reflectively registering the potentially
         * customized {@link SettableBeanProperty} instances in the {@link ValueInstantiator} backing the builder.
         *
         * @param builder must not be {@literal null}.
         * @return
         */
        BeanDeserializerBuilder conclude(BeanDeserializerBuilder builder) {

            if (instantiator == null) {
                return builder;
            }

            Field field = ReflectionUtils.findField(StdValueInstantiator.class, "_constructorArguments");
            ReflectionUtils.makeAccessible(field);
            ReflectionUtils.setField(field, instantiator, properties);

            builder.setValueInstantiator(instantiator);

            return builder;
        }
    }

    private static <S> org.springframework.data.util.Lazy<List<S>> beansOfType(ApplicationContext context, Class<?> type) {

        return org.springframework.data.util.Lazy.of(() -> (List<S>) context.getBeanProvider(type)
                .orderedStream()
                .collect(StreamUtils.toUnmodifiableList()));
    }
}

To be honest it is quite terrible but it works, so if not supporting fully this feature, making PersistentEntityJackson2Module more configurable (currently it is extremely hard to configure) by exposing modifiers would at least help

valb3r avatar Aug 04 '22 14:08 valb3r