From 6013b93cee00d8df946778e43484625d1a53eb72 Mon Sep 17 00:00:00 2001 From: exceptionfactory Date: Fri, 16 Aug 2024 16:57:53 -0500 Subject: [PATCH] NIFI-13661 Added Multipart Form Data Builder to web-client-api This closes #9183 Signed-off-by: dan-s1 --- .../nifi/web/client/api/HttpContentType.java | 29 +++ .../api/MultipartFormDataStreamBuilder.java | 58 ++++++ .../client/api/StandardHttpContentType.java | 48 +++++ ...tandardMultipartFormDataStreamBuilder.java | 187 ++++++++++++++++++ ...ardMultipartFormDataStreamBuilderTest.java | 157 +++++++++++++++ 5 files changed, 479 insertions(+) create mode 100644 nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/HttpContentType.java create mode 100644 nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/MultipartFormDataStreamBuilder.java create mode 100644 nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/StandardHttpContentType.java create mode 100644 nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/StandardMultipartFormDataStreamBuilder.java create mode 100644 nifi-commons/nifi-web-client-api/src/test/java/org/apache/nifi/web/client/api/StandardMultipartFormDataStreamBuilderTest.java diff --git a/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/HttpContentType.java b/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/HttpContentType.java new file mode 100644 index 0000000000..b57457a2ca --- /dev/null +++ b/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/HttpContentType.java @@ -0,0 +1,29 @@ +/* + * 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. + */ +package org.apache.nifi.web.client.api; + +/** + * Content Type value for HTTP headers + */ +public interface HttpContentType { + /** + * Get Content Type value for HTTP header + * + * @return Content Type + */ + String getContentType(); +} diff --git a/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/MultipartFormDataStreamBuilder.java b/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/MultipartFormDataStreamBuilder.java new file mode 100644 index 0000000000..4595c19dab --- /dev/null +++ b/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/MultipartFormDataStreamBuilder.java @@ -0,0 +1,58 @@ +/* + * 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. + */ +package org.apache.nifi.web.client.api; + +import java.io.InputStream; + +/** + * Multipart Form Data Stream Builder supports construction of an Input Stream with form-data sections according to RFC 7578 + */ +public interface MultipartFormDataStreamBuilder { + /** + * Build Input Stream based on current component elements + * + * @return Input Stream + */ + InputStream build(); + + /** + * Get Content-Type Header value containing multipart/form-data with boundary + * + * @return Multipart HTTP Content-Type + */ + HttpContentType getHttpContentType(); + + /** + * Add Part using specified Name with Content-Type and Stream + * + * @param name Name field of part to be added + * @param httpContentType Content-Type of part to be added + * @param inputStream Stream content of part to be added + * @return Builder + */ + MultipartFormDataStreamBuilder addPart(String name, HttpContentType httpContentType, InputStream inputStream); + + /** + * Add Part using specified Name with Content-Type and byte array + * + * @param name Name field of part to be added + * @param httpContentType Content-Type of part to be added + * @param bytes Byte array content of part to be added + * @return Builder + */ + MultipartFormDataStreamBuilder addPart(String name, HttpContentType httpContentType, byte[] bytes); +} diff --git a/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/StandardHttpContentType.java b/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/StandardHttpContentType.java new file mode 100644 index 0000000000..bbc2fbde58 --- /dev/null +++ b/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/StandardHttpContentType.java @@ -0,0 +1,48 @@ +/* + * 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. + */ +package org.apache.nifi.web.client.api; + +/** + * Enumeration of standard registered Content Types applicable to most HTTP requests and responses + */ +public enum StandardHttpContentType implements HttpContentType { + /** Defined in RFC 8259 */ + APPLICATION_JSON("application/json"), + + /** Defined in RFC 2046 */ + APPLICATION_OCTET_STREAM("application/octet-stream"), + + /** Defined in RFF 7303 */ + APPLICATION_XML("application/xml"), + + /** Defined according to W3C */ + TEXT_HTML("text/html"), + + /** Defined in RFC 2046 */ + TEXT_PLAIN("text/plain"); + + private final String contentType; + + StandardHttpContentType(final String contentType) { + this.contentType = contentType; + } + + @Override + public String getContentType() { + return contentType; + } +} diff --git a/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/StandardMultipartFormDataStreamBuilder.java b/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/StandardMultipartFormDataStreamBuilder.java new file mode 100644 index 0000000000..3032db2b0e --- /dev/null +++ b/nifi-commons/nifi-web-client-api/src/main/java/org/apache/nifi/web/client/api/StandardMultipartFormDataStreamBuilder.java @@ -0,0 +1,187 @@ +/* + * 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. + */ +package org.apache.nifi.web.client.api; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.SequenceInputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Standard implementation of Multipart Form Data Stream Builder supporting form-data as described in RFC 7578 + */ +public class StandardMultipartFormDataStreamBuilder implements MultipartFormDataStreamBuilder { + private static final String CONTENT_DISPOSITION_HEADER = "Content-Disposition: form-data; name=\"%s\""; + + private static final String CONTENT_TYPE_HEADER = "Content-Type: %s"; + + private static final Pattern ALLOWED_NAME_PATTERN = Pattern.compile("^\\p{ASCII}+$"); + + private static final String CARRIAGE_RETURN_LINE_FEED = "\r\n"; + + private static final String BOUNDARY_SEPARATOR = "--"; + + private static final String BOUNDARY_FORMAT = "FormDataBoundary-%s"; + + private static final String MULTIPART_FORM_DATA_FORMAT = "multipart/form-data; boundary=\"%s\""; + + private static final Charset HEADERS_CHARACTER_SET = StandardCharsets.US_ASCII; + + private final String boundary = BOUNDARY_FORMAT.formatted(UUID.randomUUID()); + + private final List parts = new ArrayList<>(); + + /** + * Build Sequence Input Stream from collection of Form Data Parts formatted with boundaries + * + * @return Input Stream + */ + @Override + public InputStream build() { + if (parts.isEmpty()) { + throw new IllegalStateException("Parts required"); + } + + final List partInputStreams = new ArrayList<>(); + + final Iterator selectedParts = parts.iterator(); + while (selectedParts.hasNext()) { + final Part part = selectedParts.next(); + final String footer = getFooter(selectedParts); + + final InputStream partInputStream = getPartInputStream(part, footer); + partInputStreams.add(partInputStream); + } + + final Enumeration enumeratedPartInputStreams = Collections.enumeration(partInputStreams); + return new SequenceInputStream(enumeratedPartInputStreams); + } + + /** + * Get Content-Type Header value containing multipart/form-data with boundary + * + * @return Multipart HTTP Content-Type + */ + @Override + public HttpContentType getHttpContentType() { + final String contentType = MULTIPART_FORM_DATA_FORMAT.formatted(boundary); + return new MultipartHttpContentType(contentType); + } + + /** + * Add Part with field name and stream source + * + * @param name Name field of part to be added + * @param httpContentType Content-Type of part to be added + * @param inputStream Stream content of part to be added + * @return Builder + */ + @Override + public MultipartFormDataStreamBuilder addPart(final String name, final HttpContentType httpContentType, final InputStream inputStream) { + Objects.requireNonNull(name, "Name required"); + Objects.requireNonNull(httpContentType, "Content Type required"); + Objects.requireNonNull(inputStream, "Input Stream required"); + + final Matcher nameMatcher = ALLOWED_NAME_PATTERN.matcher(name); + if (nameMatcher.matches()) { + final Part part = new Part(name, httpContentType, inputStream); + parts.add(part); + } else { + throw new IllegalArgumentException("Name contains characters outside of ASCII character set"); + } + + return this; + } + + /** + * Add Part with field name and byte array source + * + * @param name Name field of part to be added + * @param httpContentType Content-Type of part to be added + * @param bytes Byte array content of part to be added + * @return Builder + */ + @Override + public MultipartFormDataStreamBuilder addPart(final String name, final HttpContentType httpContentType, final byte[] bytes) { + Objects.requireNonNull(bytes, "Byte Array required"); + final InputStream inputStream = new ByteArrayInputStream(bytes); + return addPart(name, httpContentType, inputStream); + } + + private InputStream getPartInputStream(final Part part, final String footer) { + final String partHeaders = getPartHeaders(part); + final InputStream headersInputStream = new ByteArrayInputStream(partHeaders.getBytes(HEADERS_CHARACTER_SET)); + final InputStream footerInputStream = new ByteArrayInputStream(footer.getBytes(HEADERS_CHARACTER_SET)); + final Enumeration inputStreams = Collections.enumeration(List.of(headersInputStream, part.inputStream, footerInputStream)); + return new SequenceInputStream(inputStreams); + } + + private String getPartHeaders(final Part part) { + final StringBuilder headersBuilder = new StringBuilder(); + + final String contentDispositionHeader = CONTENT_DISPOSITION_HEADER.formatted(part.name); + headersBuilder.append(contentDispositionHeader); + headersBuilder.append(CARRIAGE_RETURN_LINE_FEED); + + final String contentType = part.httpContentType.getContentType(); + final String contentTypeHeader = CONTENT_TYPE_HEADER.formatted(contentType); + headersBuilder.append(contentTypeHeader); + headersBuilder.append(CARRIAGE_RETURN_LINE_FEED); + + headersBuilder.append(CARRIAGE_RETURN_LINE_FEED); + return headersBuilder.toString(); + } + + private String getFooter(final Iterator selectedParts) { + final StringBuilder footerBuilder = new StringBuilder(); + footerBuilder.append(CARRIAGE_RETURN_LINE_FEED); + footerBuilder.append(BOUNDARY_SEPARATOR); + footerBuilder.append(boundary); + if (selectedParts.hasNext()) { + footerBuilder.append(CARRIAGE_RETURN_LINE_FEED); + } else { + // Add boundary separator after last part indicating end + footerBuilder.append(BOUNDARY_SEPARATOR); + } + + return footerBuilder.toString(); + } + + private record MultipartHttpContentType(String contentType) implements HttpContentType { + @Override + public String getContentType() { + return contentType; + } + } + + private record Part( + String name, + HttpContentType httpContentType, + InputStream inputStream + ) { + } +} diff --git a/nifi-commons/nifi-web-client-api/src/test/java/org/apache/nifi/web/client/api/StandardMultipartFormDataStreamBuilderTest.java b/nifi-commons/nifi-web-client-api/src/test/java/org/apache/nifi/web/client/api/StandardMultipartFormDataStreamBuilderTest.java new file mode 100644 index 0000000000..51df9ab38f --- /dev/null +++ b/nifi-commons/nifi-web-client-api/src/test/java/org/apache/nifi/web/client/api/StandardMultipartFormDataStreamBuilderTest.java @@ -0,0 +1,157 @@ +/* + * 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. + */ +package org.apache.nifi.web.client.api; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class StandardMultipartFormDataStreamBuilderTest { + + private static final Pattern MUTLIPART_FORM_DATA_PATTERN = Pattern.compile("^multipart/form-data; boundary=\"([a-zA-Z0-9-]+)\"$"); + + private static final int BOUNDARY_GROUP = 1; + + private static final String PART_BOUNDARY = "\r\n--%s\r\n"; + + private static final String LAST_BOUNDARY = "\r\n--%s--"; + + private static final String UPLOADED = "uploaded"; + + private static final String FIELD = "field"; + + private static final String CONTENT_DISPOSITION_HEADER = "Content-Disposition: form-data; name=\"%s\"\r\n"; + + private static final Charset HEADERS_CHARACTER_SET = StandardCharsets.US_ASCII; + + private static final byte[] UNICODE_ENCODED = new byte[]{-50, -111, -50, -87}; + + private static final String UNICODE_STRING = new String(UNICODE_ENCODED, StandardCharsets.UTF_8); + + private StandardMultipartFormDataStreamBuilder builder; + + @BeforeEach + void setBuilder() { + builder = new StandardMultipartFormDataStreamBuilder(); + } + + @Test + void testGetHttpContentType() { + final HttpContentType httpContentType = builder.getHttpContentType(); + final String contentType = httpContentType.getContentType(); + + assertNotNull(contentType); + final Matcher matcher = MUTLIPART_FORM_DATA_PATTERN.matcher(contentType); + assertTrue(matcher.matches()); + } + + @Test + void testAddPartNameDisallowed() { + final String uploaded = String.class.getName(); + final byte[] uploadedBytes = uploaded.getBytes(StandardCharsets.UTF_8); + + assertThrows(IllegalArgumentException.class, () -> builder.addPart(UNICODE_STRING, StandardHttpContentType.APPLICATION_OCTET_STREAM, uploadedBytes)); + } + + @Test + void testBuildException() { + assertThrows(IllegalStateException.class, builder::build); + } + + @Test + void testBuildTextPlain() throws IOException { + final String value = String.class.getName(); + final byte[] bytes = value.getBytes(HEADERS_CHARACTER_SET); + + final InputStream inputStream = builder.addPart(UPLOADED, StandardHttpContentType.TEXT_PLAIN, bytes).build(); + + final String body = readInputStream(inputStream); + assertContentDispositionFound(body, UPLOADED); + + final String boundary = getBoundary(builder); + assertLastBoundaryFound(body, boundary); + + assertTrue(body.contains(value)); + } + + @Test + void testBuildMultipleParts() throws IOException { + final String uploaded = String.class.getName(); + final byte[] uploadedBytes = uploaded.getBytes(StandardCharsets.UTF_8); + final InputStream inputStream = new ByteArrayInputStream(uploadedBytes); + builder.addPart(UPLOADED, StandardHttpContentType.APPLICATION_OCTET_STREAM, inputStream); + + final String field = Integer.class.getName(); + final byte[] fieldBytes = field.getBytes(HEADERS_CHARACTER_SET); + builder.addPart(FIELD, StandardHttpContentType.TEXT_PLAIN, fieldBytes); + + final InputStream stream = builder.build(); + + final String body = readInputStream(stream); + assertContentDispositionFound(body, UPLOADED); + assertContentDispositionFound(body, FIELD); + + final String boundary = getBoundary(builder); + assertPartBoundaryFound(body, boundary); + assertLastBoundaryFound(body, boundary); + + assertTrue(body.contains(uploaded)); + assertTrue(body.contains(field)); + } + + private void assertContentDispositionFound(final String body, final String name) { + final String contentDispositionHeader = CONTENT_DISPOSITION_HEADER.formatted(name); + assertTrue(body.contains(contentDispositionHeader)); + } + + private void assertPartBoundaryFound(final String body, final String boundary) { + final String partBoundary = PART_BOUNDARY.formatted(boundary); + assertTrue(body.contains(partBoundary)); + } + + private void assertLastBoundaryFound(final String body, final String boundary) { + final String lastBoundary = LAST_BOUNDARY.formatted(boundary); + assertTrue(body.endsWith(lastBoundary)); + } + + private String getBoundary(final MultipartFormDataStreamBuilder builder) { + final HttpContentType httpContentType = builder.getHttpContentType(); + final String contentType = httpContentType.getContentType(); + final Matcher boundaryMatcher = MUTLIPART_FORM_DATA_PATTERN.matcher(contentType); + assertTrue(boundaryMatcher.matches()); + return boundaryMatcher.group(BOUNDARY_GROUP); + } + + private String readInputStream(final InputStream inputStream) throws IOException { + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + inputStream.transferTo(outputStream); + inputStream.close(); + return outputStream.toString(); + } +}