This is an automated email from the ASF dual-hosted git repository.
dstiggy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 6013b93cee NIFI-13661 Added Multipart Form Data Builder to
web-client-api This closes #9183
6013b93cee is described below
commit 6013b93cee00d8df946778e43484625d1a53eb72
Author: exceptionfactory <[email protected]>
AuthorDate: Fri Aug 16 16:57:53 2024 -0500
NIFI-13661 Added Multipart Form Data Builder to web-client-api
This closes #9183
Signed-off-by: dan-s1 <[email protected]>
---
.../nifi/web/client/api/HttpContentType.java | 29 ++++
.../client/api/MultipartFormDataStreamBuilder.java | 58 +++++++
.../web/client/api/StandardHttpContentType.java | 48 ++++++
.../StandardMultipartFormDataStreamBuilder.java | 187 +++++++++++++++++++++
...StandardMultipartFormDataStreamBuilderTest.java | 157 +++++++++++++++++
5 files changed, 479 insertions(+)
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<Part> 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<InputStream> partInputStreams = new ArrayList<>();
+
+ final Iterator<Part> 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<InputStream> 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<InputStream> 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<Part> 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();
+ }
+}