spring-cloud / spring-cloud-openfeign

Support for using OpenFeign in Spring Cloud apps
Apache License 2.0
1.21k stars 784 forks source link

@SpringQueryMap nested object support #442

Open zxy-c opened 3 years ago

zxy-c commented 3 years ago

FeignClient

interface MyFeignClient{
    @GetMapping(params = {"projection=wishSummary"})
   Object get(
            @SpringQueryMap MyObject myObject);
}

MyObject

class MyObject{
   Range range;
}
class Range{
  Integer from;
  Integer to;
}

Feign will build query param like "Range(from=1,to=2)" when I call MyFeignClient.get

I need a query param like "range.from=1&range.to=2" at the example

o2-mesmer commented 3 years ago

@zxy-c You can define a custom feign.QueryMapEncoder and wire it through the FeignClient's configuration.

duzhongyuan commented 3 years ago

For nested object, Collection nested objects. Rewrite FieldQueryMapEncoder .java

public class CustomNestedObjectQueryMapEncoder implements QueryMapEncoder {

    private final Map<Class<?>, CustomNestedObjectQueryMapEncoder.ObjectParamMetadata> classToMetadata =
            new HashMap<Class<?>, CustomNestedObjectQueryMapEncoder.ObjectParamMetadata>();

    @Override
    public Map<String, Object> encode(Object object) throws EncodeException {
        return encodeInternal(null, object, null);
    }

    private Map<String, Object> encodeInternal(String prefixName, Object object, Map<String, Object> fieldNameToValue) {
        if (null == fieldNameToValue) {
            fieldNameToValue = Maps.newHashMap();
        }

        try {
            CustomNestedObjectQueryMapEncoder.ObjectParamMetadata metadata = getMetadata(object.getClass());
            for (Field field : metadata.objectFields) {
                Object value = field.get(object);

                if (value != null && value != object) {
                    Param alias = field.getAnnotation(Param.class);
                    String name = alias != null ? alias.value() : field.getName();

                    if (StringUtils.isNotBlank(prefixName)) {
                        name = prefixName + "." + name;
                    }

                    ClassLoader classLoader = value.getClass().getClassLoader();

                    if (classLoader == null) {

                        processNameAndValue(name, value, fieldNameToValue);

                    } else {
                        // Recursive call
                        encodeInternal(name, value, fieldNameToValue);
                    }
                }
            }

            return fieldNameToValue;
        } catch (IllegalAccessException e) {
            throw new EncodeException("Failure encoding object into query map", e);
        }
    }

    private void processNameAndValue(String name, Object value, Map<String, Object> fieldNameToValue) throws IllegalAccessException {
        // Determines whether it is a custom object collection
        if (isCustomObjectCollection(value)) {
            Collection collection = (Collection) value;

            for (int i = 0; i < collection.size(); i++) {
                Object element = ((ArrayList) collection).get(i);

                ObjectParamMetadata metadata = getMetadata(element.getClass());

                for (Field field : metadata.objectFields) {
                    Object elementValue = field.get(element);

                    if (elementValue != null && elementValue != element) {
                        Param alias1 = field.getAnnotation(Param.class);
                        String elementName = alias1 != null ? alias1.value() : field.getName();

                        elementName = name + "[" + i + "]." + elementName;

                        ClassLoader classLoader1 = elementValue.getClass().getClassLoader();

                        if (classLoader1 == null) {

                            if (isCustomObjectCollection(elementValue)) {
                                // Recursive call
                                processNameAndValue(elementName, elementValue, fieldNameToValue);
                            } else {
                                fieldNameToValue.put(elementName, elementValue);
                            }
                        }

                    }
                }
            }
        } else {
            fieldNameToValue.put(name, value);
        }
    }

    private boolean isCustomObjectCollection(Object value) {
        return value instanceof Collection
                && !((Collection) value).isEmpty()
                && ((Collection) value).iterator().next().getClass().getClassLoader() != null;
    }

