diff --git a/docs/generators/python.md b/docs/generators/python.md index 10a032dadacb..3aab0db3ab52 100644 --- a/docs/generators/python.md +++ b/docs/generators/python.md @@ -22,6 +22,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |dateFormat|date format for query parameters| |%Y-%m-%d| |datetimeFormat|datetime format for query parameters| |%Y-%m-%dT%H:%M:%S%z| |disallowAdditionalPropertiesIfNotPresent|If false, the 'additionalProperties' implementation (set to true by default) is compliant with the OAS and JSON schema specifications. If true (default), keep the old (incorrect) behaviour that 'additionalProperties' is set to false by default.|
**false**
The 'additionalProperties' implementation is compliant with the OAS and JSON schema specifications.
**true**
Keep the old (incorrect) behaviour that 'additionalProperties' is set to false by default.
|true| +|enumUnknownDefaultCase|If the server adds new enum cases, that are unknown by an old spec/client, the client will fail to parse the network response.With this option enabled, each enum will have a new case, 'unknown_default_open_api', so that when the server sends an enum case that is not known by the client/spec, they can safely fallback to this case.| |false| |generateSourceCodeOnly|Specifies that only a library source code is to be generated.| |false| |hideGenerationTimestamp|Hides the generation timestamp when files are generated.| |true| |lazyImports|Enable lazy imports.| |false| diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java index 149ce57c317f..c5d1d77411e4 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java @@ -153,6 +153,7 @@ public PythonClientCodegen() { cliOptions.add(new CliOption(CodegenConstants.USE_ONEOF_DISCRIMINATOR_LOOKUP, CodegenConstants.USE_ONEOF_DISCRIMINATOR_LOOKUP_DESC).defaultValue("false")); cliOptions.add(new CliOption(POETRY1_FALLBACK, "Fallback to formatting pyproject.toml to Poetry 1.x format.")); cliOptions.add(new CliOption(LAZY_IMPORTS, "Enable lazy imports.").defaultValue(Boolean.FALSE.toString())); + cliOptions.add(new CliOption(CodegenConstants.ENUM_UNKNOWN_DEFAULT_CASE, CodegenConstants.ENUM_UNKNOWN_DEFAULT_CASE_DESC).defaultValue("false")); supportedLibraries.put("urllib3", "urllib3-based client"); supportedLibraries.put("asyncio", "asyncio-based client"); @@ -271,6 +272,10 @@ public void processOpts() { additionalProperties.put(LAZY_IMPORTS, Boolean.valueOf(additionalProperties.get(LAZY_IMPORTS).toString())); } + if (additionalProperties.containsKey(CodegenConstants.ENUM_UNKNOWN_DEFAULT_CASE)) { + setEnumUnknownDefaultCase(Boolean.parseBoolean(additionalProperties.get(CodegenConstants.ENUM_UNKNOWN_DEFAULT_CASE).toString())); + } + String modelPath = packagePath() + File.separatorChar + modelPackage.replace('.', File.separatorChar); String apiPath = packagePath() + File.separatorChar + apiPackage.replace('.', File.separatorChar); diff --git a/modules/openapi-generator/src/main/resources/python/model_enum.mustache b/modules/openapi-generator/src/main/resources/python/model_enum.mustache index 3f449b121a33..70307afae5a0 100644 --- a/modules/openapi-generator/src/main/resources/python/model_enum.mustache +++ b/modules/openapi-generator/src/main/resources/python/model_enum.mustache @@ -24,6 +24,13 @@ class {{classname}}({{vendorExtensions.x-py-enum-type}}, Enum): def from_json(cls, json_str: str) -> Self: """Create an instance of {{classname}} from a JSON string""" return cls(json.loads(json_str)) + {{#enumUnknownDefaultCase}} + + @classmethod + def _missing_(cls, value): + """Handle unknown enum values""" + return cls.UNKNOWN_DEFAULT_OPEN_API + {{/enumUnknownDefaultCase}} {{#defaultValue}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java index a18f2f40d11b..382cc2cebf50 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java @@ -26,6 +26,8 @@ import io.swagger.v3.parser.util.SchemaTypeUtil; import org.openapitools.codegen.*; import org.openapitools.codegen.config.CodegenConfigurator; +import org.openapitools.codegen.model.ModelMap; +import org.openapitools.codegen.model.ModelsMap; import org.openapitools.codegen.languages.PythonClientCodegen; import org.openapitools.codegen.languages.features.CXFServerFeatures; import org.testng.Assert; @@ -38,6 +40,8 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -47,6 +51,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.openapitools.codegen.TestUtils.assertFileContains; import static org.openapitools.codegen.TestUtils.assertFileExists; +import static org.openapitools.codegen.TestUtils.assertFileNotContains; public class PythonClientCodegenTest { @@ -685,4 +690,120 @@ public void testNonPoetry1LicenseFormat() throws IOException { // Verify it does NOT use the legacy string format TestUtils.assertFileNotContains(pyprojectPath, "license = \"BSD-3-Clause\""); } + + @Test(description = "test enumUnknownDefaultCase option") + public void testEnumUnknownDefaultCaseOption() { + final PythonClientCodegen codegen = new PythonClientCodegen(); + + // Test default value is false + codegen.processOpts(); + Assert.assertEquals(codegen.getEnumUnknownDefaultCase(), Boolean.FALSE); + + // Test setting via additionalProperties + codegen.additionalProperties().put(CodegenConstants.ENUM_UNKNOWN_DEFAULT_CASE, "true"); + codegen.processOpts(); + Assert.assertEquals(codegen.getEnumUnknownDefaultCase(), Boolean.TRUE); + } + + @Test(description = "test enum model generation with enumUnknownDefaultCase") + public void testEnumModelWithUnknownDefaultCase() { + final OpenAPI openAPI = TestUtils.parseFlattenSpec("src/test/resources/3_0/enum_unknown_default_case.yaml"); + final PythonClientCodegen codegen = new PythonClientCodegen(); + + // Enable enumUnknownDefaultCase + codegen.additionalProperties().put(CodegenConstants.ENUM_UNKNOWN_DEFAULT_CASE, "true"); + codegen.setOpenAPI(openAPI); + codegen.processOpts(); + + // Verify that enumUnknownDefaultCase is set + Assert.assertEquals(codegen.getEnumUnknownDefaultCase(), Boolean.TRUE); + + // Process all models to trigger enum processing + Map schemas = openAPI.getComponents().getSchemas(); + Map allModels = new HashMap<>(); + for (String modelName : schemas.keySet()) { + Schema schema = schemas.get(modelName); + CodegenModel cm = codegen.fromModel(modelName, schema); + ModelsMap modelsMap = new ModelsMap(); + modelsMap.setModels(Collections.singletonList(new ModelMap(Collections.singletonMap("model", cm)))); + allModels.put(modelName, modelsMap); + } + + // Post-process to add enumVars + allModels = codegen.postProcessAllModels(allModels); + + // Get the ColorEnum model + CodegenModel colorEnum = null; + for (Map.Entry entry : allModels.entrySet()) { + if ("ColorEnum".equals(entry.getKey())) { + colorEnum = entry.getValue().getModels().get(0).getModel(); + break; + } + } + + Assert.assertNotNull(colorEnum); + Assert.assertNotNull(colorEnum.allowableValues); + + List> enumVars = (List>) colorEnum.allowableValues.get("enumVars"); + Assert.assertNotNull(enumVars); + + // Check that we have the expected enum values including UNKNOWN_DEFAULT_OPEN_API + Assert.assertTrue(enumVars.stream().anyMatch(var -> "'RED'".equals(var.get("value")))); + Assert.assertTrue(enumVars.stream().anyMatch(var -> "'GREEN'".equals(var.get("value")))); + Assert.assertTrue(enumVars.stream().anyMatch(var -> "'BLUE'".equals(var.get("value")))); + Assert.assertTrue(enumVars.stream().anyMatch(var -> "'YELLOW'".equals(var.get("value")))); + Assert.assertTrue(enumVars.stream().anyMatch(var -> "'unknown_default_open_api'".equals(var.get("value")))); + } + + @Test(description = "test enum generation with enumUnknownDefaultCase enabled") + public void testEnumGenerationWithUnknownDefaultCase() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + String outputPath = output.getAbsolutePath().replace('\\', '/'); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("python") + .setInputSpec("src/test/resources/3_0/enum_unknown_default_case.yaml") + .setOutputDir(outputPath) + .addAdditionalProperty(CodegenConstants.ENUM_UNKNOWN_DEFAULT_CASE, "true"); + + DefaultGenerator generator = new DefaultGenerator(); + List files = generator.opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + Path enumFile = Paths.get(outputPath, "openapi_client", "models", "color_enum.py"); + + // Check that UNKNOWN_DEFAULT_OPEN_API is added (with single quotes as Python generates) + TestUtils.assertFileContains(enumFile, "UNKNOWN_DEFAULT_OPEN_API = 'unknown_default_open_api'"); + + // Check that _missing_ method is added + TestUtils.assertFileContains(enumFile, "@classmethod"); + TestUtils.assertFileContains(enumFile, "def _missing_(cls, value):"); + TestUtils.assertFileContains(enumFile, "return cls.UNKNOWN_DEFAULT_OPEN_API"); + } + + @Test(description = "test enum generation with enumUnknownDefaultCase disabled") + public void testEnumGenerationWithoutUnknownDefaultCase() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + String outputPath = output.getAbsolutePath().replace('\\', '/'); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("python") + .setInputSpec("src/test/resources/3_0/enum_unknown_default_case.yaml") + .setOutputDir(outputPath) + .addAdditionalProperty(CodegenConstants.ENUM_UNKNOWN_DEFAULT_CASE, "false"); + + DefaultGenerator generator = new DefaultGenerator(); + List files = generator.opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + Path enumFile = Paths.get(outputPath, "openapi_client", "models", "color_enum.py"); + + // Check that UNKNOWN_DEFAULT_OPEN_API is NOT added + TestUtils.assertFileNotContains(enumFile, "UNKNOWN_DEFAULT_OPEN_API"); + + // Check that _missing_ method is NOT added + TestUtils.assertFileNotContains(enumFile, "def _missing_(cls, value):"); + } } diff --git a/modules/openapi-generator/src/test/resources/3_0/enum_unknown_default_case.yaml b/modules/openapi-generator/src/test/resources/3_0/enum_unknown_default_case.yaml new file mode 100644 index 000000000000..05f23cbaef7b --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/enum_unknown_default_case.yaml @@ -0,0 +1,56 @@ +openapi: 3.0.0 +info: + title: Enum Test API + description: API for testing enum generation with enumUnknownDefaultCase + version: 1.0.0 +paths: + /colors: + get: + summary: Get color + operationId: getColor + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/ColorResponse' +components: + schemas: + ColorResponse: + type: object + required: + - color + - status + properties: + color: + $ref: '#/components/schemas/ColorEnum' + status: + $ref: '#/components/schemas/StatusEnum' + priority: + $ref: '#/components/schemas/PriorityEnum' + ColorEnum: + type: string + description: Available colors + enum: + - RED + - GREEN + - BLUE + - YELLOW + StatusEnum: + type: string + description: Status values + enum: + - PENDING + - APPROVED + - REJECTED + - IN_PROGRESS + PriorityEnum: + type: integer + description: Priority levels + enum: + - 1 + - 2 + - 3 + - 4 + - 5 \ No newline at end of file