OpenFeign / feign-form

Open Feign form encoder
Apache License 2.0
290 stars 81 forks source link

an encoder support muliple pojos and multipleFile Array #67

Open jianguyuxing opened 5 years ago

jianguyuxing commented 5 years ago

when I refactor a project running on prouduct environment, I have to reserve some structual like it used to be. When I use feign, I found it not supported multiple Pojos likes below


// a consumer Feign Client example
@FeignClient(name= "spring-cloud-producer")
public interface HelloRemote3 {
    @RequestLine(value = "POST /hello3")
    public String hello3(
                         @Param(value = "name") String name,
                         @Param(value = "date") Date date,
                         @Param(value = "pojoA") PojoA pojoA,
                         @Param(value = "pojoBs") List<PojoB> pojoBs,
                         @Param(value = "pojoCMap") Map<String, PojoC> pojoCMap,
                         @Param(value = "file") MultipartFile file,
                         @Param(value = "files") MultipartFile[] files
                         );
}

but thanks Mr.pcan, I finally found A way to support this feature by rewrite Encoder with feignContact;

@see https://github.com/pcan/feign-client-test/blob/master/src/main/java/it/pcan/test/feign/client/FeignSpringFormEncoder.java

then , you can accept request in Producer SpringMvc Controller with @RequestPart ,like below

// producer Controller
@RestController
public class HelloController {
@RequestMapping(value = "/hello3")
    public String index3(
            @RequestPart(value = "name", required = false) String name,
            @RequestPart(value = "date", required = false) Date date,
            @RequestPart(value = "pojoA", required = false) PojoA pojoA,
            @RequestPart(value = "pojoBs", required = false) List<pojoB> pojoBs,
            @RequestPart(value = "pojoCMap", required = false) Map<String, pojoC> pojoCMap,
            @RequestPart(value = "file", required = false) MultipartFile file,
            @RequestPart(value = "files", required = false) MultipartFile[] files
            ) {
        String result = "hello3 producer enter success \n";
        result += " name: " + name;
        result += " \n ------------ " + date;
        result += " \n ------------" + JSONObject.toJSONString(pojoA);
        result += " \n ------------ " + pojoBs;
        result += " \n ------------ " + pojoCMap;
        return result;
    }
}

now, it works fine in my project. a complete demo @see https://github.com/jianguyuxing/feign-multiple-pojos could you support this feature in next version ?

xxlabaza commented 5 years ago

hm, I didn't get what do you mean exactly.

To enable support for many body objects in a single method call - you need to rewrite feign.Contract, not the feign.Encoder, otherwise you will get "IllegalStateException: Method has too many Body parameters". feign.Contract is responsible for parsing your client's interface.

If you would like to have a support of MultipartFile, MultipartFile[] and even List<MultipartFile> - you need to take a look at this, I already have it (btw, thanks for the reminder, I just fixed a little bug there)

xxlabaza commented 5 years ago

@jianguyuxing, maybe you another Mr.pcan's example for feign.Contract?

jianguyuxing commented 5 years ago

hm, I dont think we need to rewrite feign.Contract. I use it in my project likes below,

@Configuration
public class FeignConfiguration {
    @Bean
    public Contract feignContract() {
        return new Contract.Default();
    }

    @Bean
    public Encoder feignSpringFormEncoder() {
        return new FeignSpringFormEncoder();
    }
}

It works fine now.

this is a simple and complete demo written by Pierantonio Cangianiello, @see https://github.com/pcan/feign-client-test

jianguyuxing commented 5 years ago

In fact , I rewrite nothing when Mr.Pcan's encoder is complete now likes below

import feign.RequestTemplate;
import feign.codec.EncodeException;
import feign.codec.Encoder;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.Type;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;

/**
 * A custom {@link feign.codec.Encoder} that supports Multipart requests. It uses
 * {@link HttpMessageConverter}s like {@link RestTemplate} does.
 *
 * @author Pierantonio Cangianiello
 */