    private CustomNestedObjectQueryMapEncoder.ObjectParamMetadata getMetadata(Class<?> objectType) {
        CustomNestedObjectQueryMapEncoder.ObjectParamMetadata metadata = classToMetadata.get(objectType);
        if (metadata == null) {
            metadata = CustomNestedObjectQueryMapEncoder.ObjectParamMetadata.parseObjectType(objectType);
            classToMetadata.put(objectType, metadata);
        }
        return metadata;
    }

    private static class ObjectParamMetadata {

        private final List<Field> objectFields;

        private ObjectParamMetadata(List<Field> objectFields) {
            this.objectFields = Collections.unmodifiableList(objectFields);
        }

        private static CustomNestedObjectQueryMapEncoder.ObjectParamMetadata parseObjectType(Class<?> type) {
            List<Field> allFields = new ArrayList();

            for (Class currentClass = type; currentClass != null; currentClass =
                    currentClass.getSuperclass()) {
                Collections.addAll(allFields, currentClass.getDeclaredFields());
            }

            return new CustomNestedObjectQueryMapEncoder.ObjectParamMetadata(allFields.stream()
                    .filter(field -> !field.isSynthetic())
                    .peek(field -> field.setAccessible(true))
                    .collect(Collectors.toList()));
        }
    }

}
`

### NestedObjectFeignConfig 
`public class NestedObjectFeignConfig {

    @Bean
    @Scope("prototype")
    @ConditionalOnProperty(name = "feign.hystrix.enabled")
    public Feign.Builder feignHystrixBuilder() {
        HystrixFeign.Builder encoder = HystrixFeign.builder();
        encoder.queryMapEncoder(new CustomNestedObjectQueryMapEncoder());

        return encoder;
    }

}
public interface TestClient {

    @GetMapping("/test-center/tapTest/getTapRecordReqDTO")
    CommonResponse<QueryJourneyResponseDTO> queryJourney(@SpringQueryMap HelloDTO HelloDTO);
}
public class HelloDTO {
    private String name;

    private WorldDTO worldDTO;

    private List<String> list;

