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();
+    }
+}

Reply via email to