public class FeignSpringFormEncoder implements Encoder {

    private final List<HttpMessageConverter<?>> converters = new RestTemplate().getMessageConverters();

    public static final Charset UTF_8 = Charset.forName("UTF-8");

    public FeignSpringFormEncoder() {
    }

    /**
     * {@inheritDoc }
     */
    @Override
    public void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException {
        if (isFormRequest(bodyType)) {
            final HttpHeaders multipartHeaders = new HttpHeaders();
            multipartHeaders.setContentType(MediaType.MULTIPART_FORM_DATA);
            encodeMultipartFormRequest((Map<String, ?>) object, multipartHeaders, template);
        } else {
            final HttpHeaders jsonHeaders = new HttpHeaders();
            jsonHeaders.setContentType(MediaType.APPLICATION_JSON);
            encodeRequest(object, jsonHeaders, template);
        }
    }

    /**
     * Encodes the request as a multipart form. It can detect a single {@link MultipartFile}, an
     * array of {@link MultipartFile}s, or POJOs (that are converted to JSON).
     *
     * @param formMap
     * @param template
     * @throws EncodeException
     */
    private void encodeMultipartFormRequest(Map<String, ?> formMap, HttpHeaders multipartHeaders, RequestTemplate template) throws EncodeException {
        if (formMap == null) {
            throw new EncodeException("Cannot encode request with null form.");
        }
        LinkedMultiValueMap<String, Object> map = new LinkedMultiValueMap<>();
        for (Entry<String, ?> entry : formMap.entrySet()) {
            Object value = entry.getValue();
            if (isMultipartFile(value)) {
                map.add(entry.getKey(), encodeMultipartFile((MultipartFile) value));
            } else if (isMultipartFileArray(value)) {
                encodeMultipartFiles(map, entry.getKey(), Arrays.asList((MultipartFile[]) value));
            } else {
                map.add(entry.getKey(), encodeJsonObject(value));
            }
        }
        encodeRequest(map, multipartHeaders, template);
    }

    private boolean isMultipartFile(Object object) {
        return object instanceof MultipartFile;
    }

    private boolean isMultipartFileArray(Object o) {
        return o != null && o.getClass().isArray() && MultipartFile.class.isAssignableFrom(o.getClass().getComponentType());
    }