    private List<WorldDTO> worldDTOList;
}`
public class WorldDTO {
    private String world;

    private String live;

    private List<TestDTO> testDTOList;
}
public class TestDTO {

    private String testName;

}
OlgaMaciaszek commented 2 years ago

@duzhongyuan Would you like to provide a PR?

yulgutlin commented 2 years ago

Needed this in my project with spring data pageable support and i slightly changed @duzhongyuan 's and @OlgaMaciaszek 's solution: fixed other collection types support, fixed enums and enum collections support. In case if someone will need that:

import feign.Param;
import feign.codec.EncodeException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.cloud.openfeign.support.PageableSpringQueryMapEncoder;

import java.lang.reflect.Field;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static java.util.function.Predicate.not;

/**
 * CustomNestedObjectQueryMapEncoder
 */
public class CustomNestedObjectQueryMapEncoder extends PageableSpringQueryMapEncoder {

    private final Map<Class<?>, CustomNestedObjectQueryMapEncoder.ObjectParamMetadata> classToMetadata = new HashMap<>();

    @Override
    public Map<String, Object> encode(Object object) throws EncodeException {
        if (super.supports(object)) {
            return super.encode(object);
        }
        return encodeInternal(null, object, null);
    }

    private Map<String, Object> encodeInternal(String prefixName, Object object, Map<String, Object> fieldNameToValue) {
        if (null == fieldNameToValue) {
            fieldNameToValue = new HashMap<>();
        }

        try {
            CustomNestedObjectQueryMapEncoder.ObjectParamMetadata metadata = getMetadata(object.getClass());
            for (Field field : metadata.objectFields) {
                Object value = field.get(object);

                if (value != null && value != object) {
                    Param alias = field.getAnnotation(Param.class);
                    String name = alias != null ? alias.value() : field.getName();

                    if (StringUtils.isNotBlank(prefixName)) {
                        name = prefixName + "." + name;
                    }

                    Class<?> aClass = value.getClass();
                    ClassLoader classLoader = aClass.getClassLoader();

                    if (classLoader == null || aClass.isEnum()) {

                        processNameAndValue(name, value, fieldNameToValue);

                    } else {
                        // Recursive call
                        encodeInternal(name, value, fieldNameToValue);
                    }
                }
            }
            return fieldNameToValue;
        } catch (IllegalAccessException e) {
            throw new EncodeException("Failure encoding object into query map", e);
        }
    }

    private void processNameAndValue(String name, Object value, Map<String, Object> fieldNameToValue) throws IllegalAccessException {
        // Determines whether it is a custom object collection
        if (isCustomObjectCollection(value)) {
            Collection<?> collection = (Collection<?>) value;

            int i = 0;
            for (Object element : collection) {
                ObjectParamMetadata metadata = getMetadata(element.getClass());

                for (Field field : metadata.objectFields) {
                    Object elementValue = field.get(element);

                    if (elementValue != null && elementValue != element) {
                        Param alias1 = field.getAnnotation(Param.class);
                        String elementName = alias1 != null ? alias1.value() : field.getName();

                        elementName = name + "[" + i + "]." + elementName;

                        ClassLoader classLoader1 = elementValue.getClass().getClassLoader();

                        if (classLoader1 == null) {

                            if (isCustomObjectCollection(elementValue)) {
                                // Recursive call
                                processNameAndValue(elementName, elementValue, fieldNameToValue);
                            } else {
                                fieldNameToValue.put(elementName, elementValue);
                            }
                        }
                    }
                }
                i++;
            }
        } else {
            fieldNameToValue.put(name, value);
        }
    }

    private boolean isCustomObjectCollection(Object value) {
        return Optional.ofNullable(value)
                .filter(Collection.class::isInstance)
                .map(Collection.class::cast)
                .filter(not(Collection::isEmpty))
                .stream()
                .flatMap(collection -> (Stream<?>) collection.stream())
                .filter(not(Enum.class::isInstance))
                .map(Object::getClass)
                .map(Class::getClassLoader)
                .anyMatch(Objects::nonNull);
    }

    private CustomNestedObjectQueryMapEncoder.ObjectParamMetadata getMetadata(Class<?> objectType) {
        CustomNestedObjectQueryMapEncoder.ObjectParamMetadata metadata = classToMetadata.get(objectType);
        if (metadata == null) {
            metadata = CustomNestedObjectQueryMapEncoder.ObjectParamMetadata.parseObjectType(objectType);
            classToMetadata.put(objectType, metadata);
        }
        return metadata;
    }

    private static class ObjectParamMetadata {
        private final List<Field> objectFields;

        private ObjectParamMetadata(List<Field> objectFields) {
            this.objectFields = Collections.unmodifiableList(objectFields);
        }

        private static CustomNestedObjectQueryMapEncoder.ObjectParamMetadata parseObjectType(Class<?> type) {
            List<Field> allFields = new ArrayList<>();

            for (Class<?> currentClass = type; currentClass != null && !currentClass.isEnum(); currentClass =
                    currentClass.getSuperclass()) {
                Collections.addAll(allFields, currentClass.getDeclaredFields());
            }

            return new CustomNestedObjectQueryMapEncoder.ObjectParamMetadata(allFields.stream()
                    .filter(field -> !field.isSynthetic())
                    .peek(field -> field.setAccessible(true))
                    .collect(Collectors.toList()));
        }
    }
}

Usage:

    @Autowired(required = false)
    private SpringDataWebProperties springDataWebProperties;

    @Bean
    public QueryMapEncoder feignQueryMapEncoderPageable() {
        PageableSpringQueryMapEncoder queryMapEncoder = new CustomNestedObjectQueryMapEncoder();
        if (springDataWebProperties != null) {
            queryMapEncoder.setPageParameter(springDataWebProperties.getPageable().getPageParameter());
            queryMapEncoder.setSizeParameter(springDataWebProperties.getPageable().getSizeParameter());
            queryMapEncoder.setSortParameter(springDataWebProperties.getSort().getSortParameter());
        }
        return queryMapEncoder;
    }
Wuaner commented 9 months ago

Is this fixed in any followup releases?

OlgaMaciaszek commented 9 months ago

No, @Wuaner , it's an enhancement that is marked as "help wanted". That means we are not planning to work on it, but we are happy to review community PRs for it if they are submitted.