diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/ContainerDefaultEvaluator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/ContainerDefaultEvaluator.java new file mode 100644 index 000000000000..cd13b4657ce6 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/ContainerDefaultEvaluator.java @@ -0,0 +1,34 @@ +package org.openapitools.codegen; + +import io.swagger.v3.oas.models.media.Schema; + +import java.util.List; + +public class ContainerDefaultEvaluator { + + final List conditions; + + public ContainerDefaultEvaluator(String containerDefaultToNull) { + conditions = ContainerDefaultParser.parseExpression(containerDefaultToNull); + } + + public boolean isNullDefault(CodegenProperty cp, Schema schema) { + ContainerDefaultParser.NullableState nullable; + if (schema.getNullable() == null && (schema.getExtensions() == null || !schema.getExtensions().containsKey("x-nullable"))) { + nullable = ContainerDefaultParser.NullableState.UNSPECIFIED; + } else { + if (cp.isNullable) { + nullable = ContainerDefaultParser.NullableState.YES; + } else { + nullable = ContainerDefaultParser.NullableState.NO; + } + } + + return conditions.stream().anyMatch( + myCondition -> + (myCondition.getRequired() == null || myCondition.getRequired().equals(cp.required)) + && (myCondition.getNullable() == null || myCondition.getNullable().equals(nullable)) + ); + } + +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/ContainerDefaultParser.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/ContainerDefaultParser.java new file mode 100644 index 000000000000..239e5f770156 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/ContainerDefaultParser.java @@ -0,0 +1,101 @@ +package org.openapitools.codegen; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +class ContainerDefaultParser { + + protected static final String REQUIRED = "required"; + protected static final String NULLABLE = "nullable"; + protected static final String ERROR_UNEXPECTED = "unexpected containerDefaultToNull keyword: "; + + protected static List parseExpression(String input) { + if (input == null || "".equals(input.trim())) { + input = "7.5.0"; + } + + String[] orConditions = input.split("\\|"); + List conditions = new ArrayList<>(); + + for (String myOrCondition : orConditions) { + conditions.addAll(parseCondition(myOrCondition.trim())); + } + + return conditions; + } + + protected static List parseCondition(String conditionStr) { + String[] andConditions = conditionStr.split("&"); + Boolean required = null; + NullableState nullable = null; + + for (String myAndCondition : andConditions) { + String trimmed = myAndCondition.trim(); + + if (trimmed.startsWith("!")) { + String keyword = trimmed.substring(1).trim(); + if (keyword.equals(REQUIRED)) { + required = false; + } else if (keyword.equals(NULLABLE)) { + nullable = NullableState.NO; + } else { + throw new IllegalArgumentException(ERROR_UNEXPECTED + keyword); + } + } else if (trimmed.startsWith("?")) { + String keyword = trimmed.substring(1).trim(); + if (keyword.equals(NULLABLE)) { + nullable = NullableState.UNSPECIFIED; + } else { + throw new IllegalArgumentException(ERROR_UNEXPECTED + keyword); + } + } else { + switch (trimmed) { + case REQUIRED: + required = true; + break; + case NULLABLE: + nullable = NullableState.YES; + break; + case "7.5.0": + case "false": + return List.of( + new Condition(true, NullableState.YES), + new Condition(false, NullableState.YES)); + case "7.4.0": + return List.of( + new Condition(true, NullableState.YES), + new Condition(false, NullableState.YES), + new Condition(false, NullableState.NO), + new Condition(false, NullableState.UNSPECIFIED)); + case "true": + return List.of( + new Condition(true, NullableState.YES), + new Condition(true, NullableState.NO), + new Condition(true, NullableState.UNSPECIFIED), + new Condition(false, NullableState.YES), + new Condition(false, NullableState.NO), + new Condition(false, NullableState.UNSPECIFIED)); + case "none": + return new ArrayList<>(); + default: + throw new IllegalArgumentException(ERROR_UNEXPECTED + trimmed); + } + } + } + + return List.of(new Condition(required, nullable)); + } + + protected enum NullableState { + YES, NO, UNSPECIFIED + } + + @Data + protected static class Condition { + private final Boolean required; + private final NullableState nullable; + } + +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java index 52e71fc22b79..4fdeb67189c2 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java @@ -175,7 +175,7 @@ public abstract class AbstractJavaCodegen extends DefaultCodegen implements Code @Setter protected String implicitHeadersRegex = null; @Setter protected boolean camelCaseDollarSign = false; @Setter protected boolean useJakartaEe = false; - @Setter protected boolean containerDefaultToNull = false; + protected String containerDefaultToNull = "false"; @Getter @Setter protected boolean generateConstructorWithAllArgs = false; @Getter @Setter @@ -189,6 +189,7 @@ public abstract class AbstractJavaCodegen extends DefaultCodegen implements Code @Getter @Setter protected boolean useBeanValidation = false; private Map schemaKeyToModelNameCache = new HashMap<>(); + private ContainerDefaultEvaluator containerDefaultEvaluator = new ContainerDefaultEvaluator("false"); public AbstractJavaCodegen() { super(); @@ -636,7 +637,7 @@ public void processOpts() { applyJavaxPackage(); } - convertPropertyToBooleanAndWriteBack(CONTAINER_DEFAULT_TO_NULL, this::setContainerDefaultToNull); + convertPropertyToStringAndWriteBack(CONTAINER_DEFAULT_TO_NULL, this::setContainerDefaultToNull); additionalProperties.put("sanitizeGeneric", (Mustache.Lambda) (fragment, writer) -> { String content = fragment.execute(); @@ -645,6 +646,8 @@ public void processOpts() { } writer.write(content); }); + + this.containerDefaultEvaluator = new ContainerDefaultEvaluator(this.containerDefaultToNull); } /** @@ -1257,8 +1260,7 @@ public String toDefaultValue(CodegenProperty cp, Schema schema) { schema = ModelUtils.getReferencedSchema(this.openAPI, schema); if (ModelUtils.isArraySchema(schema)) { if (schema.getDefault() == null) { - // nullable or containerDefaultToNull set to true - if (cp.isNullable || containerDefaultToNull) { + if (containerDefaultEvaluator.isNullDefault(cp, schema)) { return null; } return getDefaultCollectionType(schema); @@ -1273,8 +1275,7 @@ public String toDefaultValue(CodegenProperty cp, Schema schema) { return null; } - // nullable or containerDefaultToNull set to true - if (cp.isNullable || containerDefaultToNull) { + if (containerDefaultEvaluator.isNullDefault(cp, schema)) { return null; } @@ -2115,6 +2116,17 @@ public String escapeUnsafeCharacters(String input) { return input.replace("*/", "*_/").replace("/*", "/_*"); } + public void setContainerDefaultToNull(String value) { + this.containerDefaultToNull = value; + } + + /** + * for legacy (before 7.8.0) a boolean can be set + */ + public void setContainerDefaultToNull(boolean value) { + this.containerDefaultToNull = Boolean.toString(value); + } + /* * Derive invoker package name based on the input * e.g. foo.bar.model => foo.bar diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/ContainerDefaultEvaluatorTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/ContainerDefaultEvaluatorTest.java new file mode 100644 index 000000000000..c85695492f35 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/ContainerDefaultEvaluatorTest.java @@ -0,0 +1,214 @@ +package org.openapitools.codegen; + +import io.swagger.parser.OpenAPIParser; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.parser.core.models.ParseOptions; +import org.assertj.core.api.Assertions; +import org.openapitools.codegen.java.assertions.JavaFileAssert; +import org.openapitools.codegen.languages.JavaClientCodegen; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +public class ContainerDefaultEvaluatorTest { + + @Test + public void testNull() throws IOException { + executeTest( + null, + "private List r0n0 = new ArrayList<>();", + "private List r0n1;", + "private List r1n0 = new ArrayList<>();", + "private List r1n1;", + "private List r0 = new ArrayList<>();", + "private List r1 = new ArrayList<>();", + "private Set mySet;", + "private Map myMap;" + ); + } + + @Test + public void testEmptyString() throws IOException { + executeTest( + "", + "private List r0n0 = new ArrayList<>();", + "private List r0n1;", + "private List r1n0 = new ArrayList<>();", + "private List r1n1;", + "private List r0 = new ArrayList<>();", + "private List r1 = new ArrayList<>();", + "private Set mySet;", + "private Map myMap;" + ); + } + + @Test + public void testFalse() throws IOException { + executeTest( + "false", + "private List r0n0 = new ArrayList<>();", + "private List r0n1;", + "private List r1n0 = new ArrayList<>();", + "private List r1n1;", + "private List r0 = new ArrayList<>();", + "private List r1 = new ArrayList<>();", + "private Set mySet;", + "private Map myMap;" + ); + } + + @Test + public void testTrue() throws IOException { + executeTest( + "true", + "private List r0n0;", + "private List r0n1;", + "private List r1n0;", + "private List r1n1;", + "private List r0;", + "private List r1;", + "private Set mySet;", + "private Map myMap;" + ); + } + + @Test + public void testNone() throws IOException { + executeTest( + "none", + "private List r0n0 = new ArrayList<>();", + "private List r0n1 = new ArrayList<>();", + "private List r1n0 = new ArrayList<>();", + "private List r1n1 = new ArrayList<>();", + "private List r0 = new ArrayList<>();", + "private List r1 = new ArrayList<>();", + "private Set mySet = new LinkedHashSet<>();", + "private Map myMap = new HashMap<>();" + ); + } + + @Test + public void test_7_5_0() throws IOException { + executeTest( + "7.5.0", + "private List r0n0 = new ArrayList<>();", + "private List r0n1;", + "private List r1n0 = new ArrayList<>();", + "private List r1n1;", + "private List r0 = new ArrayList<>();", + "private List r1 = new ArrayList<>();", + "private Set mySet;", + "private Map myMap;" + ); + } + + @Test + public void test_7_4_0() throws IOException { + executeTest( + "7.4.0", + "private List r0n0;", + "private List r0n1;", + "private List r1n0 = new ArrayList<>();", + "private List r1n1;", + "private List r0;", + "private List r1 = new ArrayList<>();", + "private Set mySet;", + "private Map myMap;" + ); + } + + @Test + public void testCustomExpression1() throws IOException { + executeTest( + "7.5.0 | !required & ?nullable", + "private List r0n0 = new ArrayList<>();", + "private List r0n1;", + "private List r1n0 = new ArrayList<>();", + "private List r1n1;", + "private List r0;", + "private List r1 = new ArrayList<>();", + "private Set mySet;", + "private Map myMap;" + ); + } + + @Test + public void testCustomExpression2() throws IOException { + executeTest( + " ! required&?nullable| 7.5.0", + "private List r0n0 = new ArrayList<>();", + "private List r0n1;", + "private List r1n0 = new ArrayList<>();", + "private List r1n1;", + "private List r0;", + "private List r1 = new ArrayList<>();", + "private Set mySet;", + "private Map myMap;" + ); + } + + @Test + public void testNullable() throws IOException { + executeTest( + "nullable", + "private List r0n0 = new ArrayList<>();", + "private List r0n1;", + "private List r1n0 = new ArrayList<>();", + "private List r1n1;", + "private List r0 = new ArrayList<>();", + "private List r1 = new ArrayList<>();", + "private Set mySet;", + "private Map myMap;" + ); + } + + @Test + public void testNotRequired() throws IOException { + executeTest( + "!required", + "private List r0n0;", + "private List r0n1;", + "private List r1n0 = new ArrayList<>();", + "private List r1n1 = new ArrayList<>();", + "private List r0;", + "private List r1 = new ArrayList<>();", + "private Set mySet;", + "private Map myMap;" + ); + } + + private void executeTest(String containerDefaultToNull, String... expectedLines) throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + OpenAPI openAPI = new OpenAPIParser() + .readLocation("src/test/resources/3_0/nullable_required_combinations.yaml", null, new ParseOptions()).getOpenAPI(); + JavaClientCodegen codegen = new JavaClientCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.setContainerDefaultToNull(containerDefaultToNull); + + ClientOptInput input = new ClientOptInput() + .openAPI(openAPI) + .config(codegen); + + DefaultGenerator generator = new DefaultGenerator(); + + Map files = generator.opts(input).generate().stream() + .collect(Collectors.toMap(File::getName, Function.identity())); + + JavaFileAssert.assertThat(files.get("Get200Response.java")).fileContains(expectedLines); + } + + @Test + public void testInvalidInput() { + Assertions.assertThatThrownBy( + () -> new ContainerDefaultEvaluator("NULLABLE") // we expect lowercase input + ).isExactlyInstanceOf(IllegalArgumentException.class); + } + +} diff --git a/modules/openapi-generator/src/test/resources/3_0/nullable_required_combinations.yaml b/modules/openapi-generator/src/test/resources/3_0/nullable_required_combinations.yaml new file mode 100644 index 000000000000..8389f8ca459f --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/nullable_required_combinations.yaml @@ -0,0 +1,57 @@ +openapi: 3.0.3 +info: + version: 1.0.0 + title: initialization demo +paths: + /: + get: + responses: + '200': + description: dummy + content: + application/json: + schema: + type: object + required: + - r1n0 + - r1n1 + - r1 + properties: + r0n0: + type: array + items: + type: string + nullable: false + r0n1: + type: array + items: + type: string + nullable: true + r1n0: + type: array + items: + type: string + nullable: false + r1n1: + type: array + items: + type: string + nullable: true + r0: + type: array + items: + type: string + r1: + type: array + items: + type: string + mySet: + uniqueItems: true + type: array + items: + type: string + nullable: true + myMap: + type: object + additionalProperties: true + nullable: true