diff --git a/.mvn/wrapper/MavenWrapperDownloader.java b/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100755 index 000000000..776860764 --- /dev/null +++ b/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + + import java.io.IOException; + import java.io.InputStream; + import java.net.Authenticator; + import java.net.PasswordAuthentication; + import java.net.URL; + import java.nio.file.Files; + import java.nio.file.Path; + import java.nio.file.Paths; + import java.nio.file.StandardCopyOption; + + public final class MavenWrapperDownloader + { + private static final String WRAPPER_VERSION = "@@project.version@@"; + + private static final boolean VERBOSE = Boolean.parseBoolean( System.getenv( "MVNW_VERBOSE" ) ); + + public static void main( String[] args ) + { + log( "Apache Maven Wrapper Downloader " + WRAPPER_VERSION ); + + if ( args.length != 2 ) + { + System.err.println( " - ERROR wrapperUrl or wrapperJarPath parameter missing" ); + System.exit( 1 ); + } + + try + { + log( " - Downloader started" ); + final URL wrapperUrl = new URL( args[0] ); + final String jarPath = args[1].replace( "..", "" ); // Sanitize path + final Path wrapperJarPath = Paths.get( jarPath ).toAbsolutePath().normalize(); + downloadFileFromURL( wrapperUrl, wrapperJarPath ); + log( "Done" ); + } + catch ( IOException e ) + { + System.err.println( "- Error downloading: " + e.getMessage() ); + if ( VERBOSE ) + { + e.printStackTrace(); + } + System.exit( 1 ); + } + } + + private static void downloadFileFromURL( URL wrapperUrl, Path wrapperJarPath ) + throws IOException + { + log( " - Downloading to: " + wrapperJarPath ); + if ( System.getenv( "MVNW_USERNAME" ) != null && System.getenv( "MVNW_PASSWORD" ) != null ) + { + final String username = System.getenv( "MVNW_USERNAME" ); + final char[] password = System.getenv( "MVNW_PASSWORD" ).toCharArray(); + Authenticator.setDefault( new Authenticator() + { + @Override + protected PasswordAuthentication getPasswordAuthentication() + { + return new PasswordAuthentication( username, password ); + } + } ); + } + try ( InputStream inStream = wrapperUrl.openStream() ) + { + Files.copy( inStream, wrapperJarPath, StandardCopyOption.REPLACE_EXISTING ); + } + log( " - Downloader complete" ); + } + + private static void log( String msg ) + { + if ( VERBOSE ) + { + System.out.println( msg ); + } + } + + } diff --git a/README.md b/README.md index 4ef004d6c..a8c8d54d1 100644 --- a/README.md +++ b/README.md @@ -1269,3 +1269,261 @@ The Bill Of Material is a special POM file that groups dependency versions that ``` +# Form Encoder + +[![build_status](https://travis-ci.org/OpenFeign/feign-form.svg?branch=master)](https://travis-ci.org/OpenFeign/feign-form) +[![maven_central](https://maven-badges.herokuapp.com/maven-central/io.github.openfeign.form/feign-form/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.github.openfeign.form/feign-form) +[![License](http://img.shields.io/:license-apache-brightgreen.svg)](http://www.apache.org/licenses/LICENSE-2.0.html) + +This module adds support for encoding **application/x-www-form-urlencoded** and **multipart/form-data** forms. + +## Add dependency + +Include the dependency to your app: + +**Maven**: + +```xml + + ... + + io.github.openfeign.form + feign-form + 4.0.0 + + ... + +``` + +**Gradle**: + +```groovy +compile 'io.github.openfeign.form:feign-form:4.0.0' +``` + +## Requirements + +The `feign-form` extension depend on `OpenFeign` and its *concrete* versions: + +- all `feign-form` releases before **3.5.0** works with `OpenFeign` **9.\*** versions; +- starting from `feign-form`'s version **3.5.0**, the module works with `OpenFeign` **10.1.0** versions and greater. + +> **IMPORTANT:** there is no backward compatibility and no any gurantee that the `feign-form`'s versions after **3.5.0** work with `OpenFeign` before **10.\***. `OpenFeign` was refactored in 10th release, so the best approach - use the freshest `OpenFeign` and `feign-form` versions. + +Notes: + +- [spring-cloud-openfeign](https://github.com/spring-cloud/spring-cloud-openfeign) uses `OpenFeign` **9.\*** till **v2.0.3.RELEASE** and uses **10.\*** after. Anyway, the dependency already has suitable `feign-form` version, see [dependency pom](https://github.com/spring-cloud/spring-cloud-openfeign/blob/master/spring-cloud-openfeign-dependencies/pom.xml#L19), so you don't need to specify it separately; + +- `spring-cloud-starter-feign` is a **deprecated** dependency and it always uses the `OpenFeign`'s **9.\*** versions. + +## Usage + +Add `FormEncoder` to your `Feign.Builder` like so: + +```java +SomeApi github = Feign.builder() + .encoder(new FormEncoder()) + .target(SomeApi.class, "http://api.some.org"); +``` + +Moreover, you can decorate the existing encoder, for example JsonEncoder like this: + +```java +SomeApi github = Feign.builder() + .encoder(new FormEncoder(new JacksonEncoder())) + .target(SomeApi.class, "http://api.some.org"); +``` + +And use them together: + +```java +interface SomeApi { + + @RequestLine("POST /json") + @Headers("Content-Type: application/json") + void json (Dto dto); + + @RequestLine("POST /form") + @Headers("Content-Type: application/x-www-form-urlencoded") + void from (@Param("field1") String field1, @Param("field2") String[] values); +} +``` + +You can specify two types of encoding forms by `Content-Type` header. + +### application/x-www-form-urlencoded + +```java +interface SomeApi { + + @RequestLine("POST /authorization") + @Headers("Content-Type: application/x-www-form-urlencoded") + void authorization (@Param("email") String email, @Param("password") String password); + + // Group all parameters within a POJO + @RequestLine("POST /user") + @Headers("Content-Type: application/x-www-form-urlencoded") + void addUser (User user); + + class User { + + Integer id; + + String name; + } +} +``` + +### multipart/form-data + +```java +interface SomeApi { + + // File parameter + @RequestLine("POST /send_photo") + @Headers("Content-Type: multipart/form-data") + void sendPhoto (@Param("is_public") Boolean isPublic, @Param("photo") File photo); + + // byte[] parameter + @RequestLine("POST /send_photo") + @Headers("Content-Type: multipart/form-data") + void sendPhoto (@Param("is_public") Boolean isPublic, @Param("photo") byte[] photo); + + // FormData parameter + @RequestLine("POST /send_photo") + @Headers("Content-Type: multipart/form-data") + void sendPhoto (@Param("is_public") Boolean isPublic, @Param("photo") FormData photo); + + // Group all parameters within a POJO + @RequestLine("POST /send_photo") + @Headers("Content-Type: multipart/form-data") + void sendPhoto (MyPojo pojo); + + class MyPojo { + + @FormProperty("is_public") + Boolean isPublic; + + File photo; + } +} +``` + +In the example above, the `sendPhoto` method uses the `photo` parameter using three different supported types. + +* `File` will use the File's extension to detect the `Content-Type`; +* `byte[]` will use `application/octet-stream` as `Content-Type`; +* `FormData` will use the `FormData`'s `Content-Type` and `fileName`; +* Client's custom POJO for grouping parameters (including types above). + +`FormData` is custom object that wraps a `byte[]` and defines a `Content-Type` and `fileName` like this: + +```java + FormData formData = new FormData("image/png", "filename.png", myDataAsByteArray); + someApi.sendPhoto(true, formData); +``` + +### Spring MultipartFile and Spring Cloud Netflix @FeignClient support + +You can also use Form Encoder with Spring `MultipartFile` and `@FeignClient`. + +Include the dependencies to your project's pom.xml file: + +```xml + + + io.github.openfeign.form + feign-form + 4.0.0 + + + io.github.openfeign.form + feign-form-spring + 4.0.0 + + +``` + +```java +@FeignClient( + name = "file-upload-service", + configuration = FileUploadServiceClient.MultipartSupportConfig.class +) +public interface FileUploadServiceClient extends IFileUploadServiceClient { + + public class MultipartSupportConfig { + + @Autowired + private ObjectFactory messageConverters; + + @Bean + public Encoder feignFormEncoder () { + return new SpringFormEncoder(new SpringEncoder(messageConverters)); + } + } +} +``` + +Or, if you don't need Spring's standard encoder: + +```java +@FeignClient( + name = "file-upload-service", + configuration = FileUploadServiceClient.MultipartSupportConfig.class +) +public interface FileUploadServiceClient extends IFileUploadServiceClient { + + public class MultipartSupportConfig { + + @Bean + public Encoder feignFormEncoder () { + return new SpringFormEncoder(); + } + } +} +``` + +Thanks to [tf-haotri-pham](https://github.com/tf-haotri-pham) for his feature, which makes use of Apache commons-fileupload library, which handles the parsing of the multipart response. The body data parts are held as byte arrays in memory. + +To use this feature, include SpringManyMultipartFilesReader in the list of message converters for the Decoder and have the Feign client return an array of MultipartFile: + +```java +@FeignClient( + name = "${feign.name}", + url = "${feign.url}" + configuration = DownloadClient.ClientConfiguration.class +) +public interface DownloadClient { + + @RequestMapping("/multipart/download/{fileId}") + MultipartFile[] download(@PathVariable("fileId") String fileId); + + class ClientConfiguration { + + @Autowired + private ObjectFactory messageConverters; + + @Bean + public Decoder feignDecoder () { + List> springConverters = + messageConverters.getObject().getConverters(); + + List> decoderConverters = + new ArrayList>(springConverters.size() + 1); + + decoderConverters.addAll(springConverters); + decoderConverters.add(new SpringManyMultipartFilesReader(4096)); + + HttpMessageConverters httpMessageConverters = new HttpMessageConverters(decoderConverters); + + return new SpringDecoder(new ObjectFactory() { + + @Override + public HttpMessageConverters getObject() { + return httpMessageConverters; + } + }); + } + } +} +``` diff --git a/feign-form-spring/pom.xml b/feign-form-spring/pom.xml new file mode 100644 index 000000000..ca63ced4a --- /dev/null +++ b/feign-form-spring/pom.xml @@ -0,0 +1,97 @@ + + + + + + + 4.0.0 + + feign-form-spring + + + io.github.openfeign + parent + 13.5-SNAPSHOT + + + Open Feign Forms Extension for Spring + + 17 + + + + + ${project.groupId} + feign-form + ${project.version} + compile + + + + org.springframework + spring-web + 5.3.31 + compile + + + + commons-fileupload + commons-fileupload + 1.5 + compile + + + + org.springframework.boot + spring-boot-starter-web + 2.7.18 + test + + + org.springframework.cloud + spring-cloud-starter-openfeign + 3.1.9 + test + + + ${project.groupId} + feign-form-spring + + + io.github.openfeign.form + feign-form-spring + + + + + + + + + org.apache.felix + maven-bundle-plugin + + + feign.form.spring + + + + + + diff --git a/feign-form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java b/feign-form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java new file mode 100644 index 000000000..16a081f8e --- /dev/null +++ b/feign-form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java @@ -0,0 +1,97 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.spring; + +import static feign.form.ContentType.MULTIPART; +import static java.util.Collections.singletonMap; + +import java.lang.reflect.Type; +import java.util.HashMap; + +import lombok.val; +import org.springframework.web.multipart.MultipartFile; + +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.form.FormEncoder; +import feign.form.MultipartFormContentProcessor; + +/** + * Adds support for {@link MultipartFile} type to {@link FormEncoder}. + * + * @since 14.09.2016 + * @author Tomasz Juchniewicz <tjuchniewicz@gmail.com> + */ +public class SpringFormEncoder extends FormEncoder { + + /** + * Constructor with the default Feign's encoder as a delegate. + */ + public SpringFormEncoder() { + this(new Encoder.Default()); + } + + /** + * Constructor with specified delegate encoder. + * + * @param delegate + * delegate encoder, if this encoder couldn't encode object. + */ + public SpringFormEncoder(Encoder delegate) { + super(delegate); + + val processor = (MultipartFormContentProcessor) getContentProcessor(MULTIPART); + processor.addFirstWriter(new SpringSingleMultipartFileWriter()); + processor.addFirstWriter(new SpringManyMultipartFilesWriter()); + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException { + if (bodyType.equals(MultipartFile[].class)) { + val files = (MultipartFile[]) object; + val data = new HashMap(files.length, 1.F); + for (val file : files) { + data.put(file.getName(), file); + } + super.encode(data, MAP_STRING_WILDCARD, template); + } else if (bodyType.equals(MultipartFile.class)) { + val file = (MultipartFile) object; + val data = singletonMap(file.getName(), object); + super.encode(data, MAP_STRING_WILDCARD, template); + } else if (isMultipartFileCollection(object)) { + val iterable = (Iterable) object; + val data = new HashMap(); + for (val item : iterable) { + val file = (MultipartFile) item; + data.put(file.getName(), file); + } + super.encode(data, MAP_STRING_WILDCARD, template); + } else { + super.encode(object, bodyType, template); + } + } + + private boolean isMultipartFileCollection(Object object) { + if (!(object instanceof Iterable)) { + return false; + } + val iterable = (Iterable) object; + val iterator = iterable.iterator(); + return iterator.hasNext() && iterator.next() instanceof MultipartFile; + } +} diff --git a/feign-form-spring/src/main/java/feign/form/spring/SpringManyMultipartFilesWriter.java b/feign-form-spring/src/main/java/feign/form/spring/SpringManyMultipartFilesWriter.java new file mode 100644 index 000000000..bd25b1f3b --- /dev/null +++ b/feign-form-spring/src/main/java/feign/form/spring/SpringManyMultipartFilesWriter.java @@ -0,0 +1,68 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.spring; + +import static lombok.AccessLevel.PRIVATE; + +import lombok.experimental.FieldDefaults; +import lombok.val; +import org.springframework.web.multipart.MultipartFile; + +import feign.codec.EncodeException; +import feign.form.multipart.AbstractWriter; +import feign.form.multipart.Output; + +/** + * A Spring multiple files writer. + * + * @author Artem Labazin + */ +@FieldDefaults(level = PRIVATE, makeFinal = true) +public class SpringManyMultipartFilesWriter extends AbstractWriter { + + SpringSingleMultipartFileWriter fileWriter = new SpringSingleMultipartFileWriter(); + + @Override + public boolean isApplicable(Object value) { + if (value instanceof MultipartFile[]) { + return true; + } + if (!(value instanceof Iterable)) { + return false; + } + val iterable = (Iterable) value; + val iterator = iterable.iterator(); + return iterator.hasNext() && iterator.next() instanceof MultipartFile; + } + + @Override + public void write(Output output, String boundary, String key, Object value) throws EncodeException { + if (value instanceof MultipartFile[]) { + val files = (MultipartFile[]) value; + for (val file : files) { + fileWriter.write(output, boundary, key, file); + } + } else if (value instanceof Iterable) { + val iterable = (Iterable) value; + for (val file : iterable) { + fileWriter.write(output, boundary, key, file); + } + } else { + throw new IllegalArgumentException(); + } + } +} diff --git a/feign-form-spring/src/main/java/feign/form/spring/SpringSingleMultipartFileWriter.java b/feign-form-spring/src/main/java/feign/form/spring/SpringSingleMultipartFileWriter.java new file mode 100644 index 000000000..db4b31c27 --- /dev/null +++ b/feign-form-spring/src/main/java/feign/form/spring/SpringSingleMultipartFileWriter.java @@ -0,0 +1,53 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.spring; + +import java.io.IOException; + +import lombok.val; +import org.springframework.web.multipart.MultipartFile; + +import feign.codec.EncodeException; +import feign.form.multipart.AbstractWriter; +import feign.form.multipart.Output; + +/** + * A Spring single file writer. + * + * @author Artem Labazin + */ +public class SpringSingleMultipartFileWriter extends AbstractWriter { + + @Override + public boolean isApplicable(Object value) { + return value instanceof MultipartFile; + } + + @Override + protected void write(Output output, String key, Object value) throws EncodeException { + val file = (MultipartFile) value; + writeFileMetadata(output, key, file.getOriginalFilename(), file.getContentType()); + + byte[] bytes; + try { + bytes = file.getBytes(); + } catch (IOException ex) { + throw new EncodeException("Getting multipart file's content bytes error", ex); + } + output.write(bytes); + } +} diff --git a/feign-form-spring/src/main/java/feign/form/spring/converter/ByteArrayMultipartFile.java b/feign-form-spring/src/main/java/feign/form/spring/converter/ByteArrayMultipartFile.java new file mode 100644 index 000000000..599d89fa5 --- /dev/null +++ b/feign-form-spring/src/main/java/feign/form/spring/converter/ByteArrayMultipartFile.java @@ -0,0 +1,67 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.spring.converter; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import lombok.NonNull; +import lombok.Value; +import lombok.val; +import org.springframework.web.multipart.MultipartFile; + +/** + * Straight-forward implementation of interface {@link MultipartFile} where the + * file data is held as a byte array in memory. + */ +@Value +class ByteArrayMultipartFile implements MultipartFile { + + String name; + + String originalFilename; + + String contentType; + + @NonNull + byte[] bytes; + + @Override + public boolean isEmpty() { + return bytes.length == 0; + } + + @Override + public long getSize() { + return bytes.length; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(bytes); + } + + @Override + public void transferTo(File destination) throws IOException { + try (val outputStream = new FileOutputStream(destination)) { + outputStream.write(bytes); + } + } +} diff --git a/feign-form-spring/src/main/java/feign/form/spring/converter/IgnoreKeyCaseMap.java b/feign-form-spring/src/main/java/feign/form/spring/converter/IgnoreKeyCaseMap.java new file mode 100644 index 000000000..7ea31d97d --- /dev/null +++ b/feign-form-spring/src/main/java/feign/form/spring/converter/IgnoreKeyCaseMap.java @@ -0,0 +1,53 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.spring.converter; + +import java.util.HashMap; +import java.util.Locale; + +/** + * A Map implementation that normalizes the key to UPPER CASE, so that value + * retrieval via the key is case insensitive. + */ +final class IgnoreKeyCaseMap extends HashMap { + + private static final long serialVersionUID = -2321516556941546746L; + + private static String normalizeKey(Object key) { + return key == null ? null : key.toString().toUpperCase(new Locale("en_US")); + } + + @Override + public boolean containsKey(Object key) { + return super.containsKey(normalizeKey(key)); + } + + @Override + public String get(Object key) { + return super.get(normalizeKey(key)); + } + + @Override + public String put(String key, String value) { + return super.put(normalizeKey(key), value); + } + + @Override + public String remove(Object key) { + return super.remove(normalizeKey(key)); + } +} diff --git a/feign-form-spring/src/main/java/feign/form/spring/converter/SpringManyMultipartFilesReader.java b/feign-form-spring/src/main/java/feign/form/spring/converter/SpringManyMultipartFilesReader.java new file mode 100644 index 000000000..2e649a8cc --- /dev/null +++ b/feign-form-spring/src/main/java/feign/form/spring/converter/SpringManyMultipartFilesReader.java @@ -0,0 +1,176 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.spring.converter; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static lombok.AccessLevel.PRIVATE; +import static org.springframework.http.HttpHeaders.CONTENT_DISPOSITION; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static org.springframework.http.MediaType.MULTIPART_FORM_DATA; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.LinkedList; +import java.util.Map; +import java.util.regex.Pattern; + +import lombok.experimental.FieldDefaults; +import lombok.val; +import org.apache.commons.fileupload.MultipartStream; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.AbstractHttpMessageConverter; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConversionException; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +/** + * Implementation of {@link HttpMessageConverter} that can read + * multipart/form-data HTTP bodies (writing is not handled because that is + * already supported by {@link FormHttpMessageConverter}). + *

+ * This reader supports an array of {@link MultipartFile} as the mapping return + * class type - each multipart body is read into an underlying byte array (in + * memory) implemented via {@link ByteArrayMultipartFile}. + */ +@FieldDefaults(level = PRIVATE, makeFinal = true) +public class SpringManyMultipartFilesReader extends AbstractHttpMessageConverter { + + private static final Pattern NEWLINES_PATTERN = Pattern.compile("\\R"); + + private static final Pattern COLON_PATTERN = Pattern.compile(":"); + + private static final Pattern SEMICOLON_PATTERN = Pattern.compile(";"); + + private static final Pattern EQUALITY_SIGN_PATTERN = Pattern.compile("="); + + int bufSize; + + /** + * Construct an {@code AbstractHttpMessageConverter} that can read + * mulitpart/form-data. + * + * @param bufSize + * The size of the buffer (in bytes) to read the HTTP multipart body. + */ + public SpringManyMultipartFilesReader(int bufSize) { + super(MULTIPART_FORM_DATA); + this.bufSize = bufSize; + } + + @Override + protected boolean canWrite(MediaType mediaType) { + return false; // Class NOT meant for writing multipart/form-data HTTP bodies + } + + @Override + protected boolean supports(Class clazz) { + return MultipartFile[].class == clazz; + } + + @Override + protected MultipartFile[] readInternal(Class clazz, HttpInputMessage inputMessage) + throws IOException { + val headers = inputMessage.getHeaders(); + if (headers == null) { + throw new HttpMessageNotReadableException("There are no headers at all.", inputMessage); + } + + MediaType contentType = headers.getContentType(); + if (contentType == null) { + throw new HttpMessageNotReadableException("Content-Type is missing.", inputMessage); + } + + val boundaryBytes = getMultiPartBoundary(contentType); + MultipartStream multipartStream = new MultipartStream(inputMessage.getBody(), boundaryBytes, bufSize, null); + + val multiparts = new LinkedList(); + for (boolean nextPart = multipartStream.skipPreamble(); nextPart; nextPart = multipartStream.readBoundary()) { + ByteArrayMultipartFile multiPart; + try { + multiPart = readMultiPart(multipartStream); + } catch (Exception e) { + throw new HttpMessageNotReadableException("Multipart body could not be read.", e, inputMessage); + } + multiparts.add(multiPart); + } + return multiparts.toArray(new ByteArrayMultipartFile[0]); + } + + @Override + protected void writeInternal(MultipartFile[] byteArrayMultipartFiles, HttpOutputMessage outputMessage) { + throw new UnsupportedOperationException(getClass().getSimpleName() + " does not support writing to HTTP body."); + } + + private byte[] getMultiPartBoundary(MediaType contentType) { + val boundaryString = unquote(contentType.getParameter("boundary")); + if (StringUtils.hasLength(boundaryString) == false) { + throw new HttpMessageConversionException("Content-Type missing boundary information."); + } + return boundaryString.getBytes(UTF_8); + } + + private ByteArrayMultipartFile readMultiPart(MultipartStream multipartStream) throws IOException { + val multiPartHeaders = splitIntoKeyValuePairs(multipartStream.readHeaders(), NEWLINES_PATTERN, COLON_PATTERN, + false); + + val contentDisposition = splitIntoKeyValuePairs(multiPartHeaders.get(CONTENT_DISPOSITION), SEMICOLON_PATTERN, + EQUALITY_SIGN_PATTERN, true); + + if (!contentDisposition.containsKey("form-data")) { + throw new HttpMessageConversionException("Content-Disposition is not of type form-data."); + } + + val bodyStream = new ByteArrayOutputStream(); + multipartStream.readBodyData(bodyStream); + return new ByteArrayMultipartFile(contentDisposition.get("name"), contentDisposition.get("filename"), + multiPartHeaders.get(CONTENT_TYPE), bodyStream.toByteArray()); + } + + private Map splitIntoKeyValuePairs(String str, Pattern entriesSeparatorPattern, + Pattern keyValueSeparatorPattern, boolean unquoteValue) { + val keyValuePairs = new IgnoreKeyCaseMap(); + if (StringUtils.hasLength(str)) { + val tokens = entriesSeparatorPattern.split(str); + for (val token : tokens) { + val pair = keyValueSeparatorPattern.split(token.trim(), 2); + val key = pair[0].trim(); + val value = pair.length > 1 ? pair[1].trim() : ""; + + keyValuePairs.put(key, unquoteValue ? unquote(value) : value); + } + } + return keyValuePairs; + } + + private String unquote(String value) { + if (value == null) { + return null; + } + return isSurroundedBy(value, "\"") || isSurroundedBy(value, "'") + ? value.substring(1, value.length() - 1) + : value; + } + + private boolean isSurroundedBy(String value, String preSuffix) { + return value.length() > 1 && value.startsWith(preSuffix) && value.endsWith(preSuffix); + } +} diff --git a/feign-form-spring/src/main/java/lombok.config b/feign-form-spring/src/main/java/lombok.config new file mode 100644 index 000000000..26f5d95a3 --- /dev/null +++ b/feign-form-spring/src/main/java/lombok.config @@ -0,0 +1 @@ +lombok.extern.findbugs.addSuppressFBWarnings=true diff --git a/feign-form-spring/src/test/java/feign/form/feign/spring/Client.java b/feign-form-spring/src/test/java/feign/form/feign/spring/Client.java new file mode 100644 index 000000000..b3e149aa3 --- /dev/null +++ b/feign-form-spring/src/test/java/feign/form/feign/spring/Client.java @@ -0,0 +1,87 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.feign.spring; + +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; +import static org.springframework.web.bind.annotation.RequestMethod.POST; + +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.cloud.openfeign.support.SpringEncoder; +import org.springframework.context.annotation.Bean; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import feign.Logger; +import feign.Response; +import feign.codec.Encoder; +import feign.form.spring.SpringFormEncoder; + +@FeignClient(name = "multipart-support-service", url = "http://localhost:8080", configuration = Client.ClientConfiguration.class) +interface Client { + + @RequestMapping(value = "/multipart/upload1/{folder}", method = POST, consumes = MULTIPART_FORM_DATA_VALUE) + String upload1(@PathVariable("folder") String folder, @RequestPart("file") MultipartFile file, + @RequestParam(name = "message", required = false) String message); + + @RequestMapping(value = "/multipart/upload2/{folder}", method = POST, consumes = MULTIPART_FORM_DATA_VALUE) + String upload2(@RequestBody MultipartFile file, @PathVariable("folder") String folder, + @RequestParam(name = "message", required = false) String message); + + @RequestMapping(value = "/multipart/upload3/{folder}", method = POST, consumes = MULTIPART_FORM_DATA_VALUE) + String upload3(@RequestBody MultipartFile file, @PathVariable("folder") String folder, + @RequestParam(name = "message", required = false) String message); + + @RequestMapping(path = "/multipart/upload4/{id}", method = POST, produces = APPLICATION_JSON_VALUE) + String upload4(@PathVariable("id") String id, @RequestBody Map map, + @RequestParam("userName") String userName); + + @RequestMapping(path = "/multipart/upload5", method = POST, consumes = MULTIPART_FORM_DATA_VALUE) + Response upload5(Dto dto); + + @RequestMapping(path = "/multipart/upload6", method = POST, consumes = MULTIPART_FORM_DATA_VALUE) + String upload6Array(MultipartFile[] files); + + @RequestMapping(path = "/multipart/upload6", method = POST, consumes = MULTIPART_FORM_DATA_VALUE) + String upload6Collection(List files); + + class ClientConfiguration { + + @Autowired + private ObjectFactory messageConverters; + + @Bean + Encoder feignEncoder() { + return new SpringFormEncoder(new SpringEncoder(messageConverters)); + } + + @Bean + Logger.Level feignLogger() { + return Logger.Level.FULL; + } + } +} diff --git a/feign-form-spring/src/test/java/feign/form/feign/spring/DownloadClient.java b/feign-form-spring/src/test/java/feign/form/feign/spring/DownloadClient.java new file mode 100644 index 000000000..da568f636 --- /dev/null +++ b/feign-form-spring/src/test/java/feign/form/feign/spring/DownloadClient.java @@ -0,0 +1,72 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.feign.spring; + +import java.util.ArrayList; + +import lombok.val; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.cloud.openfeign.support.SpringDecoder; +import org.springframework.context.annotation.Bean; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.multipart.MultipartFile; + +import feign.Logger; +import feign.codec.Decoder; +import feign.form.spring.converter.SpringManyMultipartFilesReader; + +@FeignClient(name = "multipart-download-support-service", url = "http://localhost:8081", configuration = DownloadClient.ClientConfiguration.class) +interface DownloadClient { + + @RequestMapping("/multipart/download/{fileId}") + MultipartFile[] download(@PathVariable("fileId") String fileId); + + class ClientConfiguration { + + @Autowired + private ObjectFactory messageConverters; + + @Bean + Decoder feignDecoder() { + val springConverters = messageConverters.getObject().getConverters(); + val decoderConverters = new ArrayList>(springConverters.size() + 1); + + decoderConverters.addAll(springConverters); + decoderConverters.add(new SpringManyMultipartFilesReader(4096)); + + val httpMessageConverters = new HttpMessageConverters(decoderConverters); + + return new SpringDecoder(new ObjectFactory() { + + @Override + public HttpMessageConverters getObject() { + return httpMessageConverters; + } + }); + } + + @Bean + Logger.Level feignLoggerLevel() { + return Logger.Level.FULL; + } + } +} diff --git a/feign-form-spring/src/test/java/feign/form/feign/spring/Dto.java b/feign-form-spring/src/test/java/feign/form/feign/spring/Dto.java new file mode 100644 index 000000000..4255efd6d --- /dev/null +++ b/feign-form-spring/src/test/java/feign/form/feign/spring/Dto.java @@ -0,0 +1,42 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.feign.spring; + +import static lombok.AccessLevel.PRIVATE; + +import java.io.Serializable; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldDefaults; +import org.springframework.web.multipart.MultipartFile; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@FieldDefaults(level = PRIVATE) +public class Dto implements Serializable { + + private static final long serialVersionUID = -4218390863359894943L; + + String field1; + + int field2; + + MultipartFile file; +} diff --git a/feign-form-spring/src/test/java/feign/form/feign/spring/Server.java b/feign-form-spring/src/test/java/feign/form/feign/spring/Server.java new file mode 100644 index 000000000..20cf36072 --- /dev/null +++ b/feign-form-spring/src/test/java/feign/form/feign/spring/Server.java @@ -0,0 +1,117 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.feign.spring; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.springframework.http.HttpStatus.I_AM_A_TEAPOT; +import static org.springframework.http.HttpStatus.OK; +import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM; +import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; + +import java.io.IOException; +import java.util.Map; + +import lombok.val; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@EnableFeignClients +@SpringBootApplication +@SuppressWarnings("checkstyle:DesignForExtension") +public class Server { + + @PostMapping(path = "/multipart/upload1/{folder}", consumes = MULTIPART_FORM_DATA_VALUE) + public String upload1(@PathVariable("folder") String folder, @RequestPart("file") MultipartFile file, + @RequestParam(value = "message", required = false) String message) throws IOException { + return new String(file.getBytes()) + ':' + message + ':' + folder; + } + + @PostMapping(path = "/multipart/upload2/{folder}", consumes = MULTIPART_FORM_DATA_VALUE) + public String upload2(@RequestBody MultipartFile file, @PathVariable("folder") String folder, + @RequestParam(value = "message", required = false) String message) throws IOException { + return new String(file.getBytes()) + ':' + message + ':' + folder; + } + + @PostMapping(path = "/multipart/upload3/{folder}", consumes = MULTIPART_FORM_DATA_VALUE) + public String upload3(@RequestBody MultipartFile file, @PathVariable("folder") String folder, + @RequestParam(value = "message", required = false) String message) { + return file.getOriginalFilename() + ':' + file.getContentType() + ':' + folder; + } + + @PostMapping("/multipart/upload4/{id}") + public String upload4(@PathVariable("id") String id, @RequestBody Map map, + @RequestParam String userName) { + return userName + ':' + id + ':' + map.size(); + } + + @PostMapping(path = "/multipart/upload5", consumes = MULTIPART_FORM_DATA_VALUE) + void upload5(Dto dto) throws IOException { + assert "field 1 value".equals(dto.getField1()); + assert 42 == dto.getField2(); + + assert "Hello world".equals(new String(dto.getFile().getBytes(), UTF_8)); + } + + @PostMapping(path = "/multipart/upload6", consumes = MULTIPART_FORM_DATA_VALUE) + public ResponseEntity upload6(@RequestParam("popa1") MultipartFile popa1, + @RequestParam("popa2") MultipartFile popa2) throws Exception { + HttpStatus status = I_AM_A_TEAPOT; + String result = ""; + if (popa1 != null && popa2 != null) { + status = OK; + result = new String(popa1.getBytes()) + new String(popa2.getBytes()); + } + return ResponseEntity.status(status).body(result); + } + + @GetMapping(path = "/multipart/download/{fileId}", produces = MULTIPART_FORM_DATA_VALUE) + public MultiValueMap download(@PathVariable("fileId") String fileId) { + val multiParts = new LinkedMultiValueMap(); + + val infoString = "The text for file ID " + fileId + ". Testing unicode €"; + val infoPartheader = new HttpHeaders(); + infoPartheader.setContentType(new MediaType("text", "plain", UTF_8)); + + val infoPart = new HttpEntity(infoString, infoPartheader); + + val file = new ClassPathResource("testfile.txt"); + val filePartheader = new HttpHeaders(); + filePartheader.setContentType(APPLICATION_OCTET_STREAM); + val filePart = new HttpEntity(file, filePartheader); + + multiParts.add("info", infoPart); + multiParts.add("file", filePart); + return multiParts; + } +} diff --git a/feign-form-spring/src/test/java/feign/form/feign/spring/SpringFormEncoderTest.java b/feign-form-spring/src/test/java/feign/form/feign/spring/SpringFormEncoderTest.java new file mode 100644 index 000000000..ca495418f --- /dev/null +++ b/feign-form-spring/src/test/java/feign/form/feign/spring/SpringFormEncoderTest.java @@ -0,0 +1,109 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.feign.spring; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; + +import java.util.HashMap; +import java.util.List; + +import lombok.val; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import feign.Response; + +@SpringBootTest(webEnvironment = DEFINED_PORT, classes = Server.class, properties = {"server.port=8080", + "feign.hystrix.enabled=false", "logging.level.feign.form.feign.spring.Client=DEBUG"}) +class SpringFormEncoderTest { + + @Autowired + private Client client; + + @Test + void upload1Test() throws Exception { + val folder = "test_folder"; + val file = new MockMultipartFile("file", "test".getBytes(UTF_8)); + val message = "message test"; + + assertThat(client.upload1(folder, file, message)) + .isEqualTo(new String(file.getBytes()) + ':' + message + ':' + folder); + } + + @Test + void upload2Test() throws Exception { + val folder = "test_folder"; + val file = new MockMultipartFile("file", "test".getBytes(UTF_8)); + val message = "message test"; + + assertThat(client.upload2(file, folder, message)) + .isEqualTo(new String(file.getBytes()) + ':' + message + ':' + folder); + } + + @Test + void uploadFileNameAndContentTypeTest() throws Exception { + val folder = "test_folder"; + val file = new MockMultipartFile("file", "hello.dat", "application/octet-stream", "test".getBytes(UTF_8)); + val message = "message test"; + + assertThat(client.upload3(file, folder, message)) + .isEqualTo(file.getOriginalFilename() + ':' + file.getContentType() + ':' + folder); + } + + @Test + void upload4Test() throws Exception { + val map = new HashMap(); + map.put("one", 1); + map.put("two", 2); + + val userName = "popa"; + val id = "42"; + + assertThat(client.upload4(id, map, userName)).isEqualTo(userName + ':' + id + ':' + map.size()); + } + + @Test + void upload5Test() throws Exception { + val file = new MockMultipartFile("popa.txt", "Hello world".getBytes(UTF_8)); + val dto = new Dto("field 1 value", 42, file); + + assertThat(client.upload5(dto)).isNotNull().extracting(Response::status).isEqualTo(200); + } + + @Test + void upload6ArrayTest() throws Exception { + val file1 = new MockMultipartFile("popa1", "popa1", null, "Hello".getBytes(UTF_8)); + val file2 = new MockMultipartFile("popa2", "popa2", null, " world".getBytes(UTF_8)); + + assertThat(client.upload6Array(new MultipartFile[]{file1, file2})).isEqualTo("Hello world"); + } + + @Test + void upload6CollectionTest() throws Exception { + List list = asList( + (MultipartFile) new MockMultipartFile("popa1", "popa1", null, "Hello".getBytes(UTF_8)), + (MultipartFile) new MockMultipartFile("popa2", "popa2", null, " world".getBytes(UTF_8))); + + assertThat(client.upload6Collection(list)).isEqualTo("Hello world"); + } +} diff --git a/feign-form-spring/src/test/java/feign/form/feign/spring/SpringMultipartDecoderTest.java b/feign-form-spring/src/test/java/feign/form/feign/spring/SpringMultipartDecoderTest.java new file mode 100644 index 000000000..9fa4e58b9 --- /dev/null +++ b/feign-form-spring/src/test/java/feign/form/feign/spring/SpringMultipartDecoderTest.java @@ -0,0 +1,54 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.feign.spring; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; + +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; + +@SpringBootTest(webEnvironment = DEFINED_PORT, classes = Server.class, properties = {"server.port=8081", + "feign.hystrix.enabled=false"}) +class SpringMultipartDecoderTest { + + @Autowired + private DownloadClient downloadClient; + + @Test + void downloadTest() throws Exception { + MultipartFile[] downloads = downloadClient.download("123"); + + assertThat(downloads.length).isEqualTo(2); + + assertThat(downloads[0].getName()).isEqualTo("info"); + + MediaType infoContentType = MediaType.parseMediaType(downloads[0].getContentType()); + assertThat(MediaType.TEXT_PLAIN.includes(infoContentType)).isTrue(); + assertThat(infoContentType.getCharset()).isNotNull(); + assertThat(IOUtils.toString(downloads[0].getInputStream(), infoContentType.getCharset().name())) + .isEqualTo("The text for file ID 123. Testing unicode €"); + + assertThat(downloads[1].getOriginalFilename()).isEqualTo("testfile.txt"); + assertThat(downloads[1].getContentType()).isEqualTo(MediaType.APPLICATION_OCTET_STREAM_VALUE); + assertThat(downloads[1].getSize()).isEqualTo(14); + } +} diff --git a/feign-form-spring/src/test/java/feign/form/feign/spring/converter/SpringManyMultipartFilesReaderTest.java b/feign-form-spring/src/test/java/feign/form/feign/spring/converter/SpringManyMultipartFilesReaderTest.java new file mode 100644 index 000000000..c54370994 --- /dev/null +++ b/feign-form-spring/src/test/java/feign/form/feign/spring/converter/SpringManyMultipartFilesReaderTest.java @@ -0,0 +1,79 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.feign.spring.converter; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +import lombok.val; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; + +import feign.form.spring.converter.SpringManyMultipartFilesReader; + +class SpringManyMultipartFilesReaderTest { + + private static final String DUMMY_MULTIPART_BOUNDARY = "Boundary_4_574237629_1500021738802"; + + @Test + void readMultipartFormDataTest() throws IOException { + val multipartFilesReader = new SpringManyMultipartFilesReader(4096); + val multipartFiles = multipartFilesReader.read(MultipartFile[].class, new ValidMultipartMessage()); + + assertThat(multipartFiles.length).isEqualTo(2); + + assertThat(multipartFiles[0].getContentType()).isEqualTo(MediaType.APPLICATION_JSON_VALUE); + assertThat(multipartFiles[0].getName()).isEqualTo("form-item-1"); + assertThat(multipartFiles[0].isEmpty()).isFalse(); + + assertThat(multipartFiles[1].getContentType()).isEqualTo(MediaType.TEXT_PLAIN_VALUE); + assertThat(multipartFiles[1].getOriginalFilename()).isEqualTo("form-item-2-file-1"); + assertThat(IOUtils.toString(multipartFiles[1].getInputStream(), "US-ASCII")).isEqualTo("Plain text"); + } + + static class ValidMultipartMessage implements HttpInputMessage { + + @Override + public InputStream getBody() throws IOException { + val multipartBody = "--" + DUMMY_MULTIPART_BOUNDARY + "\r\n" + "Content-Type: application/json\r\n" + + "Content-Disposition: form-data; name=\"form-item-1\"\r\n" + "\r\n" + "{\"id\":1}" + "\r\n" + "--" + + DUMMY_MULTIPART_BOUNDARY + "\r\n" + "content-type: text/plain\r\n" + + "content-disposition: Form-Data; Filename=\"form-item-2-file-1\"; Name=\"form-item-2\"\r\n" + + "\r\n" + "Plain text" + "\r\n" + "--" + DUMMY_MULTIPART_BOUNDARY + "--\r\n"; + + return new ByteArrayInputStream(multipartBody.getBytes("US-ASCII")); + } + + @Override + public HttpHeaders getHeaders() { + val httpHeaders = new HttpHeaders(); + httpHeaders.put(CONTENT_TYPE, + singletonList(MULTIPART_FORM_DATA_VALUE + "; boundary=" + DUMMY_MULTIPART_BOUNDARY)); + return httpHeaders; + } + } +} diff --git a/feign-form-spring/src/test/resources/testfile.txt b/feign-form-spring/src/test/resources/testfile.txt new file mode 100644 index 000000000..c8fc1be13 --- /dev/null +++ b/feign-form-spring/src/test/resources/testfile.txt @@ -0,0 +1 @@ +My test text. diff --git a/feign-form/pom.xml b/feign-form/pom.xml new file mode 100644 index 000000000..8c693f259 --- /dev/null +++ b/feign-form/pom.xml @@ -0,0 +1,62 @@ + + + + + + + 4.0.0 + + feign-form + + + io.github.openfeign + parent + 13.5-SNAPSHOT + + + Open Feign Forms Core + + + + + org.apache.felix + maven-bundle-plugin + + + feign.form + feign.form.multipart + + + + + + + + + org.projectlombok + lombok + 1.18.34 + provided + + + ${project.groupId} + feign-core + + + diff --git a/feign-form/src/main/java/feign/form/ContentProcessor.java b/feign-form/src/main/java/feign/form/ContentProcessor.java new file mode 100644 index 000000000..d3ffb1658 --- /dev/null +++ b/feign-form/src/main/java/feign/form/ContentProcessor.java @@ -0,0 +1,66 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form; + +import java.nio.charset.Charset; +import java.util.Map; + +import feign.RequestTemplate; +import feign.codec.EncodeException; + +/** + * Interface for content processors. + * + * @see MultipartFormContentProcessor + * @see UrlencodedFormContentProcessor + * + * @author Artem Labazin + */ +public interface ContentProcessor { + + /** + * A content type header name. + */ + String CONTENT_TYPE_HEADER = "Content-Type"; + + /** + * End line symbols. + */ + String CRLF = "\r\n"; + + /** + * Processes a request. + * + * @param template + * Feign's request template. + * @param charset + * request charset from 'Content-Type' header (UTF-8 by default). + * @param data + * reqeust data. + * + * @throws EncodeException + * in case of any encode exception + */ + void process(RequestTemplate template, Charset charset, Map data) throws EncodeException; + + /** + * Returns supported {@link ContentType} of this processor. + * + * @return supported content type enum value. + */ + ContentType getSupportedContentType(); +} diff --git a/feign-form/src/main/java/feign/form/ContentType.java b/feign-form/src/main/java/feign/form/ContentType.java new file mode 100644 index 000000000..c1a5875e2 --- /dev/null +++ b/feign-form/src/main/java/feign/form/ContentType.java @@ -0,0 +1,75 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form; + +import static lombok.AccessLevel.PRIVATE; + +import lombok.Getter; +import lombok.experimental.FieldDefaults; +import lombok.val; + +/** + * Supported content types. + * + * @author Artem Labazin + */ +@Getter +@FieldDefaults(level = PRIVATE, makeFinal = true) +public enum ContentType { + + /** + * Unknown content type. + */ + UNDEFINED("undefined"), + /** + * Url encoded content type. + */ + URLENCODED("application/x-www-form-urlencoded"), + /** + * Multipart form data content type. + */ + MULTIPART("multipart/form-data"); + + String header; + + ContentType(String header) { + this.header = header; + } + + /** + * Parses string to content type. + * + * @param str + * string representation of content type. + * + * @return {@link ContentType} instance or {@link ContentType#UNDEFINED}, if + * there is no such content type. + */ + public static ContentType of(String str) { + if (str == null) { + return UNDEFINED; + } + + val trimmed = str.trim(); + for (val type : values()) { + if (trimmed.startsWith(type.getHeader())) { + return type; + } + } + return UNDEFINED; + } +} diff --git a/feign-form/src/main/java/feign/form/FormData.java b/feign-form/src/main/java/feign/form/FormData.java new file mode 100644 index 000000000..92d5a7f1e --- /dev/null +++ b/feign-form/src/main/java/feign/form/FormData.java @@ -0,0 +1,46 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form; + +import static lombok.AccessLevel.PRIVATE; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldDefaults; + +/** + * This object encapsulates a byte array and its associated content type. Use if + * if you want to specify the content type of your provided byte array. + * + * @since 24.03.2018 + * @author Guillaume Simard + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@FieldDefaults(level = PRIVATE) +public class FormData { + + String contentType; + + String fileName; + + byte[] data; +} diff --git a/feign-form/src/main/java/feign/form/FormEncoder.java b/feign-form/src/main/java/feign/form/FormEncoder.java new file mode 100644 index 000000000..fb01421c6 --- /dev/null +++ b/feign-form/src/main/java/feign/form/FormEncoder.java @@ -0,0 +1,140 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form; + +import static feign.form.util.PojoUtil.isUserPojo; +import static feign.form.util.PojoUtil.toMap; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Arrays.asList; +import static lombok.AccessLevel.PRIVATE; + +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; + +import lombok.experimental.FieldDefaults; +import lombok.val; + +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; + +/** + * A Feign's form encoder. + * + * @author Artem Labazin + */ +@FieldDefaults(level = PRIVATE, makeFinal = true) +public class FormEncoder implements Encoder { + + private static final String CONTENT_TYPE_HEADER; + + private static final Pattern CHARSET_PATTERN; + + static { + CONTENT_TYPE_HEADER = "Content-Type"; + CHARSET_PATTERN = Pattern.compile("(?<=charset=)([\\w\\-]+)"); + } + + Encoder delegate; + + Map processors; + + /** + * Constructor with the default Feign's encoder as a delegate. + */ + public FormEncoder() { + this(new Encoder.Default()); + } + + /** + * Constructor with specified delegate encoder. + * + * @param delegate + * delegate encoder, if this encoder couldn't encode object. + */ + public FormEncoder(Encoder delegate) { + this.delegate = delegate; + + val list = asList(new MultipartFormContentProcessor(delegate), new UrlencodedFormContentProcessor()); + + processors = new HashMap(list.size(), 1.F); + for (ContentProcessor processor : list) { + processors.put(processor.getSupportedContentType(), processor); + } + } + + @Override + @SuppressWarnings("unchecked") + public void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException { + String contentTypeValue = getContentTypeValue(template.headers()); + val contentType = ContentType.of(contentTypeValue); + if (processors.containsKey(contentType) == false) { + delegate.encode(object, bodyType, template); + return; + } + + Map data; + if (object instanceof Map) { + data = (Map) object; + } else if (isUserPojo(bodyType)) { + data = toMap(object); + } else { + delegate.encode(object, bodyType, template); + return; + } + + val charset = getCharset(contentTypeValue); + processors.get(contentType).process(template, charset, data); + } + + /** + * Returns {@link ContentProcessor} for specific {@link ContentType}. + * + * @param type + * a type for content processor search. + * + * @return {@link ContentProcessor} instance for specified type or null. + */ + public final ContentProcessor getContentProcessor(ContentType type) { + return processors.get(type); + } + + @SuppressWarnings("PMD.AvoidBranchingStatementAsLastInLoop") + private String getContentTypeValue(Map> headers) { + for (val entry : headers.entrySet()) { + if (!entry.getKey().equalsIgnoreCase(CONTENT_TYPE_HEADER)) { + continue; + } + for (val contentTypeValue : entry.getValue()) { + if (contentTypeValue == null) { + continue; + } + return contentTypeValue; + } + } + return null; + } + + private Charset getCharset(String contentTypeValue) { + val matcher = CHARSET_PATTERN.matcher(contentTypeValue); + return matcher.find() ? Charset.forName(matcher.group(1)) : UTF_8; + } +} diff --git a/feign-form/src/main/java/feign/form/FormProperty.java b/feign-form/src/main/java/feign/form/FormProperty.java new file mode 100644 index 000000000..af204e0c4 --- /dev/null +++ b/feign-form/src/main/java/feign/form/FormProperty.java @@ -0,0 +1,40 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * A form property annotation to specify a field's name. + * + * @author marembo + */ +@Documented +@Target(FIELD) +@Retention(RUNTIME) +public @interface FormProperty { + + /** + * The name of the property. + */ + String value(); +} diff --git a/feign-form/src/main/java/feign/form/MultipartFormContentProcessor.java b/feign-form/src/main/java/feign/form/MultipartFormContentProcessor.java new file mode 100644 index 000000000..cd79cbf95 --- /dev/null +++ b/feign-form/src/main/java/feign/form/MultipartFormContentProcessor.java @@ -0,0 +1,161 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form; + +import static feign.form.ContentType.MULTIPART; +import static lombok.AccessLevel.PRIVATE; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; +import java.util.LinkedList; +import java.util.Map; + +import lombok.experimental.FieldDefaults; +import lombok.val; + +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.form.multipart.ByteArrayWriter; +import feign.form.multipart.DelegateWriter; +import feign.form.multipart.FormDataWriter; +import feign.form.multipart.ManyFilesWriter; +import feign.form.multipart.ManyParametersWriter; +import feign.form.multipart.Output; +import feign.form.multipart.PojoWriter; +import feign.form.multipart.SingleFileWriter; +import feign.form.multipart.SingleParameterWriter; +import feign.form.multipart.Writer; + +/** + * Multipart form content processor. + * + * @author Artem Labazin + */ +@FieldDefaults(level = PRIVATE, makeFinal = true) +public class MultipartFormContentProcessor implements ContentProcessor { + + Deque writers; + + Writer defaultPerocessor; + + /** + * Constructor with specific delegate encoder. + * + * @param delegate + * specific delegate encoder for cases, when this processor couldn't + * handle request parameter. + */ + public MultipartFormContentProcessor(Encoder delegate) { + writers = new LinkedList<>(); + addWriter(new ByteArrayWriter()); + addWriter(new FormDataWriter()); + addWriter(new SingleFileWriter()); + addWriter(new ManyFilesWriter()); + addWriter(new SingleParameterWriter()); + addWriter(new ManyParametersWriter()); + addWriter(new PojoWriter(writers)); + + defaultPerocessor = new DelegateWriter(delegate); + } + + @Override + public void process(RequestTemplate template, Charset charset, Map data) throws EncodeException { + val boundary = Long.toHexString(System.currentTimeMillis()); + try (val output = new Output(charset)) { + for (val entry : data.entrySet()) { + if (entry == null || entry.getKey() == null || entry.getValue() == null) { + continue; + } + val writer = findApplicableWriter(entry.getValue()); + writer.write(output, boundary, entry.getKey(), entry.getValue()); + } + + output.write("--").write(boundary).write("--").write(CRLF); + + val contentTypeHeaderValue = new StringBuilder().append(getSupportedContentType().getHeader()) + .append("; charset=").append(charset.name()).append("; boundary=").append(boundary).toString(); + + template.header(CONTENT_TYPE_HEADER, Collections.emptyList()); // reset header + template.header(CONTENT_TYPE_HEADER, contentTypeHeaderValue); + + // Feign's clients try to determine binary/string content by charset presence + // so, I set it to null (in spite of availability charset) for backward + // compatibility. + val bytes = output.toByteArray(); + template.body(bytes, null); + } catch (IOException ex) { + throw new EncodeException("Output closing error", ex); + } + } + + @Override + public ContentType getSupportedContentType() { + return MULTIPART; + } + + /** + * Adds {@link Writer} instance in runtime. + * + * @param writer + * additional writer. + */ + public final void addWriter(Writer writer) { + writers.add(writer); + } + + /** + * Adds {@link Writer} instance in runtime at the beginning of writers list. + * + * @param writer + * additional writer. + */ + public final void addFirstWriter(Writer writer) { + writers.addFirst(writer); + } + + /** + * Adds {@link Writer} instance in runtime at the end of writers list. + * + * @param writer + * additional writer. + */ + public final void addLastWriter(Writer writer) { + writers.addLast(writer); + } + + /** + * Returns the unmodifiable collection of all writers. + * + * @return writers collection. + */ + public final Collection getWriters() { + return Collections.unmodifiableCollection(writers); + } + + private Writer findApplicableWriter(Object value) { + for (val writer : writers) { + if (writer.isApplicable(value)) { + return writer; + } + } + return defaultPerocessor; + } +} diff --git a/feign-form/src/main/java/feign/form/UrlencodedFormContentProcessor.java b/feign-form/src/main/java/feign/form/UrlencodedFormContentProcessor.java new file mode 100644 index 000000000..69d24915d --- /dev/null +++ b/feign-form/src/main/java/feign/form/UrlencodedFormContentProcessor.java @@ -0,0 +1,116 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form; + +import static feign.form.ContentType.URLENCODED; + +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Map.Entry; + +import lombok.SneakyThrows; +import lombok.val; + +import feign.RequestTemplate; +import feign.codec.EncodeException; + +/** + * An URL encoded form content processor. + * + * @author Artem Labazin + */ +public class UrlencodedFormContentProcessor implements ContentProcessor { + + private static final char QUERY_DELIMITER = '&'; + + private static final char EQUAL_SIGN = '='; + + @SneakyThrows + private static String encode(Object string, Charset charset) { + return URLEncoder.encode(string.toString(), charset.name()); + } + + @Override + public void process(RequestTemplate template, Charset charset, Map data) throws EncodeException { + val bodyData = new StringBuilder(); + for (Entry entry : data.entrySet()) { + if (entry == null || entry.getKey() == null) { + continue; + } + if (bodyData.length() > 0) { + bodyData.append(QUERY_DELIMITER); + } + bodyData.append(createKeyValuePair(entry, charset)); + } + + val contentTypeValue = new StringBuilder().append(getSupportedContentType().getHeader()).append("; charset=") + .append(charset.name()).toString(); + + val bytes = bodyData.toString().getBytes(charset); + + template.header(CONTENT_TYPE_HEADER, Collections.emptyList()); // reset header + template.header(CONTENT_TYPE_HEADER, contentTypeValue); + template.body(bytes, charset); + } + + @Override + public ContentType getSupportedContentType() { + return URLENCODED; + } + + private String createKeyValuePair(Entry entry, Charset charset) { + String encodedKey = encode(entry.getKey(), charset); + Object value = entry.getValue(); + + if (value == null) { + return encodedKey; + } else if (value.getClass().isArray()) { + return createKeyValuePairFromArray(encodedKey, value, charset); + } else if (value instanceof Collection) { + return createKeyValuePairFromCollection(encodedKey, value, charset); + } + return new StringBuilder().append(encodedKey).append(EQUAL_SIGN).append(encode(value, charset)).toString(); + } + + private String createKeyValuePairFromCollection(String key, Object values, Charset charset) { + val collection = (Collection) values; + val array = collection.toArray(new Object[0]); + return createKeyValuePairFromArray(key, array, charset); + } + + private String createKeyValuePairFromArray(String key, Object values, Charset charset) { + val result = new StringBuilder(); + val array = (Object[]) values; + + for (int index = 0; index < array.length; index++) { + val value = array[index]; + if (value == null) { + continue; + } + + if (index > 0) { + result.append(QUERY_DELIMITER); + } + + result.append(key).append(EQUAL_SIGN).append(encode(value, charset)); + } + return result.toString(); + } +} diff --git a/feign-form/src/main/java/feign/form/multipart/AbstractWriter.java b/feign-form/src/main/java/feign/form/multipart/AbstractWriter.java new file mode 100644 index 000000000..4df095860 --- /dev/null +++ b/feign-form/src/main/java/feign/form/multipart/AbstractWriter.java @@ -0,0 +1,94 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.multipart; + +import static feign.form.ContentProcessor.CRLF; + +import java.net.URLConnection; + +import lombok.val; + +import feign.codec.EncodeException; + +/** + * A base writer class. + * + * @author Artem Labazin + */ +public abstract class AbstractWriter implements Writer { + + @Override + public void write(Output output, String boundary, String key, Object value) throws EncodeException { + output.write("--").write(boundary).write(CRLF); + write(output, key, value); + output.write(CRLF); + } + + /** + * Writes data for it's children. + * + * @param output + * output writer. + * @param key + * name for piece of data. + * @param value + * piece of data. + * + * @throws EncodeException + * in case of write errors + */ + @SuppressWarnings({"PMD.UncommentedEmptyMethodBody", "PMD.EmptyMethodInAbstractClassShouldBeAbstract"}) + protected void write(Output output, String key, Object value) throws EncodeException { + } + + /** + * Writes file's metadata. + * + * @param output + * output writer. + * @param name + * name for piece of data. + * @param fileName + * file name. + * @param contentType + * type of file content. May be the {@code null}, in that case it + * will be determined by file name. + */ + protected void writeFileMetadata(Output output, String name, String fileName, String contentType) { + val contentDespositionBuilder = new StringBuilder().append("Content-Disposition: form-data; name=\"") + .append(name).append("\""); + if (fileName != null) { + contentDespositionBuilder.append("; ").append("filename=\"").append(fileName).append("\""); + } + + String fileContentType = contentType; + if (fileContentType == null) { + if (fileName != null) { + fileContentType = URLConnection.guessContentTypeFromName(fileName); + } + if (fileContentType == null) { + fileContentType = "application/octet-stream"; + } + } + + val string = new StringBuilder().append(contentDespositionBuilder.toString()).append(CRLF) + .append("Content-Type: ").append(fileContentType).append(CRLF) + .append("Content-Transfer-Encoding: binary").append(CRLF).append(CRLF).toString(); + + output.write(string); + } +} diff --git a/feign-form/src/main/java/feign/form/multipart/ByteArrayWriter.java b/feign-form/src/main/java/feign/form/multipart/ByteArrayWriter.java new file mode 100644 index 000000000..28fbd5998 --- /dev/null +++ b/feign-form/src/main/java/feign/form/multipart/ByteArrayWriter.java @@ -0,0 +1,40 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.multipart; + +import feign.codec.EncodeException; + +/** + * A byte array writer. + * + * @author Artem Labazin + */ +public class ByteArrayWriter extends AbstractWriter { + + @Override + public boolean isApplicable(Object value) { + return value instanceof byte[]; + } + + @Override + protected void write(Output output, String key, Object value) throws EncodeException { + writeFileMetadata(output, key, null, null); + + byte[] bytes = (byte[]) value; + output.write(bytes); + } +} diff --git a/feign-form/src/main/java/feign/form/multipart/DelegateWriter.java b/feign-form/src/main/java/feign/form/multipart/DelegateWriter.java new file mode 100644 index 000000000..e39236880 --- /dev/null +++ b/feign-form/src/main/java/feign/form/multipart/DelegateWriter.java @@ -0,0 +1,55 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.multipart; + +import static lombok.AccessLevel.PRIVATE; + +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.val; + +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; + +/** + * A delegate writer. + * + * @author Artem Labazin + */ +@RequiredArgsConstructor +@FieldDefaults(level = PRIVATE, makeFinal = true) +public class DelegateWriter extends AbstractWriter { + + Encoder delegate; + + SingleParameterWriter parameterWriter = new SingleParameterWriter(); + + @Override + public boolean isApplicable(Object value) { + return true; + } + + @Override + protected void write(Output output, String key, Object value) throws EncodeException { + val fake = new RequestTemplate(); + delegate.encode(value, value.getClass(), fake); + val bytes = fake.body(); + val string = new String(bytes, output.getCharset()).replaceAll("\n", ""); + parameterWriter.write(output, key, string); + } +} diff --git a/feign-form/src/main/java/feign/form/multipart/FormDataWriter.java b/feign-form/src/main/java/feign/form/multipart/FormDataWriter.java new file mode 100644 index 000000000..fbb288616 --- /dev/null +++ b/feign-form/src/main/java/feign/form/multipart/FormDataWriter.java @@ -0,0 +1,43 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.multipart; + +import lombok.val; + +import feign.codec.EncodeException; +import feign.form.FormData; + +/** + * A {@link FormData} writer. + * + * @since 24.03.2018 + * @author Guillaume Simard + */ +public class FormDataWriter extends AbstractWriter { + + @Override + public boolean isApplicable(Object value) { + return value instanceof FormData; + } + + @Override + protected void write(Output output, String key, Object value) throws EncodeException { + val formData = (FormData) value; + writeFileMetadata(output, key, formData.getFileName(), formData.getContentType()); + output.write(formData.getData()); + } +} diff --git a/feign-form/src/main/java/feign/form/multipart/ManyFilesWriter.java b/feign-form/src/main/java/feign/form/multipart/ManyFilesWriter.java new file mode 100644 index 000000000..c732f34f0 --- /dev/null +++ b/feign-form/src/main/java/feign/form/multipart/ManyFilesWriter.java @@ -0,0 +1,67 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.multipart; + +import static lombok.AccessLevel.PRIVATE; + +import java.io.File; + +import lombok.experimental.FieldDefaults; +import lombok.val; + +import feign.codec.EncodeException; + +/** + * A writer for multiple files. + * + * @author Artem Labazin + */ +@FieldDefaults(level = PRIVATE, makeFinal = true) +public class ManyFilesWriter extends AbstractWriter { + + SingleFileWriter fileWriter = new SingleFileWriter(); + + @Override + public boolean isApplicable(Object value) { + if (value instanceof File[]) { + return true; + } + if (!(value instanceof Iterable)) { + return false; + } + val iterable = (Iterable) value; + val iterator = iterable.iterator(); + return iterator.hasNext() && iterator.next() instanceof File; + } + + @Override + public void write(Output output, String boundary, String key, Object value) throws EncodeException { + if (value instanceof File[]) { + val files = (File[]) value; + for (val file : files) { + fileWriter.write(output, boundary, key, file); + } + } else if (value instanceof Iterable) { + val iterable = (Iterable) value; + for (val file : iterable) { + fileWriter.write(output, boundary, key, file); + } + } else { + throw new IllegalArgumentException(); + } + } +} diff --git a/feign-form/src/main/java/feign/form/multipart/ManyParametersWriter.java b/feign-form/src/main/java/feign/form/multipart/ManyParametersWriter.java new file mode 100644 index 000000000..e8e7a20c1 --- /dev/null +++ b/feign-form/src/main/java/feign/form/multipart/ManyParametersWriter.java @@ -0,0 +1,64 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.multipart; + +import static lombok.AccessLevel.PRIVATE; + +import lombok.experimental.FieldDefaults; +import lombok.val; + +import feign.codec.EncodeException; + +/** + * A multiple parameters writer. + * + * @author Artem Labazin + */ +@FieldDefaults(level = PRIVATE, makeFinal = true) +public class ManyParametersWriter extends AbstractWriter { + + SingleParameterWriter parameterWriter = new SingleParameterWriter(); + + @Override + public boolean isApplicable(Object value) { + if (value.getClass().isArray()) { + Object[] values = (Object[]) value; + return values.length > 0 && parameterWriter.isApplicable(values[0]); + } + if (!(value instanceof Iterable)) { + return false; + } + val iterable = (Iterable) value; + val iterator = iterable.iterator(); + return iterator.hasNext() && parameterWriter.isApplicable(iterator.next()); + } + + @Override + public void write(Output output, String boundary, String key, Object value) throws EncodeException { + if (value.getClass().isArray()) { + val objects = (Object[]) value; + for (val object : objects) { + parameterWriter.write(output, boundary, key, object); + } + } else if (value instanceof Iterable) { + val iterable = (Iterable) value; + for (val object : iterable) { + parameterWriter.write(output, boundary, key, object); + } + } + } +} diff --git a/feign-form/src/main/java/feign/form/multipart/Output.java b/feign-form/src/main/java/feign/form/multipart/Output.java new file mode 100644 index 000000000..cc0284de7 --- /dev/null +++ b/feign-form/src/main/java/feign/form/multipart/Output.java @@ -0,0 +1,102 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.multipart; + +import static lombok.AccessLevel.PRIVATE; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.nio.charset.Charset; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.experimental.FieldDefaults; + +/** + * Output representation utility class. + * + * @author Artem Labazin + */ +@RequiredArgsConstructor +@FieldDefaults(level = PRIVATE, makeFinal = true) +public class Output implements Closeable { + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + @Getter + Charset charset; + + /** + * Writes the string to the output. + * + * @param string + * string to write to this output + * + * @return this output + */ + public Output write(String string) { + return write(string.getBytes(charset)); + } + + /** + * Writes the byte array to the output. + * + * @param bytes + * byte arrays to write to this output + * + * @return this output + */ + @SneakyThrows + public Output write(byte[] bytes) { + outputStream.write(bytes); + return this; + } + + /** + * Writes the byte array to the output with specified offset and fixed length. + * + * @param bytes + * byte arrays to write to this output + * @param offset + * the offset within the array of the first byte to be read. Must be + * non-negative and no larger than bytes.length + * @param length + * the number of bytes to be read from the given array + * + * @return this output + */ + public Output write(byte[] bytes, int offset, int length) { + outputStream.write(bytes, offset, length); + return this; + } + + /** + * Returns byte array representation of this output class. + * + * @return byte array representation of output + */ + public byte[] toByteArray() { + return outputStream.toByteArray(); + } + + @Override + public void close() throws IOException { + outputStream.close(); + } +} diff --git a/feign-form/src/main/java/feign/form/multipart/PojoWriter.java b/feign-form/src/main/java/feign/form/multipart/PojoWriter.java new file mode 100644 index 000000000..9088f9637 --- /dev/null +++ b/feign-form/src/main/java/feign/form/multipart/PojoWriter.java @@ -0,0 +1,66 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.multipart; + +import static feign.form.util.PojoUtil.isUserPojo; +import static feign.form.util.PojoUtil.toMap; +import static lombok.AccessLevel.PRIVATE; + +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; +import lombok.val; + +import feign.codec.EncodeException; + +/** + * A custom user's POJO writer. + * + * @author Artem Labazin + */ +@RequiredArgsConstructor +@FieldDefaults(level = PRIVATE, makeFinal = true) +public class PojoWriter extends AbstractWriter { + + Iterable writers; + + @Override + public boolean isApplicable(Object object) { + return isUserPojo(object); + } + + @Override + public void write(Output output, String boundary, String key, Object object) throws EncodeException { + val map = toMap(object); + for (val entry : map.entrySet()) { + val writer = findApplicableWriter(entry.getValue()); + if (writer == null) { + continue; + } + + writer.write(output, boundary, entry.getKey(), entry.getValue()); + } + } + + private Writer findApplicableWriter(Object value) { + for (val writer : writers) { + if (writer.isApplicable(value)) { + return writer; + } + } + return null; + } +} diff --git a/feign-form/src/main/java/feign/form/multipart/SingleFileWriter.java b/feign-form/src/main/java/feign/form/multipart/SingleFileWriter.java new file mode 100644 index 000000000..e7443d700 --- /dev/null +++ b/feign-form/src/main/java/feign/form/multipart/SingleFileWriter.java @@ -0,0 +1,57 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.multipart; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +import lombok.val; + +import feign.codec.EncodeException; + +/** + * A single-file writer. + * + * @author Artem Labazin + */ +public class SingleFileWriter extends AbstractWriter { + + @Override + public boolean isApplicable(Object value) { + return value instanceof File; + } + + @Override + protected void write(Output output, String key, Object value) throws EncodeException { + val file = (File) value; + writeFileMetadata(output, key, file.getName(), null); + + try (InputStream input = new FileInputStream(file)) { + val buf = new byte[4096]; + int length = input.read(buf); + while (length > 0) { + output.write(buf, 0, length); + length = input.read(buf); + } + } catch (IOException ex) { + val message = String.format("Writing file's '%s' content error", file.getName()); + throw new EncodeException(message, ex); + } + } +} diff --git a/feign-form/src/main/java/feign/form/multipart/SingleParameterWriter.java b/feign-form/src/main/java/feign/form/multipart/SingleParameterWriter.java new file mode 100644 index 000000000..1f8ffaadb --- /dev/null +++ b/feign-form/src/main/java/feign/form/multipart/SingleParameterWriter.java @@ -0,0 +1,45 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.multipart; + +import static feign.form.ContentProcessor.CRLF; + +import lombok.val; + +import feign.codec.EncodeException; + +/** + * A writer for a single parameter. + * + * @author Artem Labazin + */ +public class SingleParameterWriter extends AbstractWriter { + + @Override + public boolean isApplicable(Object value) { + return value instanceof Number || value instanceof CharSequence || value instanceof Boolean; + } + + @Override + protected void write(Output output, String key, Object value) throws EncodeException { + val string = new StringBuilder().append("Content-Disposition: form-data; name=\"").append(key).append('"') + .append(CRLF).append("Content-Type: text/plain; charset=").append(output.getCharset().name()) + .append(CRLF).append(CRLF).append(value.toString()).toString(); + + output.write(string); + } +} diff --git a/feign-form/src/main/java/feign/form/multipart/Writer.java b/feign-form/src/main/java/feign/form/multipart/Writer.java new file mode 100644 index 000000000..7c0e0d2b7 --- /dev/null +++ b/feign-form/src/main/java/feign/form/multipart/Writer.java @@ -0,0 +1,54 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.multipart; + +import feign.codec.EncodeException; + +/** + * A writer interface. + * + * @author Artem Labazin + */ +public interface Writer { + + /** + * Processing form data to request body. + * + * @param output + * output writer. + * @param boundary + * data boundary. + * @param key + * name for piece of data. + * @param value + * piece of data. + * + * @throws EncodeException + * in case of any encode exception + */ + void write(Output output, String boundary, String key, Object value) throws EncodeException; + + /** + * Answers on question - "could this writer properly write the value". + * + * @param value + * object to write. + * + * @return {@code true} - if could write this object, otherwise {@code true} + */ + boolean isApplicable(Object value); +} diff --git a/feign-form/src/main/java/feign/form/util/PojoUtil.java b/feign-form/src/main/java/feign/form/util/PojoUtil.java new file mode 100644 index 000000000..5f152d3fe --- /dev/null +++ b/feign-form/src/main/java/feign/form/util/PojoUtil.java @@ -0,0 +1,100 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.util; + +import static java.lang.reflect.Modifier.isFinal; +import static java.lang.reflect.Modifier.isStatic; +import static lombok.AccessLevel.PRIVATE; + +import feign.form.FormProperty; +import java.lang.reflect.Field; +import java.lang.reflect.Type; +import java.rmi.UnexpectedException; +import java.security.PrivilegedAction; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nullable; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import lombok.Setter; +import lombok.SneakyThrows; +import lombok.val; +import lombok.experimental.FieldDefaults; + +/** + * An utility class to work with POJOs. + * + * @author Artem Labazin + */ +public final class PojoUtil { + + public static boolean isUserPojo(@NonNull Object object) { + val type = object.getClass(); + val packageName = type.getPackage().getName(); + return !packageName.startsWith("java."); + } + + public static boolean isUserPojo(@NonNull Type type) { + val typeName = type.toString(); + return !typeName.startsWith("class java."); + } + + @SneakyThrows + @SuppressFBWarnings("DP_DO_INSIDE_DO_PRIVILEGED") + public static Map toMap(@NonNull Object object) { + val result = new HashMap(); + val type = object.getClass(); + for (val field : type.getDeclaredFields()) { + val modifiers = field.getModifiers(); + if (isFinal(modifiers) || isStatic(modifiers)) { + continue; + } + field.setAccessible(true); + + val fieldValue = field.get(object); + if (fieldValue == null) { + continue; + } + + val propertyKey = field.isAnnotationPresent(FormProperty.class) + ? field.getAnnotation(FormProperty.class).value() + : field.getName(); + + result.put(propertyKey, fieldValue); + } + return result; + } + + private PojoUtil() throws UnexpectedException { + throw new UnexpectedException("It is not allowed to instantiate this class"); + } + + @Setter + @NoArgsConstructor + @FieldDefaults(level = PRIVATE) + private static final class SetAccessibleAction implements PrivilegedAction { + + @Nullable + Field field; + + @Override + public Object run() { + field.setAccessible(true); + return null; + } + } +} diff --git a/feign-form/src/main/java/lombok.config b/feign-form/src/main/java/lombok.config new file mode 100644 index 000000000..26f5d95a3 --- /dev/null +++ b/feign-form/src/main/java/lombok.config @@ -0,0 +1 @@ +lombok.extern.findbugs.addSuppressFBWarnings=true diff --git a/feign-form/src/test/java/feign/form/BasicClientTest.java b/feign-form/src/test/java/feign/form/BasicClientTest.java new file mode 100644 index 000000000..d4415cda6 --- /dev/null +++ b/feign-form/src/test/java/feign/form/BasicClientTest.java @@ -0,0 +1,153 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form; + +import static feign.Logger.Level.FULL; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonMap; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Map; + +import lombok.val; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import feign.Feign; +import feign.Logger.JavaLogger; +import feign.Response; +import feign.jackson.JacksonEncoder; + +@SpringBootTest(webEnvironment = DEFINED_PORT, classes = Server.class) +class BasicClientTest { + + private static final TestClient API; + + static { + API = Feign.builder().encoder(new FormEncoder(new JacksonEncoder())) + .logger(new JavaLogger(BasicClientTest.class).appendToFile("log.txt")).logLevel(FULL) + .target(TestClient.class, "http://localhost:8080"); + } + + @Test + void testForm() { + assertThat(API.form("1", "1")).isNotNull().extracting(Response::status).isEqualTo(200); + } + + @Test + void testFormException() { + assertThat(API.form("1", "2")).isNotNull().extracting(Response::status).isEqualTo(400); + } + + @Test + void testUpload() throws Exception { + val path = Paths.get(Thread.currentThread().getContextClassLoader().getResource("file.txt").toURI()); + assertThat(path).exists(); + + assertThat(API.upload(path.toFile())).asLong().isEqualTo(Files.size(path)); + } + + @Test + void testUploadWithParam() throws Exception { + val path = Paths.get(Thread.currentThread().getContextClassLoader().getResource("file.txt").toURI()); + assertThat(path).exists(); + + assertThat(API.upload(10, Boolean.TRUE, path.toFile())).asLong().isEqualTo(Files.size(path)); + } + + @Test + void testJson() { + val dto = new Dto("Artem", 11); + + assertThat(API.json(dto)).isEqualTo("ok"); + } + + @Test + void testQueryMap() { + Map value = singletonMap("filter", (Object) asList("one", "two", "three", "four")); + + assertThat(API.queryMap(value)).isEqualTo("4"); + } + + @Test + void testMultipleFilesArray() throws Exception { + val path1 = Paths.get(Thread.currentThread().getContextClassLoader().getResource("file.txt").toURI()); + assertThat(path1).exists(); + + val path2 = Paths.get(Thread.currentThread().getContextClassLoader().getResource("another_file.txt").toURI()); + assertThat(path2).exists(); + + assertThat(API.uploadWithArray(new File[]{path1.toFile(), path2.toFile()})).asLong() + .isEqualTo(Files.size(path1) + Files.size(path2)); + } + + @Test + void testMultipleFilesList() throws Exception { + val path1 = Paths.get(Thread.currentThread().getContextClassLoader().getResource("file.txt").toURI()); + assertThat(path1).exists(); + + val path2 = Paths.get(Thread.currentThread().getContextClassLoader().getResource("another_file.txt").toURI()); + assertThat(path2).exists(); + + assertThat(API.uploadWithList(asList(path1.toFile(), path2.toFile()))).asLong() + .isEqualTo(Files.size(path1) + Files.size(path2)); + } + + @Test + void testUploadWithDto() throws Exception { + val dto = new Dto("Artem", 11); + + val path = Paths.get(Thread.currentThread().getContextClassLoader().getResource("file.txt").toURI()); + assertThat(path).exists(); + + assertThat(API.uploadWithDto(dto, path.toFile())).isNotNull().extracting(Response::status).isEqualTo(200); + } + + @Test + void testUnknownTypeFile() throws Exception { + val path = Paths.get(Thread.currentThread().getContextClassLoader().getResource("file.abc").toURI()); + assertThat(path).exists(); + + assertThat(API.uploadUnknownType(path.toFile())).isEqualTo("application/octet-stream"); + } + + @Test + void testFormData() throws Exception { + val formData = new FormData("application/custom-type", "popa.txt", "Allo".getBytes("UTF-8")); + + assertThat(API.uploadFormData(formData)).isEqualTo("popa.txt:application/custom-type"); + } + + @Test + void testSubmitRepeatableQueryParam() throws Exception { + val names = new String[]{"Milada", "Thais"}; + val stringResponse = API.submitRepeatableQueryParam(names); + assertThat(stringResponse).isEqualTo("Milada and Thais"); + } + + @Test + void testSubmitRepeatableFormParam() throws Exception { + val names = Arrays.asList("Milada", "Thais"); + val stringResponse = API.submitRepeatableFormParam(names); + assertThat(stringResponse).isEqualTo("Milada and Thais"); + } +} diff --git a/feign-form/src/test/java/feign/form/ByteArrayClientTest.java b/feign-form/src/test/java/feign/form/ByteArrayClientTest.java new file mode 100644 index 000000000..04e5275f2 --- /dev/null +++ b/feign-form/src/test/java/feign/form/ByteArrayClientTest.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form; + +import static feign.Logger.Level.FULL; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; + +import lombok.val; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import feign.Feign; +import feign.Headers; +import feign.Logger.JavaLogger; +import feign.Param; +import feign.RequestLine; +import feign.Response; +import feign.jackson.JacksonEncoder; + +@SpringBootTest(webEnvironment = DEFINED_PORT, classes = Server.class) +class ByteArrayClientTest { + + private static final CustomClient API; + + static { + val encoder = new FormEncoder(new JacksonEncoder()); + + API = Feign.builder().encoder(encoder) + .logger(new JavaLogger(ByteArrayClientTest.class).appendToFile("log-byte.txt")).logLevel(FULL) + .target(CustomClient.class, "http://localhost:8080"); + } + + @Test + void testNotTreatedAsFileUpload() { + byte[] bytes = "Hello World".getBytes(); + + assertThat(API.uploadByteArray(bytes)).isNotNull().extracting(Response::status).isEqualTo(200); + } + + interface CustomClient { + + @RequestLine("POST /upload/byte_array_parameter") + @Headers("Content-Type: multipart/form-data") + Response uploadByteArray(@Param("file") byte[] bytes); + } +} diff --git a/feign-form/src/test/java/feign/form/CustomClientTest.java b/feign-form/src/test/java/feign/form/CustomClientTest.java new file mode 100644 index 000000000..90145cc4d --- /dev/null +++ b/feign-form/src/test/java/feign/form/CustomClientTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form; + +import static feign.Logger.Level.FULL; +import static feign.form.ContentType.MULTIPART; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; + +import lombok.val; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import feign.Feign; +import feign.Headers; +import feign.Logger.JavaLogger; +import feign.Param; +import feign.RequestLine; +import feign.codec.EncodeException; +import feign.form.multipart.ByteArrayWriter; +import feign.form.multipart.Output; +import feign.jackson.JacksonEncoder; + +@SpringBootTest(webEnvironment = DEFINED_PORT, classes = Server.class) +class CustomClientTest { + + private static final CustomClient API; + + static { + val encoder = new FormEncoder(new JacksonEncoder()); + val processor = (MultipartFormContentProcessor) encoder.getContentProcessor(MULTIPART); + processor.addFirstWriter(new CustomByteArrayWriter()); + + API = Feign.builder().encoder(encoder).logger(new JavaLogger(CustomClientTest.class).appendToFile("log.txt")) + .logLevel(FULL).target(CustomClient.class, "http://localhost:8080"); + } + + @Test + void test() { + assertThat(API.uploadByteArray(new byte[0])).isNotNull().isEqualTo("popa.txt"); + } + + private static final class CustomByteArrayWriter extends ByteArrayWriter { + + @Override + protected void write(Output output, String key, Object value) throws EncodeException { + writeFileMetadata(output, key, "popa.txt", null); + + val bytes = (byte[]) value; + output.write(bytes); + } + } + + interface CustomClient { + + @RequestLine("POST /upload/byte_array") + @Headers("Content-Type: multipart/form-data") + String uploadByteArray(@Param("file") byte[] bytes); + } +} diff --git a/feign-form/src/test/java/feign/form/Dto.java b/feign-form/src/test/java/feign/form/Dto.java new file mode 100644 index 000000000..2ca27140a --- /dev/null +++ b/feign-form/src/test/java/feign/form/Dto.java @@ -0,0 +1,39 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form; + +import static lombok.AccessLevel.PRIVATE; + +import java.io.Serializable; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldDefaults; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@FieldDefaults(level = PRIVATE) +class Dto implements Serializable { + + private static final long serialVersionUID = 4743133513526293872L; + + String name; + + Integer age; +} diff --git a/feign-form/src/test/java/feign/form/FormDto.java b/feign-form/src/test/java/feign/form/FormDto.java new file mode 100644 index 000000000..d22fd28a1 --- /dev/null +++ b/feign-form/src/test/java/feign/form/FormDto.java @@ -0,0 +1,36 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form; + +import static lombok.AccessLevel.PRIVATE; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.FieldDefaults; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@FieldDefaults(level = PRIVATE) +public class FormDto { + + @FormProperty("f_name") + String firstName; + + Integer age; +} diff --git a/feign-form/src/test/java/feign/form/FormPropertyTest.java b/feign-form/src/test/java/feign/form/FormPropertyTest.java new file mode 100644 index 000000000..bcafac742 --- /dev/null +++ b/feign-form/src/test/java/feign/form/FormPropertyTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form; + +import static feign.Logger.Level.FULL; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; + +import lombok.val; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import feign.Feign; +import feign.Headers; +import feign.Logger.JavaLogger; +import feign.RequestLine; +import feign.jackson.JacksonEncoder; + +@SpringBootTest(webEnvironment = DEFINED_PORT, classes = Server.class) +class FormPropertyTest { + + private static final FormClient API; + + static { + API = Feign.builder().encoder(new FormEncoder(new JacksonEncoder())) + .logger(new JavaLogger(FormPropertyTest.class).appendToFile("log.txt")).logLevel(FULL) + .target(FormClient.class, "http://localhost:8080"); + } + + @Test + void test() { + val dto = new FormDto("Amigo", 23); + + assertThat(API.postData(dto)).isEqualTo("Amigo=23"); + } + + interface FormClient { + + @RequestLine("POST /form-data") + @Headers("Content-Type: application/x-www-form-urlencoded") + String postData(FormDto dto); + } +} diff --git a/feign-form/src/test/java/feign/form/Server.java b/feign-form/src/test/java/feign/form/Server.java new file mode 100644 index 000000000..1cc6bfb1c --- /dev/null +++ b/feign-form/src/test/java/feign/form/Server.java @@ -0,0 +1,198 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.I_AM_A_TEAPOT; +import static org.springframework.http.HttpStatus.LOCKED; +import static org.springframework.http.HttpStatus.OK; +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; + +import lombok.val; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; + +@Controller +@SpringBootApplication +@SuppressWarnings("checkstyle:DesignForExtension") +public class Server { + + @PostMapping("/form") + public ResponseEntity form(@RequestParam("key1") String key1, @RequestParam("key2") String key2) { + val status = !key1.equals(key2) ? BAD_REQUEST : OK; + return ResponseEntity.status(status).body(null); + } + + @PostMapping("/upload/{id}") + @ResponseStatus(OK) + public ResponseEntity upload(@PathVariable("id") Integer id, @RequestParam("public") Boolean isPublic, + @RequestParam("file") MultipartFile file) { + HttpStatus status; + if (id == null || id != 10) { + status = LOCKED; + } else if (isPublic == null || !isPublic) { + status = FORBIDDEN; + } else if (file.getSize() == 0) { + status = I_AM_A_TEAPOT; + } else if (file.getOriginalFilename() == null || file.getOriginalFilename().trim().isEmpty()) { + status = CONFLICT; + } else { + status = OK; + } + return ResponseEntity.status(status).body(file.getSize()); + } + + @PostMapping("/upload") + public ResponseEntity upload(@RequestParam("file") MultipartFile file) { + HttpStatus status; + if (file.getSize() == 0) { + status = I_AM_A_TEAPOT; + } else if (file.getOriginalFilename() == null || file.getOriginalFilename().trim().isEmpty()) { + status = CONFLICT; + } else { + status = OK; + } + return ResponseEntity.status(status).body(file.getSize()); + } + + @PostMapping("/upload/files") + public ResponseEntity upload(@RequestParam("files") MultipartFile[] files) { + HttpStatus status; + if (files[0].getSize() == 0 || files[1].getSize() == 0) { + status = I_AM_A_TEAPOT; + } else if (files[0].getOriginalFilename() == null || files[0].getOriginalFilename().trim().isEmpty() + || files[1].getOriginalFilename() == null || files[1].getOriginalFilename().trim().isEmpty()) { + status = CONFLICT; + } else { + status = OK; + } + return ResponseEntity.status(status).body(files[0].getSize() + files[1].getSize()); + } + + @PostMapping(path = "/json", consumes = APPLICATION_JSON_VALUE) + public ResponseEntity json(@RequestBody Dto dto) { + HttpStatus status; + if (!dto.getName().equals("Artem")) { + status = CONFLICT; + } else if (!dto.getAge().equals(11)) { + status = I_AM_A_TEAPOT; + } else { + status = OK; + } + return ResponseEntity.status(status).body("ok"); + } + + @PostMapping("/query_map") + public ResponseEntity queryMap(@RequestParam("filter") List filters) { + val status = filters != null && !filters.isEmpty() ? OK : I_AM_A_TEAPOT; + return ResponseEntity.status(status).body(filters.size()); + } + + @PostMapping(path = "/wild-card-map", consumes = APPLICATION_FORM_URLENCODED_VALUE) + public ResponseEntity wildCardMap(@RequestParam("key1") String key1, @RequestParam("key2") String key2) { + val status = key1.equals(key2) ? OK : I_AM_A_TEAPOT; + return ResponseEntity.status(status).body(null); + } + + @PostMapping(path = "/upload/with_dto", consumes = MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadWithDto(Dto dto, @RequestPart("file") MultipartFile file) throws IOException { + val status = dto != null && dto.getName().equals("Artem") ? OK : I_AM_A_TEAPOT; + return ResponseEntity.status(status).body(file.getSize()); + } + + @PostMapping(path = "/upload/byte_array", consumes = MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadByteArray(@RequestPart("file") MultipartFile file) { + val status = file != null ? OK : I_AM_A_TEAPOT; + return ResponseEntity.status(status).body(file.getOriginalFilename()); + } + + @PostMapping(path = "/upload/byte_array_parameter", consumes = MULTIPART_FORM_DATA_VALUE) + // We just want the request because when there's a filename part of the + // Content-Disposition header spring + // will treat it as a file (available through getFile()) and when it doesn't + // have the filename part it's + // available in the parameter (getParameter()) + public ResponseEntity uploadByteArrayParameter(MultipartHttpServletRequest request) { + val status = request.getFile("file") == null && request.getParameter("file") != null ? OK : I_AM_A_TEAPOT; + return ResponseEntity.status(status).build(); + } + + @PostMapping(path = "/upload/unknown_type", consumes = MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadUnknownType(@RequestPart("file") MultipartFile file) { + val status = file != null ? OK : I_AM_A_TEAPOT; + return ResponseEntity.status(status).body(file.getContentType()); + } + + @PostMapping(path = "/upload/form_data", consumes = MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadFormData(@RequestPart("file") MultipartFile file) { + val status = file != null ? OK : I_AM_A_TEAPOT; + return ResponseEntity.status(status).body(file.getOriginalFilename() + ':' + file.getContentType()); + } + + @PostMapping(path = "/submit/url", consumes = APPLICATION_FORM_URLENCODED_VALUE) + public ResponseEntity submitRepeatableQueryParam(@RequestParam("names") String[] names) { + val response = new StringBuilder(); + if (names != null && names.length == 2) { + response.append(names[0]).append(" and ").append(names[1]); + } + val status = response.length() > 0 ? OK : I_AM_A_TEAPOT; + + return ResponseEntity.status(status).body(response.toString()); + } + + @PostMapping(path = "/submit/form", consumes = MULTIPART_FORM_DATA_VALUE) + public ResponseEntity submitRepeatableFormParam(@RequestParam("names") Collection names) { + val response = new StringBuilder(); + if (names != null && names.size() == 2) { + val iterator = names.iterator(); + response.append(iterator.next()).append(" and ").append(iterator.next()); + } + val status = response.length() > 0 ? OK : I_AM_A_TEAPOT; + + return ResponseEntity.status(status).body(response.toString()); + } + + @PostMapping(path = "/form-data", consumes = APPLICATION_FORM_URLENCODED_VALUE) + public ResponseEntity submitPostData(@RequestParam("f_name") String firstName, + @RequestParam("age") Integer age) { + val response = new StringBuilder(); + if (firstName != null && age != null) { + response.append(firstName).append("=").append(age); + } + val status = response.length() > 0 ? OK : I_AM_A_TEAPOT; + + return ResponseEntity.status(status).body(response.toString()); + } +} diff --git a/feign-form/src/test/java/feign/form/TestClient.java b/feign-form/src/test/java/feign/form/TestClient.java new file mode 100644 index 000000000..874864821 --- /dev/null +++ b/feign-form/src/test/java/feign/form/TestClient.java @@ -0,0 +1,82 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form; + +import java.io.File; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import feign.Headers; +import feign.Param; +import feign.QueryMap; +import feign.RequestLine; +import feign.Response; + +public interface TestClient { + + @RequestLine("POST /form") + @Headers("Content-Type: application/x-www-form-urlencoded") + Response form(@Param("key1") String key1, @Param("key2") String key2); + + @RequestLine("POST /upload/{id}") + @Headers("Content-Type: multipart/form-data") + String upload(@Param("id") Integer id, @Param("public") Boolean isPublic, @Param("file") File file); + + @RequestLine("POST /upload") + @Headers("Content-Type: multipart/form-data") + String upload(@Param("file") File file); + + @RequestLine("POST /json") + @Headers("Content-Type: application/json") + String json(Dto dto); + + @RequestLine("POST /query_map") + String queryMap(@QueryMap Map value); + + @RequestLine("POST /upload/files") + @Headers("Content-Type: multipart/form-data") + String uploadWithArray(@Param("files") File[] files); + + @RequestLine("POST /upload/files") + @Headers("Content-Type: multipart/form-data") + String uploadWithList(@Param("files") List files); + + @RequestLine("POST /upload/files") + @Headers("Content-Type: multipart/form-data") + String uploadWithManyFiles(@Param("files") File file1, @Param("files") File file2); + + @RequestLine("POST /upload/with_dto") + @Headers("Content-Type: multipart/form-data") + Response uploadWithDto(@Param("1") Dto dto, @Param("file") File file); + + @RequestLine("POST /upload/unknown_type") + @Headers("Content-Type: multipart/form-data") + String uploadUnknownType(@Param("file") File file); + + @RequestLine("POST /upload/form_data") + @Headers("Content-Type: multipart/form-data") + String uploadFormData(@Param("file") FormData formData); + + @RequestLine("POST /submit/url") + @Headers("Content-Type: application/x-www-form-urlencoded") + String submitRepeatableQueryParam(@Param("names") String[] names); + + @RequestLine("POST /submit/form") + @Headers("Content-Type: multipart/form-data") + String submitRepeatableFormParam(@Param("names") Collection names); +} diff --git a/feign-form/src/test/java/feign/form/WildCardMapTest.java b/feign-form/src/test/java/feign/form/WildCardMapTest.java new file mode 100644 index 000000000..e61b30f57 --- /dev/null +++ b/feign-form/src/test/java/feign/form/WildCardMapTest.java @@ -0,0 +1,85 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form; + +import static feign.Logger.Level.FULL; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import feign.Feign; +import feign.Headers; +import feign.Logger.JavaLogger; +import feign.RequestLine; +import feign.Response; + +@SpringBootTest(webEnvironment = DEFINED_PORT, classes = Server.class) +class WildCardMapTest { + + private static FormUrlEncodedApi api; + + @BeforeAll + static void configureClient() { + api = Feign.builder().encoder(new FormEncoder()) + .logger(new JavaLogger(WildCardMapTest.class).appendToFile("log.txt")).logLevel(FULL) + .target(FormUrlEncodedApi.class, "http://localhost:8080"); + } + + @Test + void testOk() { + Map param = new HashMap() { + + private static final long serialVersionUID = 3109256773218160485L; + + { + put("key1", "1"); + put("key2", "1"); + } + }; + + assertThat(api.wildCardMap(param)).isNotNull().extracting(Response::status).isEqualTo(200); + } + + @Test + void testBadRequest() { + Map param = new HashMap() { + + private static final long serialVersionUID = 3109256773218160485L; + + { + + put("key1", "1"); + put("key2", "2"); + } + }; + + assertThat(api.wildCardMap(param)).isNotNull().extracting(Response::status).isEqualTo(418); + } + + interface FormUrlEncodedApi { + + @RequestLine("POST /wild-card-map") + @Headers("Content-Type: application/x-www-form-urlencoded") + Response wildCardMap(Map param); + } +} diff --git a/feign-form/src/test/java/feign/form/issues/Issue63Test.java b/feign-form/src/test/java/feign/form/issues/Issue63Test.java new file mode 100644 index 000000000..d372b41ee --- /dev/null +++ b/feign-form/src/test/java/feign/form/issues/Issue63Test.java @@ -0,0 +1,70 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.issues; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashMap; +import java.util.Map; + +import io.undertow.server.HttpServerExchange; +import lombok.val; +import org.junit.jupiter.api.Test; + +import feign.Feign; +import feign.Headers; +import feign.RequestLine; +import feign.form.FormEncoder; +import feign.form.utils.UndertowServer; +import feign.jackson.JacksonEncoder; + +// https://github.com/OpenFeign/feign-form/issues/63 +class Issue63Test { + + @Test + void test() { + try (val server = UndertowServer.builder().callback(this::handleRequest).start()) { + val client = Feign.builder().encoder(new FormEncoder(new JacksonEncoder())).target(Client.class, + server.getConnectUrl()); + + val data = new HashMap(); + data.put("from", "+987654321"); + data.put("to", "+123456789"); + data.put("body", "hello world"); + + assertThat(client.map(data)).isEqualTo("ok"); + } + } + + private void handleRequest(HttpServerExchange exchange, byte[] message) { + // assert request + assertThat(exchange.getRequestHeaders().getFirst(io.undertow.util.Headers.CONTENT_TYPE)) + .isEqualTo("application/x-www-form-urlencoded; charset=UTF-8"); + assertThat(message).asString().isEqualTo("from=%2B987654321&to=%2B123456789&body=hello+world"); + + // build response + exchange.getResponseHeaders().put(io.undertow.util.Headers.CONTENT_TYPE, "text/plain"); + exchange.getResponseSender().send("ok"); + } + + interface Client { + + @RequestLine("POST") + @Headers("Content-Type: application/x-www-form-urlencoded; charset=utf-8") + String map(Map data); + } +} diff --git a/feign-form/src/test/java/feign/form/utils/UndertowServer.java b/feign-form/src/test/java/feign/form/utils/UndertowServer.java new file mode 100644 index 000000000..04ec22a71 --- /dev/null +++ b/feign-form/src/test/java/feign/form/utils/UndertowServer.java @@ -0,0 +1,85 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package feign.form.utils; + +import java.net.InetSocketAddress; + +import io.appulse.utils.SocketUtils; +import io.undertow.Undertow; +import io.undertow.io.Receiver.FullBytesCallback; +import io.undertow.server.HttpHandler; +import io.undertow.server.HttpServerExchange; +import io.undertow.server.handlers.BlockingHandler; +import io.undertow.util.Headers; +import lombok.Builder; +import lombok.RequiredArgsConstructor; +import lombok.val; + +public final class UndertowServer implements AutoCloseable { + + private final Undertow undertow; + + @Builder(buildMethodName = "start") + private UndertowServer(FullBytesCallback callback) { + val port = SocketUtils.findFreePort() + .orElseThrow(() -> new IllegalStateException("no available port to start server")); + + undertow = Undertow.builder().addHttpListener(port, "localhost") + .setHandler(new BlockingHandler(new ReadAllBytesHandler(callback))).build(); + + undertow.start(); + } + + /** + * Returns server connect URL. + * + * @return listining server's url. + */ + public String getConnectUrl() { + val listenerInfo = undertow.getListenerInfo().iterator().next(); + + val address = (InetSocketAddress) listenerInfo.getAddress(); + + return String.format("%s://%s:%d", listenerInfo.getProtcol(), address.getHostString(), address.getPort()); + } + + @Override + public void close() { + undertow.stop(); + } + + @RequiredArgsConstructor + private static final class ReadAllBytesHandler implements HttpHandler { + + private final FullBytesCallback callback; + + @Override + public void handleRequest(HttpServerExchange exchange) throws Exception { + exchange.getRequestReceiver().receiveFullBytes(this::handleBytes); + } + + private void handleBytes(HttpServerExchange exchange, byte[] message) { + try { + callback.handle(exchange, message); + } catch (Throwable ex) { + exchange.setStatusCode(500); + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/plain"); + exchange.getResponseSender().send(ex.getMessage()); + } + } + } +} diff --git a/feign-form/src/test/resources/another_file.txt b/feign-form/src/test/resources/another_file.txt new file mode 100644 index 000000000..ca9df1127 --- /dev/null +++ b/feign-form/src/test/resources/another_file.txt @@ -0,0 +1,2 @@ + +Another hello! diff --git a/feign-form/src/test/resources/file.abc b/feign-form/src/test/resources/file.abc new file mode 100644 index 000000000..e69de29bb diff --git a/feign-form/src/test/resources/file.txt b/feign-form/src/test/resources/file.txt new file mode 100644 index 000000000..178a58b8d --- /dev/null +++ b/feign-form/src/test/resources/file.txt @@ -0,0 +1,2 @@ + +Hello world! diff --git a/pom.xml b/pom.xml index ec98d4b3f..e28bcd878 100644 --- a/pom.xml +++ b/pom.xml @@ -67,6 +67,8 @@ benchmark moshi fastjson2 + feign-form + feign-form-spring @@ -334,6 +336,17 @@ test + + ${project.groupId} + feign-form + ${project.version} + + + ${project.groupId} + feign-form-spring + ${project.version} + + org.junit junit-bom @@ -1071,5 +1084,16 @@ Spencer Gibb spencer@gibb.us + + Artem Labazin + xxlabaza@gmail.com + + + Tomasz Juchniewicz + tjuchniewicz@gmail.com + + + Guillaume Simard +