Hello,
I'm using Jetty 12.0.1 and observe the control character \r
in my populated model attribute. With Jetty I have this controller:
@RestController
public class FormSubmissionController {
@PostMapping(path = "/", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public OutputForm greetingSubmit(@ModelAttribute InputForm form) {
return new OutputForm(form.firstName, form.lastName);
}
public record InputForm(String firstName, String lastName) {
}
public record OutputForm(String firstName, String lastName) {
}
}
and i expect this test to pass:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class JettyFormSubmissionApplicationTests {
@Autowired
private TestRestTemplate testRestTemplate;
@Test
void test() {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("firstName", "first-name");
formData.add("lastName", "last-name");
HttpHeaders headers = new HttpHeaders();
headers.add("Content-Type", MediaType.MULTIPART_FORM_DATA_VALUE);
RequestEntity<?> requestEntity = new RequestEntity<>(formData, headers, HttpMethod.POST, URI.create("/"));
ResponseEntity<FormSubmissionController.OutputForm> result = this.testRestTemplate.exchange(requestEntity, FormSubmissionController.OutputForm.class);
assertThat(result.getBody().firstName()).isEqualTo("first-name");
assertThat(result.getBody().lastName()).isEqualTo("last-name");
}
}
This fails with Jetty 12, giving me this error message:
org.opentest4j.AssertionFailedError:
expected: "last-name"
but was: "
last-name"
With Jetty 11.0.15, it works.
I have prepared a small reproducer. You can switch back to Jetty 11 by editing the build.gradle, i've put some comments in.
Comment From: rstoyanchev
This is likely down to Jetty as we don't parse ourselves but rather access request params and parts. Do those look okay if accessed directly through the HttpServletRequest?
Comment From: mhalbritter
I get the same failure if I use the getParts()
API from HttpServletRequest
. Looks like a Jetty bug to me then:
@PostMapping(path = "/", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public OutputForm greetingSubmit(HttpServletRequest servletRequest, @ModelAttribute InputForm form) throws ServletException, IOException {
Map<String, String> parts = new HashMap<>();
for (Part part : servletRequest.getParts()) {
try (InputStream inputStream = part.getInputStream()) {
String content = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
parts.put(part.getName(), content);
}
}
return new OutputForm(parts.get("firstName"), parts.get("lastName"));
}
Comment From: mhalbritter
Hm. It looks like it's related to the sender side. I have this code:
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("firstName", "first-name");
formData.add("lastName", "last-name");
HttpHeaders headers = new HttpHeaders();
headers.add("Content-Type", MediaType.MULTIPART_FORM_DATA_VALUE);
RequestEntity<?> requestEntity = new RequestEntity<>(formData, headers, HttpMethod.POST, URI.create("/"));
ResponseEntity<FormSubmissionController.OutputForm> result = this.testRestTemplate.exchange(requestEntity, FormSubmissionController.OutputForm.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(200));
assertThat(result.getBody().firstName()).isEqualTo("first-name");
assertThat(result.getBody().lastName()).isEqualTo("last-name");
When the RestTemplate
is using JettyClientHttpRequestFactory
it fails. If it's using CustomHttpComponentsClientHttpRequestFactory
(or JdkClientHttpRequestFactory
, or SimpleClientHttpRequestFactory
), it works. It works with curl, too:
curl -v -F firstName=First -F lastName=Last http://localhost:8080
Comment From: rstoyanchev
Thanks for the further investigation. Part writing happens in FormHttpMessageConverter and is the same for any client, and has been in place for a very long time (hasn't changed recently). If the issue is reproducible with direct use of Jetty HttpClient, then it is for the Jetty issue tracker. It could be that some of the changes in https://github.com/jetty/jetty.project/issues/9076 are related to this.
Comment From: poutsma
I did some investigation of this, and—in short—still don't understand what's going on. I'm pretty sure that it is a Jetty 12 client-side bug, because of the reasons mentioned by @rstoyanchev (i.e. the FormHttpMessageConverter
has not changed, and is used by all clients). I have added some tests (0839f5b) to verify the behavior of the FormHttpMessageConverter
when used with a RestClient
, and reproduce the behavior of the sample app, but that seems to work fine, also for Jetty.
I even went as far as to see what is exactly posted by your JettyFormSubmissionApplicationTests
, by running the test against netcat
, dumping the posted bytes in a file, and comparing the bytes using a hex viewer. The results look good.
The only thing that I can think of is that it's off-by-one error, because the byte that precedes last-name
is \n
. Somehow, this character either gets duplicated, sent twice, or mixed up.
Comment From: poutsma
I fixed the issue, by buffering OutputStream.write
calls with a BufferedOutputStream
.
By looking at the dump from netcat
, I determined that each write to Jetty's OutputStreamContentSource
results in a separate HTTP chunk written. The FormHttpMessageConverter
does many writes, some even as short as 1 byte, resulting in a lot of HTTP chunks. My guess is that these chunks collided somewhere along the wire. By buffering the content into a BufferedOutputStream
, we ensure that Jetty only writes a new chunk every 1024 bytes.