Skip to content

Commit c939399

Browse files
authored
Fix multiple decoding of parameter values (#108)
* refactor: add ContractTestBase to simplify creation of new E2E tests * feat: add reproducer and fix for phone number issue --------- Signed-off-by: Pascal Krause <[email protected]>
1 parent 0087153 commit c939399

File tree

13 files changed

+390
-77
lines changed

13 files changed

+390
-77
lines changed

src/main/asciidoc/index.adoc

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,25 @@ The {@link io.vertx.openapi.contract.OpenAPIContract} interface offers methods t
7878
{@link examples.ContractExamples#pathParameterOperationExample}
7979
----
8080

81+
=== Extensions
82+
83+
Vert.x OpenAPI supports the extension `x-vertx-openapi-urldecode` to specify if a `cookie` or `header` parameter should
84+
be URL decoded. The default is `false`. `Query` parameters and `path` parameters gets always URL decoded.
85+
86+
[source,yaml]
87+
----
88+
paths:
89+
/forceEncoding:
90+
get:
91+
operationId: forceEncoding
92+
parameters:
93+
- in: header
94+
name: headerWithEncodedContent
95+
x-vertx-openapi-urldecode: true
96+
schema:
97+
type: string
98+
----
99+
81100
== Validation
82101

83102
Vert.x OpenAPI checks both whether the content is syntactically correct and whether it corresponds to the schema.

src/main/java/io/vertx/openapi/contract/Parameter.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
@VertxGen
2727
public interface Parameter extends OpenAPIObject {
2828

29+
String EXTENSION_URLDECODE = "x-vertx-openapi-urldecode";
30+
2931
/**
3032
* @return name of this parameter
3133
*/

src/main/java/io/vertx/openapi/validation/RequestUtils.java

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ public static Future<ValidatableRequest> extract(HttpServerRequest request, Oper
8080
break;
8181
case PATH:
8282
int segment = findPathSegment(operation.getAbsoluteOpenAPIPath(), param.getName());
83-
pathParams.put(param.getName(), extractPathParameters(request, segment));
83+
pathParams.put(param.getName(), extractPathParameters(param, request, segment));
8484
break;
8585
case QUERY:
8686
query.put(param.getName(), extractQuery(request, param));
@@ -104,32 +104,37 @@ public static Future<ValidatableRequest> extract(HttpServerRequest request, Oper
104104

105105
private static RequestParameter extractCookie(HttpServerRequest request, Parameter parameter) {
106106
Collection<String> cookies =
107-
request.cookies(parameter.getName()).stream().map(Cookie::getValue).collect(Collectors.toList());
107+
request.cookies(parameter.getName()).stream().map(Cookie::getValue).map(c -> urlDecodeIfRequired(parameter, c))
108+
.collect(Collectors.toList());
108109
return joinFormValues(cookies, parameter, () -> {
109110
String explodedObject =
110-
request.cookies().stream().map(c -> c.getName() + "=" + decodeUrl(c.getValue())).collect(joining("&"));
111+
request.cookies().stream().map(c -> c.getName() + "=" + urlDecodeIfRequired(parameter, c.getValue()))
112+
.collect(joining("&"));
111113
return new RequestParameterImpl(explodedObject);
112114
});
113115
}
114116

115117
private static RequestParameter extractHeaders(HttpServerRequest request, Parameter parameter) {
116118
String headerValue = request.getHeader(parameter.getName());
117-
return new RequestParameterImpl(decodeUrl(headerValue));
119+
return new RequestParameterImpl(urlDecodeIfRequired(parameter, headerValue));
118120
}
119121

120-
private static RequestParameter extractPathParameters(HttpServerRequest request, int segment) {
122+
private static RequestParameter extractPathParameters(Parameter param, HttpServerRequest request, int segment) {
121123
String[] pathSegments = request.path().substring(1).split("/");
122124
if (pathSegments.length < segment) {
123125
return EMPTY;
124126
}
125127
return new RequestParameterImpl(decodeUrl(pathSegments[segment - 1]));
126128
}
127129

130+
/**
131+
* It seems that query parameters are always decoded, so we MUST NOT decode them again.
132+
*/
128133
private static RequestParameter extractQuery(HttpServerRequest request, Parameter parameter) {
129134
Collection<String> queryParams = request.params().getAll(parameter.getName());
130135
return joinFormValues(queryParams, parameter, () -> {
131136
String decodedQuery =
132-
request.params().entries().stream().map(entry -> entry.getKey() + "=" + decodeUrl(entry.getValue()))
137+
request.params().entries().stream().map(entry -> entry.getKey() + "=" + entry.getValue())
133138
.collect(joining("&"));
134139
return new RequestParameterImpl(decodedQuery);
135140
});
@@ -146,18 +151,18 @@ private static RequestParameter joinFormValues(Collection<String> formValues, Pa
146151
if (parameter.isExplode()) {
147152
return explodedObjectSupplier.get();
148153
} else {
149-
return new RequestParameterImpl(decodeUrl(GET_FIRST_VALUE.apply(formValues)));
154+
return new RequestParameterImpl(GET_FIRST_VALUE.apply(formValues));
150155
}
151156
case ARRAY:
152157
if (parameter.isExplode()) {
153158
String explodedString =
154-
formValues.stream().map(fv -> parameter.getName() + "=" + decodeUrl(fv)).collect(joining("&"));
159+
formValues.stream().map(fv -> parameter.getName() + "=" + fv).collect(joining("&"));
155160
return new RequestParameterImpl(explodedString);
156161
} else {
157-
return new RequestParameterImpl(decodeUrl(GET_FIRST_VALUE.apply(formValues)));
162+
return new RequestParameterImpl(GET_FIRST_VALUE.apply(formValues));
158163
}
159164
default:
160-
return new RequestParameterImpl(decodeUrl(GET_FIRST_VALUE.apply(formValues)));
165+
return new RequestParameterImpl(GET_FIRST_VALUE.apply(formValues));
161166
}
162167
}
163168

@@ -167,7 +172,16 @@ public static int findPathSegment(String templatePath, String parameterName) {
167172
return (int) templatePath.subSequence(0, idx).chars().filter(c -> c == '/').count();
168173
}
169174

170-
static String decodeUrl(String encoded) {
175+
static String urlDecodeIfRequired(Parameter param, String value) {
176+
boolean requiresDecoding =
177+
Boolean.TRUE.equals(param.getExtensions().getOrDefault(Parameter.EXTENSION_URLDECODE, false));
178+
if (requiresDecoding) {
179+
return decodeUrl(value);
180+
}
181+
return value;
182+
}
183+
184+
private static String decodeUrl(String encoded) {
171185
try {
172186
return encoded == null ? null : URLDecoder.decode(encoded, StandardCharsets.UTF_8);
173187
} catch (Exception e) {

src/main/java/io/vertx/openapi/validation/transformer/ParameterTransformer.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,7 @@ public Object transform(Parameter parameter, String rawValue) {
5757
* @return An {@link Object} holding the transformed value.
5858
*/
5959
public Object transformPrimitive(Parameter parameter, String rawValue) {
60-
boolean isString = STRING.equals(parameter.getSchemaType());
61-
if (isString && rawValue.matches("\\w+")) {
60+
if (STRING.equals(parameter.getSchemaType())) {
6261
return rawValue;
6362
}
6463

src/test/java/io/vertx/tests/test/E2ETest.java

Lines changed: 10 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -14,54 +14,27 @@
1414

1515
import static com.google.common.truth.Truth.assertThat;
1616
import static io.vertx.tests.ResourceHelper.getRelatedTestResourcePath;
17-
import static io.vertx.tests.ResourceHelper.loadJson;
1817

1918
import io.netty.handler.codec.http.HttpHeaderValues;
2019
import io.vertx.core.Future;
2120
import io.vertx.core.http.HttpClientRequest;
22-
import io.vertx.core.http.HttpClientResponse;
2321
import io.vertx.core.http.HttpHeaders;
2422
import io.vertx.core.http.HttpMethod;
2523
import io.vertx.core.json.JsonArray;
2624
import io.vertx.core.json.JsonObject;
2725
import io.vertx.junit5.Timeout;
2826
import io.vertx.junit5.VertxTestContext;
29-
import io.vertx.openapi.contract.OpenAPIContract;
30-
import io.vertx.openapi.validation.RequestValidator;
31-
import io.vertx.openapi.validation.ResponseValidator;
3227
import io.vertx.openapi.validation.ValidatableResponse;
33-
import io.vertx.openapi.validation.ValidatedRequest;
34-
import io.vertx.tests.test.base.HttpServerTestBase;
28+
import io.vertx.tests.test.base.ContractTestBase;
3529
import java.nio.file.Path;
3630
import java.util.concurrent.TimeUnit;
37-
import java.util.function.Consumer;
38-
import java.util.function.Function;
3931
import org.junit.jupiter.api.DisplayName;
4032
import org.junit.jupiter.api.Test;
41-
import org.junit.jupiter.api.parallel.Execution;
42-
import org.junit.jupiter.api.parallel.ExecutionMode;
4333
import org.junit.jupiter.params.ParameterizedTest;
4434
import org.junit.jupiter.params.provider.ValueSource;
4535

46-
// We need to enforce sequential execution, because the tests are manipulating fields
47-
@Execution(ExecutionMode.SAME_THREAD)
48-
class E2ETest extends HttpServerTestBase {
49-
50-
private OpenAPIContract contract;
51-
private RequestValidator requestValidator;
52-
private ResponseValidator responseValidator;
53-
54-
Future<Void> setupContract(String basePath, VertxTestContext testContext) {
55-
JsonObject contractJson = loadJson(vertx, getRelatedTestResourcePath(E2ETest.class).resolve("petstore.json"));
56-
// Modify base path
57-
JsonObject server = contractJson.getJsonArray("servers").getJsonObject(0);
58-
server.put("url", server.getString("url") + basePath);
59-
return OpenAPIContract.from(vertx, contractJson).onComplete(testContext.succeeding(contract -> {
60-
this.contract = contract;
61-
this.requestValidator = RequestValidator.create(vertx, contract);
62-
this.responseValidator = ResponseValidator.create(vertx, contract);
63-
})).mapEmpty();
64-
}
36+
class E2ETest extends ContractTestBase {
37+
private Path CONTRACT_FILE = getRelatedTestResourcePath(E2ETest.class).resolve("petstore.json");
6538

6639
@Timeout(value = 2, timeUnit = TimeUnit.SECONDS)
6740
@ParameterizedTest(name = "{index} Test with base path: {0}")
@@ -70,16 +43,16 @@ void testExtractPath(String basePath, VertxTestContext testContext) {
7043
int expectedPetId = 1;
7144
JsonObject expectedPet = new JsonObject().put("id", 1).put("name", "FooBar");
7245

73-
setupContract(basePath, testContext).compose(v -> createValidationHandler(req -> {
46+
loadContract(CONTRACT_FILE, basePath, testContext).compose(v -> createServerWithRequestProcessor(req -> {
7447
testContext.verify(() -> {
7548
int petId = req.getPathParameters().get("petId").getInteger();
7649
assertThat(petId).isEqualTo(expectedPetId);
7750
});
7851
return ValidatableResponse.create(200, expectedPet.toBuffer(), HttpHeaderValues.APPLICATION_JSON.toString());
79-
}, contract.operation("showPetById").getOperationId(), testContext)).compose(v -> {
52+
}, "showPetById", testContext)).compose(v -> {
8053
String bp = basePath.endsWith("/") ? basePath.substring(0, basePath.length() - 1) : basePath;
8154
Future<HttpClientRequest> req = createRequest(HttpMethod.GET, bp + "/pets/" + expectedPetId);
82-
return request(req, resp -> resp.body().onComplete(testContext.succeeding(
55+
return sendAndVerifyRequest(req, resp -> resp.body().onComplete(testContext.succeeding(
8356
body -> testContext.verify(() -> {
8457
assertThat(resp.statusCode()).isEqualTo(200);
8558
assertThat(body.toJsonObject()).isEqualTo(expectedPet);
@@ -104,7 +77,7 @@ public void sendMultipartFormDataRequest(VertxTestContext testContext) {
10477
.put("email", "[email protected]")
10578
.put("phone", "5555555555"));
10679

107-
setupContract("", testContext).compose(v -> createValidationHandler(req -> {
80+
loadContract(CONTRACT_FILE, testContext).compose(v -> createServerWithRequestProcessor(req -> {
10881
testContext.verify(() -> {
10982
assertThat(req.getBody()).isNotNull();
11083
JsonObject jsonReq = req.getBody().getJsonObject();
@@ -114,7 +87,7 @@ public void sendMultipartFormDataRequest(VertxTestContext testContext) {
11487
testContext.completeNow();
11588
});
11689
return ValidatableResponse.create(201);
117-
}, contract.operation("uploadPet").getOperationId(), testContext))
90+
}, "uploadPet", testContext))
11891
.compose(v -> createRequest(HttpMethod.POST, "/pets/upload"))
11992
.map(request -> request.putHeader(HttpHeaders.CONTENT_TYPE, "multipart/form-data; boundary=4ad8accc990e99c2"))
12093
.map(request -> request.putHeader(HttpHeaders.CONTENT_DISPOSITION, ""))
@@ -129,28 +102,13 @@ public void sendMultipartFormDataRequest(VertxTestContext testContext) {
129102
void testLessRestrictiveContentType(String requestContentType, VertxTestContext testContext) {
130103
JsonObject expectedPet = new JsonObject().put("id", 1).put("name", "FooBar");
131104

132-
setupContract("", testContext).compose(v -> createValidationHandler(req -> {
105+
loadContract(CONTRACT_FILE, testContext).compose(v -> createServerWithRequestProcessor(req -> {
133106
testContext.completeNow();
134107
return ValidatableResponse.create(201);
135-
}, contract.operation("createPets").getOperationId(), testContext))
108+
}, "createPets", testContext))
136109
.compose(v -> createRequest(HttpMethod.POST, "/pets"))
137110
.map(request -> request.putHeader(HttpHeaders.CONTENT_TYPE, requestContentType))
138111
.compose(request -> request.send(expectedPet.toBuffer()))
139112
.onFailure(testContext::failNow);
140113
}
141-
142-
private Future<Void> request(Future<HttpClientRequest> request, Consumer<HttpClientResponse> verifier,
143-
VertxTestContext testContext) {
144-
return request.compose(HttpClientRequest::send)
145-
.onSuccess(response -> testContext.verify(() -> verifier.accept(response))).onFailure(testContext::failNow)
146-
.mapEmpty();
147-
}
148-
149-
private Future<Void> createValidationHandler(Function<ValidatedRequest, ValidatableResponse> processor,
150-
String operationId, VertxTestContext testContext) {
151-
return createServer(request -> requestValidator.validate(request, operationId)
152-
.map(processor).compose(validatableResponse -> responseValidator.validate(validatableResponse, operationId))
153-
.compose(validatedResponse -> validatedResponse.send(request.response()))
154-
.onFailure(testContext::failNow)).onFailure(testContext::failNow);
155-
}
156114
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright (c) 2025, SAP SE
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License 2.0 which is available at
6+
* http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
7+
* which is available at https://www.apache.org/licenses/LICENSE-2.0.
8+
*
9+
* SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
10+
*
11+
*/
12+
13+
package io.vertx.tests.test;
14+
15+
import static com.google.common.truth.Truth.assertThat;
16+
import static io.vertx.core.http.HttpMethod.POST;
17+
import static io.vertx.tests.ResourceHelper.getRelatedTestResourcePath;
18+
19+
import io.vertx.core.Future;
20+
import io.vertx.core.http.HttpClientRequest;
21+
import io.vertx.core.http.HttpClientResponse;
22+
import io.vertx.core.http.HttpHeaders;
23+
import io.vertx.junit5.Timeout;
24+
import io.vertx.junit5.VertxTestContext;
25+
import io.vertx.openapi.validation.ValidatableResponse;
26+
import io.vertx.openapi.validation.ValidatedRequest;
27+
import io.vertx.tests.test.base.ContractTestBase;
28+
import java.net.URLEncoder;
29+
import java.nio.charset.StandardCharsets;
30+
import java.nio.file.Path;
31+
import java.util.concurrent.TimeUnit;
32+
import java.util.function.Consumer;
33+
import java.util.function.Function;
34+
import org.junit.jupiter.api.DisplayName;
35+
import org.junit.jupiter.api.Test;
36+
37+
class PhoneNumberTest extends ContractTestBase {
38+
private Path CONTRACT_FILE = getRelatedTestResourcePath(PhoneNumberTest.class)
39+
.resolve("contract_various_scenarios.yaml");
40+
41+
@Timeout(value = 2, timeUnit = TimeUnit.SECONDS)
42+
@Test
43+
@DisplayName("Test that query parameters don't get decoded twice and loose a '+' sign")
44+
void testPhoneNumberInQuery(VertxTestContext testContext) {
45+
String operationId = "phoneNumberInQuery";
46+
String queryParam = "phoneNumber";
47+
String queryValue = "+39 334 192 1829";
48+
String encodedQueryValue = URLEncoder.encode(queryValue, StandardCharsets.UTF_8);
49+
String queryString = queryParam + "=" + encodedQueryValue;
50+
51+
Function<ValidatedRequest, ValidatableResponse> requestProcessor = req -> {
52+
assertThat(req.getQuery().get(queryParam).getString()).isEqualTo(queryValue);
53+
return ValidatableResponse.create(200);
54+
};
55+
56+
Consumer<HttpClientResponse> responseVerifier = resp -> testContext.verify(() -> {
57+
assertThat(resp.statusCode()).isEqualTo(200);
58+
testContext.completeNow();
59+
});
60+
61+
loadContract(CONTRACT_FILE, testContext)
62+
.compose(v -> createServerWithRequestProcessor(requestProcessor, operationId, testContext))
63+
.compose(v -> {
64+
Future<HttpClientRequest> req = createRequest(POST, "/phoneNumber?" + queryString);
65+
return sendAndVerifyRequest(req, responseVerifier, testContext);
66+
}).onFailure(testContext::failNow);
67+
}
68+
69+
// @Timeout(value = 2, timeUnit = TimeUnit.SECONDS)
70+
@Test
71+
@DisplayName("Test that force encoding of header and cookies is respected")
72+
void testForceEncodingHeaderAndCookies(VertxTestContext testContext) {
73+
String operationId = "forceEncoding";
74+
String headerEncoded = "headerEncoded";
75+
String headerNotEncoded = "headerNotEncoded";
76+
String cookieEncoded = "cookieEncoded";
77+
String cookieNotEncoded = "cookieNotEncoded";
78+
String encodedTestValue = "3%2C4%2C5";
79+
String decodedTestValue = "3,4,5";
80+
81+
Function<ValidatedRequest, ValidatableResponse> requestProcessor = req -> {
82+
assertThat(req.getCookies().get(cookieEncoded).getString()).isEqualTo(encodedTestValue);
83+
assertThat(req.getCookies().get(cookieNotEncoded).getString()).isEqualTo(decodedTestValue);
84+
assertThat(req.getHeaders().get(headerEncoded).getString()).isEqualTo(encodedTestValue);
85+
assertThat(req.getHeaders().get(headerNotEncoded).getString()).isEqualTo(decodedTestValue);
86+
return ValidatableResponse.create(200);
87+
};
88+
89+
Consumer<HttpClientResponse> responseVerifier = resp -> testContext.verify(() -> {
90+
assertThat(resp.statusCode()).isEqualTo(200);
91+
testContext.completeNow();
92+
});
93+
94+
loadContract(CONTRACT_FILE, testContext)
95+
.compose(v -> createServerWithRequestProcessor(requestProcessor, operationId, testContext))
96+
.compose(v -> {
97+
Future<HttpClientRequest> req = createRequest(POST, "/phoneNumber", reqOpts -> {
98+
reqOpts.putHeader(headerEncoded, encodedTestValue);
99+
reqOpts.putHeader(headerNotEncoded, encodedTestValue);
100+
reqOpts.putHeader(HttpHeaders.COOKIE, cookieEncoded + "=" + encodedTestValue + "; "
101+
+ cookieNotEncoded + "=" + encodedTestValue);
102+
});
103+
104+
return sendAndVerifyRequest(req, responseVerifier, testContext);
105+
}).onFailure(testContext::failNow);
106+
}
107+
}

0 commit comments

Comments
 (0)