Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/main/java/io/vertx/openapi/contract/impl/ServerImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
import io.vertx.core.json.JsonObject;
import io.vertx.openapi.contract.Server;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URI;
import java.net.URISyntaxException;

public class ServerImpl implements Server {

Expand All @@ -37,8 +38,11 @@ public ServerImpl(JsonObject serverModel) {
throw createUnsupportedFeature("Server Variables");
}
try {
this.basePath = new URL(url.endsWith("/") ? url.substring(0, url.length() - 1) : url).getPath();
} catch (MalformedURLException e) {
URI uri = new URI(url.endsWith("/") ? url.substring(0, url.length() - 1) : url);
// The URL may be relative, to indicate that the host location is relative to the location where
// the document containing the Server Object is being served.
this.basePath = uri.isAbsolute() ? uri.toURL().getPath() : uri.getPath();
} catch (URISyntaxException | MalformedURLException | IllegalArgumentException e) {
throw createInvalidContract("The specified URL is malformed: " + url, e);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public void checkSyntacticalCorrectness() {
throw new ValidatorException(msg, MISSING_REQUIRED_PARAMETER);
}

parts = MultipartPart.fromMultipartBody(content.toString(), boundary);
parts = MultipartPart.fromMultipartBody(content, boundary);
}

@Override
Expand Down
87 changes: 71 additions & 16 deletions src/main/java/io/vertx/openapi/mediatype/impl/MultipartPart.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import io.vertx.core.buffer.Buffer;
import io.vertx.openapi.validation.ValidatorException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
Expand All @@ -29,49 +30,103 @@ public class MultipartPart {
CASE_INSENSITIVE);
private static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile("Content-Type: (.*)", CASE_INSENSITIVE);

private static final byte[] HEADER_SECTION_DELIMITER = { '\r', '\n', '\r', '\n' };

private final String name;
private final String contentType;
private final Buffer body;

// Should only be called by MultipartPartFormTransformer
static List<MultipartPart> fromMultipartBody(String body, String boundary) {
// Should only be called by MultipartFormAnalyser
static List<MultipartPart> fromMultipartBody(Buffer body, String boundary) {
return parseParts(body, boundary).stream().map(MultipartPart::parsePart).collect(Collectors.toList());
}

// VisibleForTesting
public static List<String> parseParts(String body, String boundary) {
String delimiter = "--" + boundary + "|\r\n--" + boundary;
String[] rawParts = body.split(delimiter);
public static List<Buffer> parseParts(Buffer body, String boundary) {
// The parts must be split on byte level, because bodies of binary parts could contain byte sequences
// that are invalid in UTF-8 and would get corrupted by a String round trip.
byte[] bytes = body.getBytes();
byte[] delimiter = ("--" + boundary).getBytes(StandardCharsets.US_ASCII);
byte[] delimiterWithLineBreak = ("\r\n--" + boundary).getBytes(StandardCharsets.US_ASCII);

List<Buffer> rawParts = new ArrayList<>();
int segmentStart = 0;
int i = 0;
while (i < bytes.length) {
// The line break preceding the delimiter belongs to the delimiter, not to the part body.
byte[] match = matchesAt(bytes, i, delimiter) ? delimiter
: matchesAt(bytes, i, delimiterWithLineBreak) ? delimiterWithLineBreak : null;
if (match == null) {
i++;
} else {
rawParts.add(body.getBuffer(segmentStart, i));
i += match.length;
segmentStart = i;
}
}
rawParts.add(body.getBuffer(segmentStart, bytes.length));

if (rawParts.length < 3 || !"--".equals(rawParts[rawParts.length - 1].strip())) {
if (rawParts.size() < 3 || !"--".equals(rawParts.get(rawParts.size() - 1).toString().strip())) {
String msg = "The multipart message doesn't contain any parts, or has an invalid structure.";
throw new ValidatorException(msg, INVALID_VALUE);
}

List<String> parts = new ArrayList<>(rawParts.length - 2);
List<Buffer> parts = new ArrayList<>(rawParts.size() - 2);

// Omit first and last part, because first part is everything up to the first delimiter and the last part
// contains "--";
for (int i = 1; i < rawParts.length - 1; i++) {
parts.add(rawParts[i].strip());
for (int j = 1; j < rawParts.size() - 1; j++) {
parts.add(stripLeadingWhitespace(rawParts.get(j)));
}

return parts;
}

private static boolean matchesAt(byte[] bytes, int offset, byte[] pattern) {
if (offset + pattern.length > bytes.length) {
return false;
}
for (int i = 0; i < pattern.length; i++) {
if (bytes[offset + i] != pattern[i]) {
return false;
}
}
return true;
}

private static Buffer stripLeadingWhitespace(Buffer rawPart) {
int start = 0;
while (start < rawPart.length()) {
byte b = rawPart.getByte(start);
if (b == ' ' || b == '\t' || b == '\r' || b == '\n') {
start++;
} else {
break;
}
}
return rawPart.getBuffer(start, rawPart.length());
}

private static Optional<String> parsePattern(Pattern pattern, String rawPart) {
return pattern.matcher(rawPart).results().findFirst().map(m -> m.group(1));
}

// VisibleForTesting
public static MultipartPart parsePart(String rawPart) {
String sectionDelimiterPattern = "\r\n\r\n";
int sectionDelimiter = rawPart.indexOf(sectionDelimiterPattern);
public static MultipartPart parsePart(Buffer rawPart) {
int sectionDelimiter = -1;
byte[] bytes = rawPart.getBytes();
for (int i = 0; i < bytes.length; i++) {
if (matchesAt(bytes, i, HEADER_SECTION_DELIMITER)) {
sectionDelimiter = i;
break;
}
}

// if no empty line exists, there are only headers
String headerSection = sectionDelimiter == -1 ? rawPart : rawPart.substring(0, sectionDelimiter);
String body =
sectionDelimiter == -1 ? null : rawPart.substring(sectionDelimiter + sectionDelimiterPattern.length());
String headerSection = sectionDelimiter == -1 ? rawPart.toString()
: rawPart.getBuffer(0, sectionDelimiter).toString();
Buffer body = sectionDelimiter == -1 ? null
: rawPart.getBuffer(sectionDelimiter + HEADER_SECTION_DELIMITER.length, rawPart.length());

String name = parsePattern(NAME_PATTERN, headerSection).orElseThrow(() -> {
String msg = "A part of the multipart message doesn't contain a name.";
Expand All @@ -81,7 +136,7 @@ public static MultipartPart parsePart(String rawPart) {
// If no header is set, content type defaults to text/plain
String contentType = parsePattern(CONTENT_TYPE_PATTERN, headerSection).orElse("text/plain");

return new MultipartPart(name, contentType, body == null ? null : Buffer.buffer(body));
return new MultipartPart(name, contentType, body == null || body.length() == 0 ? null : body);
}

public MultipartPart(String name, String contentType, Buffer body) {
Expand Down
24 changes: 23 additions & 1 deletion src/test/java/io/vertx/tests/contract/impl/ServerImplTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ private static Stream<Arguments> testBasePathExtraction() {
Arguments.of("https://example.com/", ""),
Arguments.of("https://example.com/foo", "/foo"),
Arguments.of("https://example.com/foo/", "/foo"),
Arguments.of("https://example.com/foo/bar", "/foo/bar"));
Arguments.of("https://example.com/foo/bar", "/foo/bar"),
Arguments.of("/", ""),
Arguments.of("/v1/api", "/v1/api"),
Arguments.of("/v1/api/", "/v1/api"),
Arguments.of("//example.com/foo", "/foo"));
}

@ParameterizedTest(name = "{index} BasePath extraction: {0} should result into {1}")
Expand All @@ -56,6 +60,17 @@ void testBasePathExtraction(String url, String basePath) {
assertThat(server.getBasePath()).isEqualTo(basePath);
}

@Test
void testRelativeUrlGetters() {
String url = "/v1/api";
JsonObject model = new JsonObject().put("url", url);
Server server = new ServerImpl(model);

assertThat(server.getOpenAPIModel()).isEqualTo(model);
assertThat(server.getURL()).isEqualTo(url);
assertThat(server.getBasePath()).isEqualTo("/v1/api");
}

@Test
void testExceptions() {
String msgUnsupported = "The passed OpenAPI contract contains a feature that is not supported: Server Variables";
Expand All @@ -72,5 +87,12 @@ void testExceptions() {
() -> new ServerImpl(new JsonObject().put("url", "http://foo.bar:-80")));
assertThat(exceptionInvalid.type()).isEqualTo(ContractErrorType.INVALID_SPEC);
assertThat(exceptionInvalid).hasMessageThat().isEqualTo(msgInvalid);

String msgInvalidRelative = "The passed OpenAPI contract is invalid: The specified URL is malformed: /v1/ api";
OpenAPIContractException exceptionInvalidRelative =
assertThrows(OpenAPIContractException.class,
() -> new ServerImpl(new JsonObject().put("url", "/v1/ api")));
assertThat(exceptionInvalidRelative.type()).isEqualTo(ContractErrorType.INVALID_SPEC);
assertThat(exceptionInvalidRelative).hasMessageThat().isEqualTo(msgInvalidRelative);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,29 @@ void testTransformOctetStream() throws IOException {
assertThat(result.getBuffer("address").toJsonObject()).isEqualTo(expected);
}

@Test
void testTransformOctetStreamWithBinaryContent() {
// Contains bytes that are invalid in UTF-8 (e.g. 0x89, 0xFF) and whitespace bytes at the edges,
// which must survive the transformation unmodified.
byte[] binaryContent = { 0x0A, 0x20, (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00,
(byte) 0xFF, (byte) 0xFE, 0x42, 0x09, 0x20, 0x0D, 0x0A };

Buffer multipartBody = Buffer.buffer()
.appendString("--abcde12345\r\n")
.appendString("Content-Disposition: form-data; name=\"file\"; filename=\"blob.bin\"\r\n")
.appendString("Content-Type: application/octet-stream\r\n")
.appendString("\r\n")
.appendBytes(binaryContent)
.appendString("\r\n--abcde12345--");
String contentType = "multipart/form-data; boundary=abcde12345";

MultipartFormAnalyser analyser = new MultipartFormAnalyser(contentType, multipartBody, REQUEST);
analyser.checkSyntacticalCorrectness(); // must always be executed before transform

JsonObject result = (JsonObject) analyser.transform();
assertThat(result.getBuffer("file")).isEqualTo(Buffer.buffer(binaryContent));
}

@Test
void testTransformContinueWhenBodyEmpty() throws IOException {
Buffer multipartBody = Buffer.buffer(Files.readString(TEST_RESOURCE_PATH.resolve("multipart_id_no_body.txt")));
Expand Down
24 changes: 14 additions & 10 deletions src/test/java/io/vertx/tests/mediatype/impl/MultipartPartTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,23 @@
class MultipartPartTest {
private static final Path TEST_RESOURCE_PATH = getRelatedTestResourcePath(MultipartPartTest.class);

private static Buffer readResource(String file) throws IOException {
return Buffer.buffer(Files.readAllBytes(TEST_RESOURCE_PATH.resolve(file)));
}

@Test
void testParseParts() throws IOException {
String part1 = Files.readString(TEST_RESOURCE_PATH.resolve("part1.txt"));
String part2 = Files.readString(TEST_RESOURCE_PATH.resolve("part2.txt"));
Buffer part1 = readResource("part1.txt");
Buffer part2 = readResource("part2.txt");

String multipartBody = Files.readString(TEST_RESOURCE_PATH.resolve("multipart.txt"));
Buffer multipartBody = readResource("multipart.txt");
Truth.assertThat(MultipartPart.parseParts(multipartBody, "abcde12345")).containsExactly(part1, part2);
}

@ParameterizedTest
@ValueSource(strings = { "multipart_invalid_structure", "multipart_invalid_structure_2" })
void testParsePartsInvalidStructure(String file) throws IOException {
String multipartBody = Files.readString(TEST_RESOURCE_PATH.resolve(file + ".txt"));
Buffer multipartBody = readResource(file + ".txt");

ValidatorException exception =
assertThrows(ValidatorException.class, () -> MultipartPart.parseParts(multipartBody, "abcde12345"));
Expand All @@ -56,13 +60,13 @@ void testParsePartsInvalidStructure(String file) throws IOException {

@Test
void testParsePart() throws IOException {
String part1 = Files.readString(TEST_RESOURCE_PATH.resolve("part1.txt"));
Buffer part1 = readResource("part1.txt");
MultipartPart mpp1 = MultipartPart.parsePart(part1);
assertThat(mpp1.getName()).isEqualTo("id");
assertThat(mpp1.getContentType()).isEqualTo("text/plain");
assertThat(mpp1.getBody()).isEqualTo(Buffer.buffer("123e4567-e89b-12d3-a456-426655440000"));

String part2 = Files.readString(TEST_RESOURCE_PATH.resolve("part2.txt"));
Buffer part2 = readResource("part2.txt");
MultipartPart mpp2 = MultipartPart.parsePart(part2);
assertThat(mpp2.getName()).isEqualTo("address");
assertThat(mpp2.getContentType()).isEqualTo("application/json");
Expand All @@ -71,7 +75,7 @@ void testParsePart() throws IOException {
.put("city", "Hillsbery, UT");
assertThat(mpp2.getBody().toJsonObject()).isEqualTo(body);

String part3 = Files.readString(TEST_RESOURCE_PATH.resolve("part3.txt"));
Buffer part3 = readResource("part3.txt");
MultipartPart mpp3 = MultipartPart.parsePart(part3);
assertThat(mpp3.getName()).isEqualTo("randomBinary");
assertThat(mpp3.getContentType()).isEqualTo("application/octet-stream");
Expand All @@ -80,7 +84,7 @@ void testParsePart() throws IOException {

@Test
void testParsePartWithoutName() throws IOException {
String part = Files.readString(TEST_RESOURCE_PATH.resolve("part_without_name.txt"));
Buffer part = readResource("part_without_name.txt");

ValidatorException exception =
assertThrows(ValidatorException.class, () -> MultipartPart.parsePart(part));
Expand All @@ -92,7 +96,7 @@ void testParsePartWithoutName() throws IOException {

@Test
void testParsePartWithoutContentType() throws IOException {
String part = Files.readString(TEST_RESOURCE_PATH.resolve("part_without_contenttype.txt"));
Buffer part = readResource("part_without_contenttype.txt");

MultipartPart mpp = MultipartPart.parsePart(part);
assertThat(mpp.getName()).isEqualTo("id");
Expand All @@ -102,7 +106,7 @@ void testParsePartWithoutContentType() throws IOException {

@Test
void testParsePartWithoutBody() throws IOException {
String part = Files.readString(TEST_RESOURCE_PATH.resolve("part_without_body.txt"));
Buffer part = readResource("part_without_body.txt");
MultipartPart mpp = MultipartPart.parsePart(part);
assertThat(mpp.getName()).isEqualTo("id");
assertThat(mpp.getContentType()).isEqualTo("text/plain");
Expand Down