spring-projects / spring-framework

Spring Framework
https://spring.io/projects/spring-framework
Apache License 2.0
56.21k stars 37.97k forks source link

Part.name is not used with REST HTTP Interface #33423

Open Hansanto opened 3 weeks ago

Hansanto commented 3 weeks ago

Affects:

Context

Hello

I'm trying to use HTTP Interface to send Multipart data. My goal is to send 2 part. So I write this function:

Service

public interface TestService {

    @PostExchange(value = "/", accept = MediaType.APPLICATION_JSON_VALUE)
    Mono<MyObject> create(
            @RequestPart Part a,
            @RequestPart Part b
    );
}

Part

I think we need to create our own Part (I didn't find any usable implementation), so I create one for byte[] and other for json

public class BytesPart implements Part {

    private final String name;
    private final String filename;
    private final byte[] data;

    public BytesPart(String name, byte[] data) {
        this(name, data, null);
    }

    public BytesPart(String name, byte[] data, String filename) {
        this.name = name;
        this.data = data;
        this.filename = filename;
    }

    @Override
    public String name() {
        return name;
    }

    @Override
    public HttpHeaders headers() {
        MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();

        StringBuilder contentDispositionBuilder = new StringBuilder();
        contentDispositionBuilder.append("form-data");
        if(filename != null) {
            contentDispositionBuilder.append("; filename=\"").append(filename).append("\"");
        }
        headers.add("Content-Disposition", contentDispositionBuilder.toString());

        headers.add("Content-Type", "application/octet-stream");
        headers.add("Content-Transfer-Encoding", "binary");
        return HttpHeaders.readOnlyHttpHeaders(headers);
    }

    @Override
    public Flux<DataBuffer> content() {
        return Flux.just(data).map(bytes -> new DefaultDataBufferFactory().wrap(bytes));
    }
}
public class JsonPart implements Part {

    private final String name;
    private final String filename;
    private final String json;

    public JsonPart(String name, String json) {
        this(name, json, null);
    }

    public JsonPart(String name, String json, String filename) {
        this.name = name;
        this.json = json;
        this.filename = filename;
    }

    @Override
    public String name() {
        return name;
    }

    @Override
    public HttpHeaders headers() {
        MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
        StringBuilder contentDispositionBuilder = new StringBuilder();
        contentDispositionBuilder.append("form-data");
        if(filename != null) {
            contentDispositionBuilder.append("; filename=\"").append(filename).append("\"");
        }
        headers.add("Content-Disposition", contentDispositionBuilder.toString());
        headers.add("Content-Type", "application/json; charset=UTF-8");
        return HttpHeaders.readOnlyHttpHeaders(headers);
    }

    @Override
    public Flux<DataBuffer> content() {
        return Flux.just(json).map(json -> new DefaultDataBufferFactory().wrap(json.getBytes()));
    }
}

Service usage

I'm using the service and part like that:

testService.create(
        new JsonPart("MyFileName1", "{\"test\": \"value\"}"), // Part a
        new BytesPart("MyFileName2", content, "test.pdf") // Part b
)

Requests

Result without annotation parameters

With the service's function with this definition:

Mono<MyObject> create(@RequestPart Part a, @RequestPart Part b);

The following request will be generated

Content-Type: multipart/form-data; boundary=woUHyaWlaKTKt-kuazHfdfjEsB9pxg7k_eirw9Qp
Body:
--woUHyaWlaKTKt-kuazHfdfjEsB9pxg7k_eirw9Qp
Content-Disposition: form-data; name="a"
Content-Type: application/json; charset=UTF-8

{"test": "value"}
--woUHyaWlaKTKt-kuazHfdfjEsB9pxg7k_eirw9Qp
Content-Disposition: form-data; name="b"; filename="test.pdf"
Content-Type: application/octet-stream
Content-Transfer-Encoding: binary

azerzaerzerzerazrzaereaze
--woUHyaWlaKTKt-kuazHfdfjEsB9pxg7k_eirw9Qp--

Problem

As you can see in the request, in the Content-Disposition value, the name is corresponding to the variable defined in the definition of the function and not the name from the Part values.

The function Part.name() is never used to obtain the name of the Part.

Result with annotation parameters

With the service's function with this definition:

Mono<MyObject> create(@RequestPart("test1") Part a, @RequestPart("test2") Part b);

The following request will be generated

Content-Type: multipart/form-data; boundary=woUHyaWlaKTKt-kuazHfdfjEsB9pxg7k_eirw9Qp
Body:
--woUHyaWlaKTKt-kuazHfdfjEsB9pxg7k_eirw9Qp
Content-Disposition: form-data; name="test1"
Content-Type: application/json; charset=UTF-8

{"test": "value"}
--woUHyaWlaKTKt-kuazHfdfjEsB9pxg7k_eirw9Qp
Content-Disposition: form-data; name="test2"; filename="test.pdf"
Content-Type: application/octet-stream
Content-Transfer-Encoding: binary

azerzaerzerzerazrzaereaze
--woUHyaWlaKTKt-kuazHfdfjEsB9pxg7k_eirw9Qp--

Problem

As you can see in the request, in the Content-Disposition value, the name is corresponding to the value sent in @RequestPart("XXX") for each part, but like the previous case the function Part.name() is never used to obtain the name of the Part.

Other tests

For my Part implementations, I replaced the:

public class JsonPart implements Part {
    // ...
    @Override
    public String name() {
        return name;
    }
    // ...
}

by

public class JsonPart implements Part {
    // ...
    @Override
    public String name() {
        throw new RuntimeException("Not used");
    }
    // ...
}

And as expected, no error has been thrown during the request. (Same in debug mode, the breakpoint is never used)

Expectation

The priority to define the multipart name should be:

flowchart v
    A[Part.name] -->|Is Present| B[Use Part.name]
    A -->|Is Absent| C[@RequestPart.name]
    C -->|Is Present| D[Use @RequestPart.name]
    C -->|Is Absent| E[Use signature variable name]

Part name defined

If the name in Part implementation is defined, the name should be used as name in multipart content

testService.create(
        new JsonPart("MyFileName1", "{\"test\": \"value\"}"), // Part a
        new BytesPart("MyFileName2", content, "test.pdf") // Part b
)

So the values in multipart content should be:

Content-Disposition: form-data; name="MyFileName1"
&
Content-Disposition: form-data; name="MyFileName2"; filename="test.pdf"

even if the @RequestPart.name is defined. The name from Part should override it.

Part name not defined

If the name in Part implementation is not defined, the @RequestPart.name should be used as name in multipart content

testService.create(
        new JsonPart(null, "{\"test\": \"value\"}"), // Part a
        new BytesPart(null, content, "test.pdf") // Part b
)

And the service:

public interface TestService {

    @PostExchange(value = "/", accept = MediaType.APPLICATION_JSON_VALUE)
    Mono<MyObject> create(
            @RequestPart("Name1") Part a,
            @RequestPart("Name2") Part b
    );
}

So the values in multipart content should be:

Content-Disposition: form-data; name="Name1"
&
Content-Disposition: form-data; name="Name2"; filename="test.pdf"

Not name defined

If Part.name and @RequestPart.name are not defined, the name of the variable in the function signature should be used.

testService.create(
        new JsonPart(null, "{\"test\": \"value\"}"), // Part a
        new BytesPart(null, content, "test.pdf") // Part b
)
public interface TestService {

    @PostExchange(value = "/", accept = MediaType.APPLICATION_JSON_VALUE)
    Mono<MyObject> create(
            @RequestPart Part a,
            @RequestPart Part b
    );
}

So the values in multipart content should be:

Content-Disposition: form-data; name="a"
&
Content-Disposition: form-data; name="b"; filename="test.pdf"
Hansanto commented 3 weeks ago

Also, I tried to set name="..." in header for the Content-Disposition but it's override during the request to use the @RequestParam.name or ,variable name from function signature

rstoyanchev commented 1 week ago

I'm not able to address your description in detail, because the premise at the start that you need to create a Part is incorrect. We do support Part, and that's useful if you need to make a remote call from a controller, but we don't expect you to create one.

The supported types for @RequestPart are listed in the reference docs.

Add a request part, which may be a String (form field), Resource (file part), Object (entity to be encoded, e.g. as JSON), HttpEntity (part content and headers), a Spring Part, or Reactive Streams Publisher of any of the above.

Generally I'm also not sure why using the variable name or the annotation to give the part a name is an issue.

spring-projects-issues commented 19 hours ago

If you would like us to look at this issue, please provide the requested information. If the information is not provided within the next 7 days this issue will be closed.