Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ hs_err_pid*
vertx-json-validator.iml
.idea/
*.iml
.vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import io.vertx.openapi.mediatype.impl.ApplicationJsonAnalyser;
import io.vertx.openapi.mediatype.impl.MultipartFormAnalyser;
import io.vertx.openapi.mediatype.impl.NoOpAnalyser;
import io.vertx.openapi.mediatype.impl.XWwwFormUrlencodedAnalyser;
import io.vertx.openapi.validation.ValidationContext;

@VertxGen
Expand All @@ -43,4 +44,8 @@ static ContentAnalyserFactory noop() {
static ContentAnalyserFactory multipart() {
return MultipartFormAnalyser::new;
}

static ContentAnalyserFactory xWwwFormUrlencoded() {
return XWwwFormUrlencodedAnalyser::new;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ public interface MediaTypeRegistration {
MediaTypePredicate.ofExactTypes(DefaultMediaTypeRegistration.APPLICATION_OCTET_STREAM),
ContentAnalyserFactory.noop());

MediaTypeRegistration APPLICATION_X_WWW_FORM_URL_ENCODED = new DefaultMediaTypeRegistration(
MediaTypePredicate.ofExactTypes(DefaultMediaTypeRegistration.APPLICATION_X_WWW_FORM_URL_ENCODED),
ContentAnalyserFactory.xWwwFormUrlencoded());

MediaTypeRegistration VENDOR_SPECIFIC_JSON = new DefaultMediaTypeRegistration(
MediaTypePredicate.ofRegexp(Pattern.compile("^[^/]+/vnd\\.[\\w.-]+\\+json$").pattern()),
ContentAnalyserFactory.json());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
import java.util.List;

/**
* The MediaTypeRegistry contains all supported MediaTypes and Validators for the mediatypes. New MediaTypes can be registered
* The MediaTypeRegistry contains all supported MediaTypes and Validators for
* the mediatypes. New MediaTypes can be registered
* by providing new MediaTypeRegistrations.
*/
@VertxGen
public interface MediaTypeRegistry {
/**
* Creates a default registry with application/json, application/multipart and text/plain mediatypes registered.
* Creates a default registry with application/json, application/multipart and
* text/plain mediatypes registered.
*
* @return A registry with default options.
*/
Expand All @@ -32,6 +34,7 @@ static MediaTypeRegistry createDefault() {
.register(MediaTypeRegistration.APPLICATION_JSON)
.register(MediaTypeRegistration.MULTIPART_FORM_DATA)
.register(MediaTypeRegistration.APPLICATION_OCTET_STREAM)
.register(MediaTypeRegistration.APPLICATION_X_WWW_FORM_URL_ENCODED)
.register(MediaTypeRegistration.TEXT_PLAIN)
.register(MediaTypeRegistration.VENDOR_SPECIFIC_JSON);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class DefaultMediaTypeRegistration implements MediaTypeRegistration {
public static final String APPLICATION_OCTET_STREAM = "application/octet-stream";
public static final String TEXT_PLAIN = "text/plain";
public static final String TEXT_PLAIN_UTF8 = TEXT_PLAIN + "; charset=utf-8";
public static final String APPLICATION_X_WWW_FORM_URL_ENCODED = "application/x-www-form-urlencoded";

private final MediaTypePredicate canHandleMediaType;
private final ContentAnalyserFactory contentAnalyserFactory;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2025, Shi HaiBin
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
* which is available at https://www.apache.org/licenses/LICENSE-2.0.
*
* SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
*
*/

package io.vertx.openapi.mediatype.impl;

import static io.vertx.openapi.mediatype.impl.DefaultMediaTypeRegistration.APPLICATION_X_WWW_FORM_URL_ENCODED;
import static io.vertx.openapi.validation.ValidatorErrorType.MISSING_REQUIRED_PARAMETER;

import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.DecodeException;
import io.vertx.core.json.Json;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.openapi.mediatype.MediaTypeInfo;
import io.vertx.openapi.validation.ValidationContext;
import io.vertx.openapi.validation.ValidatorException;
import java.net.URLDecoder;
import java.nio.charset.Charset;
import java.nio.charset.IllegalCharsetNameException;
import java.nio.charset.StandardCharsets;
import java.nio.charset.UnsupportedCharsetException;

public class XWwwFormUrlencodedAnalyser extends AbstractContentAnalyser {

private JsonObject parsedForm;

/**
* Creates a new content analyser.
*
* @param contentType the content type.
* @param content the content to be analysed.
* @param context the context in which the content is used.
*/
public XWwwFormUrlencodedAnalyser(String contentType, Buffer content, ValidationContext context) {
super(contentType, content, context);
}

@Override
public void checkSyntacticalCorrectness() {
if (contentType == null || contentType.isEmpty()
|| !contentType.startsWith(APPLICATION_X_WWW_FORM_URL_ENCODED)) {
String msg = "The expected application/x-www-form-urlencoded " + requestOrResponse
+ " doesn't contain the required content-type header.";
throw new ValidatorException(msg, MISSING_REQUIRED_PARAMETER);
}

Charset charset = resolveCharset(contentType);
String body = content == null ? "" : content.toString(charset);
parsedForm = parseFormData(body, charset);
}

@Override
public Object transform() {
return parsedForm;
}

public static Charset resolveCharset(String contentType) {
if (contentType != null && contentType.contains("charset=")) {
String charsetName = MediaTypeInfo.of(contentType).parameters().get("charset");
if (charsetName != null) {
try {
return Charset.forName(charsetName);
} catch (IllegalCharsetNameException | UnsupportedCharsetException ignored) {
// fall through to default
}
}
}
return StandardCharsets.UTF_8;
}

private JsonObject parseFormData(String body, Charset charset) {
JsonObject result = new JsonObject();
if (body.isEmpty()) {
return result;
}

for (String pair : body.split("&")) {
if (pair.isEmpty()) {
continue;
}
int idx = pair.indexOf('=');
String rawKey = idx >= 0 ? pair.substring(0, idx) : pair;
String rawValue = idx >= 0 ? pair.substring(idx + 1) : "";

String key = URLDecoder.decode(rawKey, charset);
String decodedValue = URLDecoder.decode(rawValue, charset);
Object value = coerceValue(decodedValue);
// Handle array notation: key[]=value1&key[]=value2
if (key.endsWith("[]")) {
String arrayKey = key.substring(0, key.length() - 2);
if (!result.containsKey(arrayKey)) {
result.put(arrayKey, new JsonArray());
}
result.getJsonArray(arrayKey).add(value);
} else {
// Handle duplicate keys: if key already exists, convert to array
if (result.containsKey(key)) {
Object existing = result.getValue(key);
if (existing instanceof JsonArray) {
((JsonArray) existing).add(value);
} else {
result.put(key, new JsonArray().add(existing).add(value));
}
} else {
result.put(key, value);
}
}
}
return result;
}

public static Object coerceValue(String value) {
try {
return Json.decodeValue(value);
} catch (DecodeException e) {
return value;
}
}
}
3 changes: 2 additions & 1 deletion src/test/java/io/vertx/tests/contract/impl/PathImplTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ void testCutTrailingSlash() {
String expected = "/pets";
assertThat(
new PathImpl(BASE_PATH, expected + "/", EMPTY_JSON_OBJECT, emptyList(), MediaTypeRegistry.createDefault())
.getName()).isEqualTo(expected);
.getName())
.isEqualTo(expected);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ private static Stream<Arguments> testExceptions() {
Arguments.of("0002_RequestBody_With_Content_Type_Application_Png", UNSUPPORTED_FEATURE,
"The passed OpenAPI contract contains a feature that is not supported: Operation dummyOperation defines a "
+ "request body with an unsupported media type. Supported: application/json, application/json; charset=utf-8,"
+ " application/hal+json, multipart/form-data, application/octet-stream, text/plain, text/plain; charset=utf-8, ^[^/]+/vnd\\.[\\w.-]+\\+json$"));
+ " application/hal+json, multipart/form-data, application/octet-stream, application/x-www-form-urlencoded, text/plain, text/plain; charset=utf-8, ^[^/]+/vnd\\.[\\w.-]+\\+json$"));
}

@ParameterizedTest(name = "{index} test getters for scenario: {0}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ private static Stream<Arguments> testExceptions() {
Arguments.of("0000_Response_With_Content_Type_Application_Png", UNSUPPORTED_FEATURE,
"The passed OpenAPI contract contains a feature that is not supported: Operation dummyOperation defines a "
+ "response with an unsupported media type. Supported: application/json, application/json; charset=utf-8, "
+ "application/hal+json, multipart/form-data, application/octet-stream, text/plain, text/plain; charset=utf-8, ^[^/]+/vnd\\.[\\w.-]+\\+json$"));
+ "application/hal+json, multipart/form-data, application/octet-stream, application/x-www-form-urlencoded, text/plain, text/plain; charset=utf-8, ^[^/]+/vnd\\.[\\w.-]+\\+json$"));
}

@ParameterizedTest(name = "{index} test getters for scenario: {0}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ void testCreateEmpty() {
assertThat(r.isSupported(DefaultMediaTypeRegistration.APPLICATION_HAL_JSON)).isFalse();
assertThat(r.isSupported(DefaultMediaTypeRegistration.APPLICATION_OCTET_STREAM)).isFalse();
assertThat(r.isSupported(DefaultMediaTypeRegistration.MULTIPART_FORM_DATA)).isFalse();
assertThat(r.isSupported(DefaultMediaTypeRegistration.APPLICATION_X_WWW_FORM_URL_ENCODED)).isFalse();
}

@Test
Expand All @@ -45,6 +46,7 @@ void testCreateDefault() {
assertThat(r.isSupported(DefaultMediaTypeRegistration.APPLICATION_HAL_JSON)).isTrue();
assertThat(r.isSupported(DefaultMediaTypeRegistration.APPLICATION_OCTET_STREAM)).isTrue();
assertThat(r.isSupported(DefaultMediaTypeRegistration.MULTIPART_FORM_DATA)).isTrue();
assertThat(r.isSupported(DefaultMediaTypeRegistration.APPLICATION_X_WWW_FORM_URL_ENCODED)).isTrue();
}

@Test
Expand Down
Loading