mirror of https://github.com/apache/nifi.git
NIFI-13661 Added Multipart Form Data Builder to web-client-api
This closes #9183 Signed-off-by: dan-s1 <dstieg1@gmail.com>
This commit is contained in:
parent
fa6e55f1de
commit
6013b93cee
|
@ -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();
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue