Skip to content

Commit 9046615

Browse files
authored
feat: support for deepObject explode=true
* Initial support for deepObject, per spec only for explode=true * Spotless apply * Added requested unit tests, some cleanup * override transformPrimitive for deepObject and throw an exception, early validation for deepObject + non-object schema type * Fix transformObject -> transformPrimitive call breakage, fix unit test exception expectation * More informative exception message for primitive transformation in deep object transformer
1 parent 9ed5a45 commit 9046615

File tree

10 files changed

+222
-5
lines changed

10 files changed

+222
-5
lines changed

src/main/java/io/vertx/openapi/contract/impl/ParameterImpl.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
package io.vertx.openapi.contract.impl;
1414

1515
import static io.vertx.json.schema.common.dsl.SchemaType.ARRAY;
16+
import static io.vertx.json.schema.common.dsl.SchemaType.OBJECT;
1617
import static io.vertx.openapi.contract.Location.COOKIE;
1718
import static io.vertx.openapi.contract.Location.HEADER;
1819
import static io.vertx.openapi.contract.Location.PATH;
@@ -111,9 +112,15 @@ public ParameterImpl(String path, JsonObject parameterModel) {
111112
if (!(style == FORM || style == SPACE_DELIMITED || style == PIPE_DELIMITED || style == DEEP_OBJECT)) {
112113
throw createInvalidStyle(in, "form, spaceDelimited, pipeDelimited or deepObject");
113114
} else {
114-
if (style == SPACE_DELIMITED || style == PIPE_DELIMITED || style == DEEP_OBJECT) {
115+
if (style == SPACE_DELIMITED || style == PIPE_DELIMITED) {
115116
throw createUnsupportedFeature("Parameters of style: " + style);
116117
}
118+
if (style == DEEP_OBJECT && !explode) {
119+
throw createUnsupportedFeature("Query parameter in non-exploded deepObject style");
120+
}
121+
if (style == DEEP_OBJECT && schemaType != OBJECT) {
122+
throw createUnsupportedFeature("Query parameter in deepObject style can only be an object");
123+
}
117124
}
118125
}
119126
}

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,10 @@ public enum ValidatorErrorType {
4949
/**
5050
* The response can't get validated due to missing response definition for the related status code information.
5151
*/
52-
MISSING_RESPONSE
52+
MISSING_RESPONSE,
53+
54+
/**
55+
* Transformation to the chosen output format is not supported.
56+
*/
57+
UNSUPPORTED_TRANSFORMATION
5358
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@
1717
import static io.vertx.openapi.validation.ValidatorErrorType.MISSING_OPERATION;
1818
import static io.vertx.openapi.validation.ValidatorErrorType.MISSING_REQUIRED_PARAMETER;
1919
import static io.vertx.openapi.validation.ValidatorErrorType.MISSING_RESPONSE;
20+
import static io.vertx.openapi.validation.ValidatorErrorType.UNSUPPORTED_TRANSFORMATION;
2021
import static io.vertx.openapi.validation.ValidatorErrorType.UNSUPPORTED_VALUE_FORMAT;
2122

2223
import io.vertx.core.http.HttpMethod;
24+
import io.vertx.json.schema.common.dsl.SchemaType;
2325
import io.vertx.openapi.contract.Parameter;
26+
import io.vertx.openapi.contract.Style;
2427

2528
/**
2629
* A ValidatorException is thrown, if the validation of a request or response fails. The validation can fail for
@@ -61,6 +64,11 @@ public static ValidatorException createUnsupportedValueFormat(Parameter paramete
6164
return new ValidatorException(msg, UNSUPPORTED_VALUE_FORMAT);
6265
}
6366

67+
public static ValidatorException createUnsupportedTransformation(Style style, SchemaType schemaType) {
68+
String msg = String.format("Transformation in style %s to schema type %s is not supported.", style, schemaType);
69+
return new ValidatorException(msg, UNSUPPORTED_TRANSFORMATION);
70+
}
71+
6472
public static ValidatorException createCantDecodeValue(Parameter parameter) {
6573
String msg = String.format("The value of %s parameter %s can't be decoded.", parameter.getIn().name().toLowerCase(),
6674
parameter.getName());

src/main/java/io/vertx/openapi/validation/impl/RequestValidatorImpl.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
package io.vertx.openapi.validation.impl;
1414

1515
import static io.vertx.core.Future.failedFuture;
16+
import static io.vertx.openapi.contract.Style.DEEP_OBJECT;
1617
import static io.vertx.openapi.contract.Style.FORM;
1718
import static io.vertx.openapi.contract.Style.LABEL;
1819
import static io.vertx.openapi.contract.Style.MATRIX;
@@ -41,6 +42,7 @@
4142
import io.vertx.openapi.validation.ValidatableRequest;
4243
import io.vertx.openapi.validation.ValidatedRequest;
4344
import io.vertx.openapi.validation.ValidatorException;
45+
import io.vertx.openapi.validation.transformer.DeepObjectTransformer;
4446
import io.vertx.openapi.validation.transformer.FormTransformer;
4547
import io.vertx.openapi.validation.transformer.LabelTransformer;
4648
import io.vertx.openapi.validation.transformer.MatrixTransformer;
@@ -60,6 +62,7 @@ public RequestValidatorImpl(Vertx vertx, OpenAPIContract contract) {
6062
parameterTransformers.put(LABEL, new LabelTransformer());
6163
parameterTransformers.put(MATRIX, new MatrixTransformer());
6264
parameterTransformers.put(FORM, new FormTransformer());
65+
parameterTransformers.put(DEEP_OBJECT, new DeepObjectTransformer());
6366
}
6467

6568
@Override
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright (c) 2011-2025 Contributors to the Eclipse Foundation
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+
package io.vertx.openapi.validation.transformer;
12+
13+
import static io.vertx.json.schema.common.dsl.SchemaType.OBJECT;
14+
import static io.vertx.openapi.contract.Style.DEEP_OBJECT;
15+
import static io.vertx.openapi.validation.ValidatorException.createUnsupportedTransformation;
16+
17+
import io.vertx.json.schema.common.dsl.SchemaType;
18+
import io.vertx.openapi.contract.Parameter;
19+
import java.util.ArrayList;
20+
import java.util.List;
21+
import java.util.regex.Matcher;
22+
import java.util.regex.Pattern;
23+
24+
/**
25+
* <p>
26+
* +------------+---------+--------+----------+-----------+-------------------------------------------+
27+
* | style | explode | empty | string | array | object |
28+
* +------------+---------+--------+----------+-----------+-------------------------------------------+
29+
* | deepObject | true | n/a | n/a | n/a | dummy[role]=admin&dummy[firstName]=Alex |
30+
* +------------+---------+--------+----------+-----------+-------------------------------------------+
31+
*/
32+
public class DeepObjectTransformer extends ParameterTransformer {
33+
34+
/**
35+
* Matches name=[key]=value
36+
*/
37+
private final Pattern PATTERN = Pattern.compile("(\\w+)\\[(\\w+)]\\s*=\\s*([^&]+)");
38+
39+
@Override
40+
public Object transformPrimitive(SchemaType type, String rawValue) {
41+
// as transformObject calls transformPrimitive internally, we delegate to the base method for type OBJECT
42+
// to avoid breaking transformObject.
43+
if (type == OBJECT)
44+
return super.transformPrimitive(type, rawValue);
45+
throw createUnsupportedTransformation(DEEP_OBJECT, type);
46+
}
47+
48+
@Override
49+
protected String[] getObjectKeysAndValues(Parameter parameter, String rawValue) {
50+
Matcher matcher = PATTERN.matcher(rawValue);
51+
52+
List<String> keysAndValues = new ArrayList<>();
53+
while (matcher.find()) {
54+
String name = matcher.group(1);
55+
String key = matcher.group(2);
56+
String value = matcher.group(3);
57+
58+
if (parameter.getName().equals(name)) {
59+
keysAndValues.add(key);
60+
keysAndValues.add(value);
61+
}
62+
}
63+
64+
return keysAndValues.toArray(new String[keysAndValues.size()]);
65+
}
66+
67+
@Override
68+
public Object transformArray(Parameter parameter, String rawValue) {
69+
throw createUnsupportedTransformation(parameter.getStyle(), parameter.getSchemaType());
70+
}
71+
72+
@Override
73+
protected String[] getArrayValues(Parameter parameter, String rawValue) {
74+
// this is never called due to transformArray overridden and throwing an exception.
75+
return null;
76+
}
77+
78+
}

src/test/java/io/vertx/tests/contract/impl/ParameterImplTest.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,18 @@ private static Stream<Arguments> provideErrorScenarios() {
9090
Arguments.of("0008_Query_With_Wrong_Style", INVALID_SPEC,
9191
"The passed OpenAPI contract is invalid: The style of a query parameter MUST be form, spaceDelimited, pipeDelimited or deepObject"),
9292
Arguments.of("0009_Query_With_Unsupported_Style_DeepObject", UNSUPPORTED_FEATURE,
93-
"The passed OpenAPI contract contains a feature that is not supported: Parameters of style: deepObject"),
93+
"The passed OpenAPI contract contains a feature that is not supported: Query parameter in non-exploded deepObject style"),
9494
Arguments.of("0010_Query_With_Unsupported_Style_SpaceDelimited", UNSUPPORTED_FEATURE,
9595
"The passed OpenAPI contract contains a feature that is not supported: Parameters of style: spaceDelimited"),
9696
Arguments.of("0011_Query_With_Unsupported_Style_pipeDelimited", UNSUPPORTED_FEATURE,
9797
"The passed OpenAPI contract contains a feature that is not supported: Parameters of style: pipeDelimited"),
9898
Arguments.of("0012_With_Schema_No_Type", INVALID_SPEC,
9999
"The passed OpenAPI contract is invalid: Missing \"type\" for \"schema\" property in parameter: petId"),
100100
Arguments.of("0013_Cookie_With_Unsupported_Combination_Array_And_Exploded", UNSUPPORTED_FEATURE,
101-
"The passed OpenAPI contract contains a feature that is not supported: Cookie parameter values formatted as exploded array"));
101+
"The passed OpenAPI contract contains a feature that is not supported: Cookie parameter values formatted as exploded array"),
102+
Arguments.of("0014_Query_With_Unsupported_Schema_DeepObject", UNSUPPORTED_FEATURE,
103+
"The passed OpenAPI contract contains a feature that is not supported: Query parameter in deepObject style can only be an object"));
104+
102105
}
103106

104107
private static Stream<Arguments> provideDefaultValuesScenarios() {

src/test/java/io/vertx/tests/validation/ValidatorExceptionTest.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,13 @@ void testCreateResponseNotFound() {
8585
assertThat(exception).hasMessageThat().isEqualTo(expectedMsg);
8686
assertThat(exception.type()).isEqualTo(ValidatorErrorType.MISSING_RESPONSE);
8787
}
88+
89+
@Test
90+
void testCreateUnsupportedTransformationFormat() {
91+
ValidatorException exception = ValidatorException.createUnsupportedTransformation(DUMMY_PARAMETER.getStyle(),
92+
DUMMY_PARAMETER.getSchemaType());
93+
String expectedMsg = "Transformation in style label to schema type INTEGER is not supported.";
94+
assertThat(exception).hasMessageThat().isEqualTo(expectedMsg);
95+
assertThat(exception.type()).isEqualTo(ValidatorErrorType.UNSUPPORTED_TRANSFORMATION);
96+
}
8897
}

src/test/java/io/vertx/tests/validation/impl/RequestValidatorImplTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ void testValidateParameterMissingParamIsNotRequired(String scenario, RequestPara
372372
}
373373

374374
@ParameterizedTest(name = "{index} Throw UNSUPPORTED_VALUE_FORMAT error when param style is {0}")
375-
@EnumSource(value = Style.class, names = { "SPACE_DELIMITED", "PIPE_DELIMITED", "DEEP_OBJECT" })
375+
@EnumSource(value = Style.class, names = { "SPACE_DELIMITED", "PIPE_DELIMITED" })
376376
void testValidateParameterThrowUnsupportedValueFormat(Style style) {
377377
Parameter param = mockParameter("dummy", HEADER, style, false, JsonSchema.of(stringSchema().toJson()));
378378
ValidatorException exception = assertThrows(ValidatorException.class,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright (c) 2011-2025 Contributors to the Eclipse Foundation
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+
package io.vertx.tests.validation.transformer;
12+
13+
import static com.google.common.truth.Truth.assertThat;
14+
import static io.vertx.openapi.contract.Location.QUERY;
15+
import static io.vertx.openapi.contract.Style.DEEP_OBJECT;
16+
import static io.vertx.openapi.impl.Utils.EMPTY_JSON_OBJECT;
17+
import static io.vertx.tests.MockHelper.mockParameter;
18+
import static org.junit.jupiter.api.Assertions.assertThrows;
19+
20+
import io.vertx.core.json.JsonObject;
21+
import io.vertx.openapi.contract.Parameter;
22+
import io.vertx.openapi.validation.ValidatorException;
23+
import io.vertx.openapi.validation.transformer.DeepObjectTransformer;
24+
import java.util.stream.Stream;
25+
import org.junit.jupiter.api.Test;
26+
import org.junit.jupiter.params.ParameterizedTest;
27+
import org.junit.jupiter.params.provider.Arguments;
28+
import org.junit.jupiter.params.provider.MethodSource;
29+
30+
public class DeepObjectTransformerTest implements SchemaSupport {
31+
32+
private static final Parameter DEEP_OBJECT_PARAM = mockParameter(NAME, QUERY, DEEP_OBJECT, true, OBJECT_SCHEMA);
33+
private static final Parameter DEEP_OBJECT_ARRAY_PARAM = mockParameter(NAME, QUERY, DEEP_OBJECT, true, ARRAY_SCHEMA);
34+
private static final Parameter STRING_PARAM = mockParameter(NAME, QUERY, DEEP_OBJECT, false, STRING_SCHEMA);
35+
private static final Parameter NUMBER_PARAM = mockParameter(NAME, QUERY, DEEP_OBJECT, false, NUMBER_SCHEMA);
36+
private static final Parameter INTEGER_PARAM = mockParameter(NAME, QUERY, DEEP_OBJECT, false, INTEGER_SCHEMA);
37+
private static final Parameter BOOLEAN_PARAM = mockParameter(NAME, QUERY, DEEP_OBJECT, false, BOOLEAN_SCHEMA);
38+
39+
private static final DeepObjectTransformer TRANSFORMER = new DeepObjectTransformer();
40+
41+
private static Stream<Arguments> provideValidPrimitiveValues() {
42+
return Stream.of(
43+
Arguments.of("(String) empty", STRING_PARAM, "", ""),
44+
Arguments.of("(String) 44", STRING_PARAM, "44", "44"),
45+
Arguments.of("(Integer) -100", INTEGER_PARAM, "-101", -101),
46+
Arguments.of("(Number) 14.6767", NUMBER_PARAM, "14.6767", 14.6767),
47+
Arguments.of("(Boolean) true", BOOLEAN_PARAM, "true", true));
48+
}
49+
50+
private static Stream<Arguments> provideValidObjectValues() {
51+
String complexExplodedRaw = "dummy[role]=admin&dummy[firstName]=Alex";
52+
String complexExplodedRawWithExtras = "something=nothing&dummy[role]=admin&dummy[firstName]=Alex";
53+
String complexExplodedRawWithAnotherParameter =
54+
"dummy[role]=admin&dummy[firstName]=Alex&silly[role]=user&silly[firstName]=John";
55+
JsonObject expected = new JsonObject().put("role", "admin").put("firstName", "Alex");
56+
57+
return Stream.of(
58+
Arguments.of("empty", DEEP_OBJECT_PARAM, "", EMPTY_JSON_OBJECT),
59+
Arguments.of(complexExplodedRaw + " (exploded)", DEEP_OBJECT_PARAM, complexExplodedRaw, expected),
60+
Arguments.of(complexExplodedRawWithExtras + " (exploded)", DEEP_OBJECT_PARAM, complexExplodedRawWithExtras,
61+
expected),
62+
Arguments.of(complexExplodedRawWithAnotherParameter + " (exploded)", DEEP_OBJECT_PARAM,
63+
complexExplodedRawWithAnotherParameter, expected));
64+
}
65+
66+
@ParameterizedTest(name = "{index} Transform \"Query\" parameter of style \"deepObject\" with object value: {0}")
67+
@MethodSource("provideValidObjectValues")
68+
void testTransformObjectValid(String scenario, Parameter parameter, String rawValue, Object expectedValue) {
69+
assertThat(TRANSFORMER.transformObject(parameter, rawValue)).isEqualTo(expectedValue);
70+
}
71+
72+
@Test
73+
void testInvalidTransformation() {
74+
String validExplodedRaw = "dummy[role]=admin&dummy[firstName]=Alex";
75+
ValidatorException exception =
76+
assertThrows(ValidatorException.class, () -> TRANSFORMER.transform(DEEP_OBJECT_ARRAY_PARAM, validExplodedRaw));
77+
String expectedMsg = "Transformation in style deepObject to schema type ARRAY is not supported.";
78+
assertThat(exception).hasMessageThat().isEqualTo(expectedMsg);
79+
}
80+
81+
@ParameterizedTest(name = "{index} Transform \"Query\" parameter of style \"deepObject\" with primitive value: {0}")
82+
@MethodSource("provideValidPrimitiveValues")
83+
void testTransformPrimitiveValid(String scenario, Parameter parameter, String rawValue, Object expectedValue) {
84+
ValidatorException exception =
85+
assertThrows(ValidatorException.class,
86+
() -> TRANSFORMER.transformPrimitive(parameter.getSchemaType(), rawValue));
87+
String expectedMsg = String.format("Transformation in style deepObject to schema type %s is not supported.",
88+
parameter.getSchemaType());
89+
assertThat(exception).hasMessageThat().isEqualTo(expectedMsg);
90+
}
91+
}

src/test/resources/io/vertx/tests/contract/impl/parameter_invalid.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,5 +153,18 @@
153153
"type": "array"
154154
}
155155
}
156+
},
157+
"0014_Query_With_Unsupported_Schema_DeepObject": {
158+
"path": "/pets/{petId}",
159+
"parameterModel": {
160+
"name": "petId",
161+
"in": "query",
162+
"required": true,
163+
"explode": true,
164+
"style": "deepObject",
165+
"schema": {
166+
"type": "array"
167+
}
168+
}
156169
}
157170
}

0 commit comments

Comments
 (0)