    /**
     * Wraps a single {@link MultipartFile} into a {@link HttpEntity} and sets the
     * {@code Content-type} header to {@code application/octet-stream}
     *
     * @param file
     * @return
     */
    private HttpEntity<?> encodeMultipartFile(MultipartFile file) {
        HttpHeaders filePartHeaders = new HttpHeaders();
        filePartHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM);
        try {
            Resource multipartFileResource = new MultipartFileResource(file.getOriginalFilename(), file.getSize(), file.getInputStream());
            return new HttpEntity<>(multipartFileResource, filePartHeaders);
        } catch (IOException ex) {
            throw new EncodeException("Cannot encode request.", ex);
        }
    }

    /**
     * Fills the request map with {@link HttpEntity}s containing the given {@link MultipartFile}s.
     * Sets the {@code Content-type} header to {@code application/octet-stream} for each file.
     *
     * @param the current request map.
     * @param name the name of the array field in the multipart form.
     * @param files
     */
    private void encodeMultipartFiles(LinkedMultiValueMap<String, Object> map, String name, List<? extends MultipartFile> files) {
        HttpHeaders filePartHeaders = new HttpHeaders();
        filePartHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM);
        try {
            for (MultipartFile file : files) {
                Resource multipartFileResource = new MultipartFileResource(file.getOriginalFilename(), file.getSize(), file.getInputStream());
                map.add(name, new HttpEntity<>(multipartFileResource, filePartHeaders));
            }
        } catch (IOException ex) {
            throw new EncodeException("Cannot encode request.", ex);
        }
    }

    /**
     * Wraps an object into a {@link HttpEntity} and sets the {@code Content-type} header to
     * {@code application/json}
     *
     * @param o
     * @return
     */
    private HttpEntity<?> encodeJsonObject(Object o) {
        HttpHeaders jsonPartHeaders = new HttpHeaders();
        jsonPartHeaders.setContentType(MediaType.APPLICATION_JSON);
        return new HttpEntity<>(o, jsonPartHeaders);
    }

    /**
     * Calls the conversion chain actually used by
     * {@link org.springframework.web.client.RestTemplate}, filling the body of the request
     * template.
     *
     * @param value
     * @param requestHeaders
     * @param template
     * @throws EncodeException
     */
    private void encodeRequest(Object value, HttpHeaders requestHeaders, RequestTemplate template) throws EncodeException {
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        HttpOutputMessage dummyRequest = new HttpOutputMessageImpl(outputStream, requestHeaders);
        try {
            Class<?> requestType = value.getClass();
            MediaType requestContentType = requestHeaders.getContentType();
            for (HttpMessageConverter<?> messageConverter : converters) {
                if (messageConverter.canWrite(requestType, requestContentType)) {
                    ((HttpMessageConverter<Object>) messageConverter).write(
                            value, requestContentType, dummyRequest);
                    break;
                }
            }
        } catch (IOException ex) {
            throw new EncodeException("Cannot encode request.", ex);
        }
        HttpHeaders headers = dummyRequest.getHeaders();
        if (headers != null) {
            for (Entry<String, List<String>> entry : headers.entrySet()) {
                template.header(entry.getKey(), entry.getValue());
            }
        }
        /*
        we should use a template output stream... this will cause issues if files are too big, 
        since the whole request will be in memory.
         */
        template.body(outputStream.toByteArray(), UTF_8);
    }

    /**
     * Minimal implementation of {@link org.springframework.http.HttpOutputMessage}. It's needed to
     * provide the request body output stream to
     * {@link org.springframework.http.converter.HttpMessageConverter}s
     */
    private class HttpOutputMessageImpl implements HttpOutputMessage {

        private final OutputStream body;
        private final HttpHeaders headers;

        public HttpOutputMessageImpl(OutputStream body, HttpHeaders headers) {
            this.body = body;
            this.headers = headers;
        }

        @Override
        public OutputStream getBody() throws IOException {
            return body;
        }

        @Override
        public HttpHeaders getHeaders() {
            return headers;
        }

    }

    /**
     * Heuristic check for multipart requests.
     *
     * @param type
     * @return
     * @see feign.Types#MAP_STRING_WILDCARD
     */
    static boolean isFormRequest(Type type) {
        return MAP_STRING_WILDCARD.equals(type);
    }

    /**
     * Dummy resource class. Wraps file content and its original name.
     */
    static class MultipartFileResource extends InputStreamResource {

        private final String filename;
        private final long size;

        public MultipartFileResource(String filename, long size, InputStream inputStream) {
            super(inputStream);
            this.size = size;
            this.filename = filename;
        }

        @Override
        public String getFilename() {
            return this.filename;
        }

        @Override
        public InputStream getInputStream() throws IOException, IllegalStateException {
            return super.getInputStream(); //To change body of generated methods, choose Tools | Templates.
        }

        @Override
        public long contentLength() throws IOException {
            return size;
        }

    }

}
JokerSun commented 5 years ago

@jianguyuxing I have same question about that , but I haven't found a good way, it looks like we can only rewrite the contract , do you have any better solution?

https://github.com/OpenFeign/feign-form/issues/68#issuecomment-479816726

jianguyuxing commented 5 years ago

@jianguyuxing I have same question about that , but I haven't found a good way, it looks like we can only rewrite the contract , do you have any better solution?

#68 (comment)

@JokerSun no,you needn't rewrite contract but encoder. And you should use feignContract instead of springMvcContract. (feign use SpringMvcContract by default when we do not specify it)

it's a complete demo @see https://github.com/jianguyuxing/feign-multiple-pojos