diff --git a/vividus-allure-adaptor/src/main/resources/allure-customization/webjars/bootstrap/3.4.1/fonts/clipboard.svg b/vividus-allure-adaptor/src/main/resources/allure-customization/webjars/bootstrap/3.4.1/fonts/clipboard.svg new file mode 100644 index 0000000000..b92f42a5bc --- /dev/null +++ b/vividus-allure-adaptor/src/main/resources/allure-customization/webjars/bootstrap/3.4.1/fonts/clipboard.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/vividus-plugin-rest-api/src/main/java/org/vividus/http/CurlUtils.java b/vividus-plugin-rest-api/src/main/java/org/vividus/http/CurlUtils.java new file mode 100644 index 0000000000..14cedda1c5 --- /dev/null +++ b/vividus-plugin-rest-api/src/main/java/org/vividus/http/CurlUtils.java @@ -0,0 +1,101 @@ +/* + * Copyright 2019-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 + * + * https://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 org.vividus.http; + +import java.net.URISyntaxException; +import java.nio.charset.Charset; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpRequest; + +public final class CurlUtils +{ + private static final Pattern NAME_FILE_NAME_PATTERN = Pattern.compile("name=\"(.+)\"; filename=\"(.+)\""); + private static final Pattern NAME_CONTENT_PATTERN = Pattern.compile("name=\"(.+)\"\nContent-Type:.+\n\n(.*)"); + private static final String SINGLE_QUOTE = "'"; + private static final String END_OF_LINE = " \\\n"; + private static final String DOUBLE_QUOTE = "\""; + + private CurlUtils() + { + } + + public static String buildCurlCommand(HttpRequest request, String mimeType, + Charset charset, byte[] body) throws URISyntaxException + { + StringBuilder curlCommand = new StringBuilder("curl "); + appendMethodAndUri(curlCommand, request); + appendHeaders(curlCommand, request.getHeaders()); + if (body != null) + { + appendBody(curlCommand, mimeType, charset, body); + } + return curlCommand.toString(); + } + + private static void appendMethodAndUri(StringBuilder curlCommand, HttpRequest request) throws URISyntaxException + { + curlCommand.append("-X ").append(request.getMethod()).append(" '") + .append(request.getUri()).append(SINGLE_QUOTE); + } + + private static void appendHeaders(StringBuilder curlCommand, Header[] headers) + { + Stream.of(headers).forEach(h -> curlCommand.append(END_OF_LINE) + .append("-H '").append(h.getName()).append(": ").append(h.getValue()).append(SINGLE_QUOTE)); + } + + private static void appendBody(StringBuilder curlCommand, String mimeType, Charset charset, byte[] body) + { + String bodyAsString = new String(body, charset); + if (mimeType.contains("multipart")) + { + appendMultipartData(curlCommand, bodyAsString); + return; + } + curlCommand.append(END_OF_LINE).append("-d '").append(bodyAsString).append(SINGLE_QUOTE); + } + + private static void appendMultipartData(StringBuilder curlCommand, String bodyAsString) + { + String regex = bodyAsString.split("\\R", 2)[0]; + String[] formDataArray = bodyAsString.split(regex + ".*"); + + Stream.of(formDataArray).forEach(e -> + { + Matcher matcher = NAME_FILE_NAME_PATTERN.matcher(e); + String formStringStart = "-F \""; + if (matcher.find()) + { + curlCommand.append(END_OF_LINE).append(formStringStart).append(matcher.group(1)) + .append("=@").append(matcher.group(2)).append(DOUBLE_QUOTE); + } + else + { + matcher = NAME_CONTENT_PATTERN.matcher(e); + if (matcher.find()) + { + curlCommand.append(END_OF_LINE).append(formStringStart).append(matcher.group(1)) + .append("=").append(matcher.group(2)).append(DOUBLE_QUOTE); + } + } + }); + } +} diff --git a/vividus-plugin-rest-api/src/main/java/org/vividus/http/PublishingAttachmentInterceptor.java b/vividus-plugin-rest-api/src/main/java/org/vividus/http/PublishingAttachmentInterceptor.java index 02373c8701..2e69ab2c8a 100644 --- a/vividus-plugin-rest-api/src/main/java/org/vividus/http/PublishingAttachmentInterceptor.java +++ b/vividus-plugin-rest-api/src/main/java/org/vividus/http/PublishingAttachmentInterceptor.java @@ -1,5 +1,5 @@ /* - * Copyright 2019-2023 the original author or authors. + * Copyright 2019-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. @@ -18,6 +18,8 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; @@ -30,6 +32,7 @@ import org.apache.hc.core5.http.HttpEntityContainer; import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.HttpRequestInterceptor; +import org.apache.hc.core5.http.io.entity.ByteArrayEntity; import org.apache.hc.core5.http.protocol.HttpContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -53,6 +56,7 @@ public void process(HttpRequest request, EntityDetails entityDetails, HttpContex { byte[] body = null; String mimeType = null; + Charset charset = StandardCharsets.UTF_8; if (request instanceof HttpEntityContainer httpEntityContainer) { HttpEntity entity = httpEntityContainer.getEntity(); @@ -63,6 +67,10 @@ public void process(HttpRequest request, EntityDetails entityDetails, HttpContex Optional.ofNullable(ContentType.parseLenient(entity.getContentType())) .orElse(ContentType.DEFAULT_TEXT).getMimeType() ); + if (entity instanceof ByteArrayEntity) + { + charset = StandardCharsets.US_ASCII; + } try (ByteArrayOutputStream baos = new ByteArrayOutputStream((int) entity.getContentLength())) { // https://github.com/apache/httpcomponents-client/commit/09cefc2b8970eea56d81b1a886d9bb769a48daf3 @@ -75,25 +83,37 @@ public void process(HttpRequest request, EntityDetails entityDetails, HttpContex } } } - attachApiMessage("Request: " + request, request.getHeaders(), body, mimeType, -1); + String curlCommand = null; + try + { + curlCommand = CurlUtils.buildCurlCommand(request, mimeType, charset, body); + } + catch (URISyntaxException e) + { + LOGGER.error("Error is occurred on building cURL command", e); + } + attachApiMessage("Request: " + request, request.getHeaders(), body, mimeType, -1, curlCommand); } @Override - public void handle(HttpResponse response) throws IOException + public void handle(HttpResponse response) { Header[] headers = response.getResponseHeaders(); String attachmentTitle = String.format("Response: %s %s", response.getMethod(), response.getFrom()); String mimeType = MimeTypeUtils.getMimeTypeFromHeadersWithDefault(headers); - attachApiMessage(attachmentTitle, headers, response.getResponseBody(), mimeType, response.getStatusCode()); + attachApiMessage(attachmentTitle, headers, response.getResponseBody(), mimeType, + response.getStatusCode(), null); } - private void attachApiMessage(String title, Header[] headers, byte[] body, String mimeType, int statusCode) + private void attachApiMessage(String title, Header[] headers, byte[] body, String mimeType, + int statusCode, String curlCommand) { Map dataMap = new HashMap<>(); dataMap.put("headers", headers); dataMap.put("body", body != null ? new String(body, StandardCharsets.UTF_8) : null); dataMap.put("bodyContentType", mimeType); dataMap.put("statusCode", statusCode); + dataMap.put("curlCommand", curlCommand); attachmentPublisher.publishAttachment("/org/vividus/http/attachment/api-message.ftl", dataMap, title); } diff --git a/vividus-plugin-rest-api/src/main/resources/org/vividus/http/attachment/api-message.ftl b/vividus-plugin-rest-api/src/main/resources/org/vividus/http/attachment/api-message.ftl index 755e39dea2..bcacda0eab 100644 --- a/vividus-plugin-rest-api/src/main/resources/org/vividus/http/attachment/api-message.ftl +++ b/vividus-plugin-rest-api/src/main/resources/org/vividus/http/attachment/api-message.ftl @@ -94,6 +94,21 @@ + + <#if curlCommand??> +
+
+

+ +

+
+
+
+
${curlCommand}
+
+
+
+ @@ -110,6 +125,11 @@ hljs.highlightElement(e); }); }); + + function copyCurlCommand() { + var text = document.querySelector('#collapse-curl code').textContent; + navigator.clipboard.writeText(text); + } diff --git a/vividus-plugin-rest-api/src/test/java/org/vividus/http/CurlUtilsTests.java b/vividus-plugin-rest-api/src/test/java/org/vividus/http/CurlUtilsTests.java new file mode 100644 index 0000000000..936c68bb75 --- /dev/null +++ b/vividus-plugin-rest-api/src/test/java/org/vividus/http/CurlUtilsTests.java @@ -0,0 +1,106 @@ +/* + * Copyright 2019-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 + * + * https://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 org.vividus.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.vividus.http.HttpMethod.GET; +import static org.vividus.http.HttpMethod.POST; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.message.BasicHeader; +import org.apache.hc.core5.http.message.BasicHttpRequest; +import org.junit.jupiter.api.Test; + +class CurlUtilsTests +{ + private static final String ANY = "any"; + private static final Header[] EMPTY_HEADERS = {}; + private static final Charset CHARSET = StandardCharsets.UTF_8; + + @Test + void testRequestWithHeadersAndWithoutBody() throws URISyntaxException + { + BasicHttpRequest request = mock(); + when(request.getMethod()).thenReturn(GET.name()); + when(request.getUri()).thenReturn(new URI("http://get.example.org/")); + Header[] requestHeaders = + { + new BasicHeader("Connection", "keep-alive"), + new BasicHeader("Host", "example.org") + }; + when(request.getHeaders()).thenReturn(requestHeaders); + + assertEquals(CurlUtils.buildCurlCommand(request, ANY, CHARSET, null), + """ + curl -X GET 'http://get.example.org/' \\ + -H 'Connection: keep-alive' \\ + -H 'Host: example.org'"""); + } + + @Test + void testRequestWithHeadersAndWithBody() throws URISyntaxException + { + BasicHttpRequest request = mock(); + when(request.getMethod()).thenReturn(POST.name()); + when(request.getUri()).thenReturn(new URI("http://post.example.org/")); + when(request.getHeaders()).thenReturn(EMPTY_HEADERS); + + assertEquals(CurlUtils.buildCurlCommand(request, ANY, CHARSET, "post body".getBytes(CHARSET)), + """ + curl -X POST 'http://post.example.org/' \\ + -d 'post body'"""); + } + + @Test + void testRequestWithFormBody() throws URISyntaxException + { + BasicHttpRequest request = mock(); + when(request.getMethod()).thenReturn(POST.name()); + when(request.getUri()).thenReturn(new URI("http://post.form.data.example.org/")); + when(request.getHeaders()).thenReturn(EMPTY_HEADERS); + + // CHECKSTYLE:OFF + String formDataBody = """ + --Bbg5_2qfo5RoGjrGzlpR2MFzlAqzj2ie49bp7 + Content-Disposition: form-data; name="string-key" + Content-Type: text/plain + + string1 + --Bbg5_2qfo5RoGjrGzlpR2MFzlAqzj2ie49bp7 + Content-Disposition: form-data; name="binary-key"; filename="raw.txt" + Content-Type: text/plain + + raw + --Bbg5_2qfo5RoGjrGzlpR2MFzlAqzj2ie49bp7-- + """; + // CHECKSTYLE:ON + + assertEquals(CurlUtils.buildCurlCommand(request, "multipart/form-data", + CHARSET, formDataBody.getBytes(CHARSET)), + """ + curl -X POST 'http://post.form.data.example.org/' \\ + -F "string-key=string1" \\ + -F "binary-key=@raw.txt\""""); + } +} diff --git a/vividus-plugin-rest-api/src/test/java/org/vividus/http/PublishingAttachmentInterceptorTests.java b/vividus-plugin-rest-api/src/test/java/org/vividus/http/PublishingAttachmentInterceptorTests.java index 570b2f9cf1..64994a724c 100644 --- a/vividus-plugin-rest-api/src/test/java/org/vividus/http/PublishingAttachmentInterceptorTests.java +++ b/vividus-plugin-rest-api/src/test/java/org/vividus/http/PublishingAttachmentInterceptorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2019-2023 the original author or authors. + * Copyright 2019-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. @@ -33,6 +33,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.URI; +import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.List; @@ -49,6 +50,7 @@ import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.io.entity.ByteArrayEntity; import org.apache.hc.core5.http.message.BasicClassicHttpRequest; import org.apache.hc.core5.http.message.BasicHeader; import org.apache.hc.core5.http.protocol.HttpContext; @@ -75,6 +77,7 @@ class PublishingAttachmentInterceptorTests private static final String CONTENT_TYPE = "Content-Type"; private static final String TEXT_PLAIN = "text/plain"; private static final byte[] DATA = "data".getBytes(StandardCharsets.UTF_8); + private static final String SPACE = " "; @Mock private IAttachmentPublisher attachmentPublisher; @InjectMocks private PublishingAttachmentInterceptor interceptor; @@ -104,7 +107,7 @@ void testHttpRequestWithNullBodyIsAttachedSuccessfully() void testHttpRequestIsAttachedSuccessfullyWhenContentTypeIsSet(String contentTypeHeaderName) throws IOException { Header contentTypeHeader = new BasicHeader(contentTypeHeaderName, TEXT_PLAIN); - var httpEntity = mock(HttpEntity.class); + var httpEntity = mock(ByteArrayEntity.class); testHttpRequestIsAttachedSuccessfully(contentTypeHeader, httpEntity); verify(httpEntity).getContentLength(); verify(httpEntity).writeTo(any(ByteArrayOutputStream.class)); @@ -135,7 +138,7 @@ void testNoHttpRequestBodyIsAttached() { HttpRequest httpRequest = mock(); when(httpRequest.getHeaders()).thenReturn(new Header[] {}); - when(httpRequest.toString()).thenReturn(METHOD + " " + ENDPOINT); + when(httpRequest.toString()).thenReturn(METHOD + SPACE + ENDPOINT); testNoHttpRequestBodyIsAttached(httpRequest, empty()); } @@ -177,6 +180,19 @@ void testNoHttpResponseBodyIsAttached() throws IOException verifyPublishAttachment(RESPONSE); } + @Test + void testCurlBuildIsFailed() throws URISyntaxException + { + HttpRequest httpRequest = mock(); + when(httpRequest.getHeaders()).thenReturn(new Header[] {}); + when(httpRequest.toString()).thenReturn(METHOD + SPACE + ENDPOINT); + String empty = ""; + var exception = new URISyntaxException(empty, empty); + when(httpRequest.getUri()).thenThrow(exception); + testNoHttpRequestBodyIsAttached(httpRequest, + equalTo(List.of(error(exception, "Error is occurred on building cURL command")))); + } + private void testHttpRequestIsAttachedSuccessfully(Header contentTypeHeader, HttpEntity httpEntity) { var httpRequest = createClassicHttpRequest(new Header[] { contentTypeHeader }, httpEntity);