From a9abcd74bced67757e3ed0968463dfa8d53ecbd6 Mon Sep 17 00:00:00 2001 From: chuuminggg Date: Fri, 16 Oct 2026 02:09:49 +0900 Subject: [PATCH] Quote curl form values that curl would otherwise interpret Previously, the content of a part without a submitted file name was used as-is in curl's -F option. curl truncates such a value at ; and gives a leading @, <, or ( a special meaning, so the command did not send the documented content. Such values are now enclosed in double quotes. Signed-off-by: chuuminggg See gh-1077 --- .../restdocs/cli/CurlRequestSnippet.java | 10 ++- .../restdocs/cli/CurlRequestSnippetTests.java | 85 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/spring-restdocs-core/src/main/java/org/springframework/restdocs/cli/CurlRequestSnippet.java b/spring-restdocs-core/src/main/java/org/springframework/restdocs/cli/CurlRequestSnippet.java index ae341b168..b7e1387cb 100644 --- a/spring-restdocs-core/src/main/java/org/springframework/restdocs/cli/CurlRequestSnippet.java +++ b/spring-restdocs-core/src/main/java/org/springframework/restdocs/cli/CurlRequestSnippet.java @@ -138,7 +138,7 @@ private void writePartsIfNecessary(OperationRequest request, List lines) StringBuilder oneLine = new StringBuilder(); oneLine.append(String.format("-F '%s=", part.getName())); if (!StringUtils.hasText(part.getSubmittedFileName())) { - oneLine.append(escapeSingleQuotes(part.getContentAsString())); + oneLine.append(escapeSingleQuotes(quoteFormValueIfNecessary(part.getContentAsString()))); } else { oneLine.append(String.format("@%s", escapeSingleQuotes(part.getSubmittedFileName()))); @@ -159,6 +159,14 @@ private void writeContent(CliOperationRequest request, List lines) { } } + private static String quoteFormValueIfNecessary(String value) { + if (value.startsWith("@") || value.startsWith("<") || value.startsWith("(") || value.startsWith("\"") + || value.contains(";")) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + return value; + } + private static String escapeSingleQuotes(String input) { return input.replace("'", "'\\''"); } diff --git a/spring-restdocs-core/src/test/java/org/springframework/restdocs/cli/CurlRequestSnippetTests.java b/spring-restdocs-core/src/test/java/org/springframework/restdocs/cli/CurlRequestSnippetTests.java index 470b3d941..003a749f4 100644 --- a/spring-restdocs-core/src/test/java/org/springframework/restdocs/cli/CurlRequestSnippetTests.java +++ b/spring-restdocs-core/src/test/java/org/springframework/restdocs/cli/CurlRequestSnippetTests.java @@ -253,6 +253,91 @@ void multipartPostWithNoSubmittedFileName(OperationBuilder operationBuilder, Ass .isCodeBlock((codeBlock) -> codeBlock.withLanguage("bash").content(expectedContent)); } + @RenderedSnippetTest + void multipartPostWithNoSubmittedFileNameAndContentContainingSemicolon(OperationBuilder operationBuilder, + AssertableSnippets snippets) throws IOException { + new CurlRequestSnippet(this.commandFormatter).document(operationBuilder.request("http://localhost/upload") + .method("POST") + .header(HttpHeaders.CONTENT_TYPE, MediaType.MULTIPART_FORM_DATA_VALUE) + .part("metadata", "{\"description\": \"foo; bar\"}".getBytes()) + .build()); + String expectedContent = "$ curl 'http://localhost/upload' -i -X POST -H " + + "'Content-Type: multipart/form-data' -F " + "'metadata=\"{\\\"description\\\": \\\"foo; bar\\\"}\"'"; + assertThat(snippets.curlRequest()) + .isCodeBlock((codeBlock) -> codeBlock.withLanguage("bash").content(expectedContent)); + } + + @RenderedSnippetTest + void multipartPostWithNoSubmittedFileNameAndContentStartingWithAt(OperationBuilder operationBuilder, + AssertableSnippets snippets) throws IOException { + new CurlRequestSnippet(this.commandFormatter).document(operationBuilder.request("http://localhost/upload") + .method("POST") + .header(HttpHeaders.CONTENT_TYPE, MediaType.MULTIPART_FORM_DATA_VALUE) + .part("mention", "@alice".getBytes()) + .build()); + String expectedContent = "$ curl 'http://localhost/upload' -i -X POST -H " + + "'Content-Type: multipart/form-data' -F " + "'mention=\"@alice\"'"; + assertThat(snippets.curlRequest()) + .isCodeBlock((codeBlock) -> codeBlock.withLanguage("bash").content(expectedContent)); + } + + @RenderedSnippetTest + void multipartPostWithNoSubmittedFileNameAndContentStartingWithLessThan(OperationBuilder operationBuilder, + AssertableSnippets snippets) throws IOException { + new CurlRequestSnippet(this.commandFormatter).document(operationBuilder.request("http://localhost/upload") + .method("POST") + .header(HttpHeaders.CONTENT_TYPE, MediaType.MULTIPART_FORM_DATA_VALUE) + .part("note", "bold".getBytes()) + .build()); + String expectedContent = "$ curl 'http://localhost/upload' -i -X POST -H " + + "'Content-Type: multipart/form-data' -F " + "'note=\"bold\"'"; + assertThat(snippets.curlRequest()) + .isCodeBlock((codeBlock) -> codeBlock.withLanguage("bash").content(expectedContent)); + } + + @RenderedSnippetTest + void multipartPostWithNoSubmittedFileNameAndContentStartingWithParenthesisAndContentType( + OperationBuilder operationBuilder, AssertableSnippets snippets) throws IOException { + new CurlRequestSnippet(this.commandFormatter).document(operationBuilder.request("http://localhost/upload") + .method("POST") + .header(HttpHeaders.CONTENT_TYPE, MediaType.MULTIPART_FORM_DATA_VALUE) + .part("note", "(optional)".getBytes()) + .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN_VALUE) + .build()); + String expectedContent = "$ curl 'http://localhost/upload' -i -X POST -H " + + "'Content-Type: multipart/form-data' -F " + "'note=\"(optional)\";type=text/plain'"; + assertThat(snippets.curlRequest()) + .isCodeBlock((codeBlock) -> codeBlock.withLanguage("bash").content(expectedContent)); + } + + @RenderedSnippetTest + void multipartPostWithNoSubmittedFileNameAndQuotedContentContainingBackslash(OperationBuilder operationBuilder, + AssertableSnippets snippets) throws IOException { + new CurlRequestSnippet(this.commandFormatter).document(operationBuilder.request("http://localhost/upload") + .method("POST") + .header(HttpHeaders.CONTENT_TYPE, MediaType.MULTIPART_FORM_DATA_VALUE) + .part("path", "C:\\temp;D:\\temp".getBytes()) + .build()); + String expectedContent = "$ curl 'http://localhost/upload' -i -X POST -H " + + "'Content-Type: multipart/form-data' -F " + "'path=\"C:\\\\temp;D:\\\\temp\"'"; + assertThat(snippets.curlRequest()) + .isCodeBlock((codeBlock) -> codeBlock.withLanguage("bash").content(expectedContent)); + } + + @RenderedSnippetTest + void multipartPostWithNoSubmittedFileNameAndContentThatDoesNotNeedQuoting(OperationBuilder operationBuilder, + AssertableSnippets snippets) throws IOException { + new CurlRequestSnippet(this.commandFormatter).document(operationBuilder.request("http://localhost/upload") + .method("POST") + .header(HttpHeaders.CONTENT_TYPE, MediaType.MULTIPART_FORM_DATA_VALUE) + .part("note", "say \"hi\" \\o/".getBytes()) + .build()); + String expectedContent = "$ curl 'http://localhost/upload' -i -X POST -H " + + "'Content-Type: multipart/form-data' -F " + "'note=say \"hi\" \\o/'"; + assertThat(snippets.curlRequest()) + .isCodeBlock((codeBlock) -> codeBlock.withLanguage("bash").content(expectedContent)); + } + @RenderedSnippetTest void multipartPostWithContentType(OperationBuilder operationBuilder, AssertableSnippets snippets) throws IOException {