From db43c65c8dcc45dff1d9c8bb53f9cd2d612fdcd5 Mon Sep 17 00:00:00 2001 From: Zach Hornback Date: Thu, 30 Jul 2026 22:28:09 -0500 Subject: [PATCH 01/13] [python-fastapi] Use ... as default value rather than None (#22494) (#23398) * [python-fastapi] Default default value ... instead of None (#22494) With various FastAPI parameters (Query, Path, Header, Cookie, Body, Form, and File) the default value of None is a valid value, but signifies that the parameter is optional. To allow for required parameters, default should either not be set at all or be set to '...' (ellipsis). Issue: https://github.com/OpenAPITools/openapi-generator/issues/22494 * Update samples after previous changes * Add generated newlines * Adjust test to expect ... Since the initial PR, a new test was added expecting None rather than '...'. Changing test and samples as needed. * Update test and samples after last change A new test needed to be adjusted, the Form was required so ... is more appropriate than None. --- .../endpoint_argument_definition.mustache | 2 +- .../PythonFastAPIServerCodegenTest.java | 4 +- .../src/openapi_server/apis/fake_api.py | 6 ++- .../src/openapi_server/apis/pet_api.py | 39 ++++++++++++------- .../src/openapi_server/apis/store_api.py | 9 +++-- .../src/openapi_server/apis/user_api.py | 27 ++++++++----- 6 files changed, 57 insertions(+), 30 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/python-fastapi/endpoint_argument_definition.mustache b/modules/openapi-generator/src/main/resources/python-fastapi/endpoint_argument_definition.mustache index e1a76b210fd7..e398eca12c82 100644 --- a/modules/openapi-generator/src/main/resources/python-fastapi/endpoint_argument_definition.mustache +++ b/modules/openapi-generator/src/main/resources/python-fastapi/endpoint_argument_definition.mustache @@ -1 +1 @@ -{{#isPathParam}}{{baseName}}{{/isPathParam}}{{^isPathParam}}{{paramName}}{{/isPathParam}}: {{>param_type}} = {{#isPathParam}}Path{{/isPathParam}}{{#isHeaderParam}}Header{{/isHeaderParam}}{{#isFormParam}}{{#isFile}}File{{/isFile}}{{^isFile}}Form{{/isFile}}{{/isFormParam}}{{#isQueryParam}}Query{{/isQueryParam}}{{#isCookieParam}}Cookie{{/isCookieParam}}{{#isBodyParam}}Body{{/isBodyParam}}({{&defaultValue}}{{^defaultValue}}{{#isPathParam}}...{{/isPathParam}}{{^isPathParam}}{{#isFile}}{{#required}}...{{/required}}{{^required}}None{{/required}}{{/isFile}}{{^isFile}}None{{/isFile}}{{/isPathParam}}{{/defaultValue}}, description="{{description}}"{{#isQueryParam}}, alias="{{baseName}}"{{/isQueryParam}}{{#isFormParam}}, alias="{{baseName}}"{{/isFormParam}}{{#isLong}}{{#minimum}}, ge={{.}}{{/minimum}}{{#maximum}}, le={{.}}{{/maximum}}{{/isLong}}{{#isInteger}}{{#minimum}}, ge={{.}}{{/minimum}}{{#maximum}}, le={{.}}{{/maximum}}{{/isInteger}}{{#vendorExtensions.x-regex}}, regex=r"{{.}}"{{/vendorExtensions.x-regex}}{{#minLength}}, min_length={{.}}{{/minLength}}{{#maxLength}}, max_length={{.}}{{/maxLength}}{{^isBodyParam}}{{#vendorExtensions.x-py-example}}, examples=[{{{.}}}]{{/vendorExtensions.x-py-example}}{{/isBodyParam}}{{#isBodyParam}}{{#vendorExtensions.x-py-fastapi-example}}, examples=[{{{.}}}]{{/vendorExtensions.x-py-fastapi-example}}{{/isBodyParam}}) \ No newline at end of file +{{#isPathParam}}{{baseName}}{{/isPathParam}}{{^isPathParam}}{{paramName}}{{/isPathParam}}: {{>param_type}} = {{#isPathParam}}Path{{/isPathParam}}{{#isHeaderParam}}Header{{/isHeaderParam}}{{#isFormParam}}{{#isFile}}File{{/isFile}}{{^isFile}}Form{{/isFile}}{{/isFormParam}}{{#isQueryParam}}Query{{/isQueryParam}}{{#isCookieParam}}Cookie{{/isCookieParam}}{{#isBodyParam}}Body{{/isBodyParam}}({{&defaultValue}}{{^defaultValue}}{{#required}}...{{/required}}{{^required}}None{{/required}}{{/defaultValue}}, description="{{description}}"{{#isQueryParam}}, alias="{{baseName}}"{{/isQueryParam}}{{#isFormParam}}, alias="{{baseName}}"{{/isFormParam}}{{#isLong}}{{#minimum}}, ge={{.}}{{/minimum}}{{#maximum}}, le={{.}}{{/maximum}}{{/isLong}}{{#isInteger}}{{#minimum}}, ge={{.}}{{/minimum}}{{#maximum}}, le={{.}}{{/maximum}}{{/isInteger}}{{#vendorExtensions.x-regex}}, regex=r"{{.}}"{{/vendorExtensions.x-regex}}{{#minLength}}, min_length={{.}}{{/minLength}}{{#maxLength}}, max_length={{.}}{{/maxLength}}{{^isBodyParam}}{{#vendorExtensions.x-py-example}}, examples=[{{{.}}}]{{/vendorExtensions.x-py-example}}{{/isBodyParam}}{{#isBodyParam}}{{#vendorExtensions.x-py-fastapi-example}}, examples=[{{{.}}}]{{/vendorExtensions.x-py-fastapi-example}}{{/isBodyParam}}) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFastAPIServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFastAPIServerCodegenTest.java index 34d19499cec7..26b9f73e20be 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFastAPIServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFastAPIServerCodegenTest.java @@ -71,7 +71,7 @@ public void testRequestBodyExampleInBodyMetadata() throws IOException { final Path p = Paths.get(outputPath + "src/openapi_server/apis/user_api.py"); assertFileExists(p); - assertFileContains(p, "user: Annotated[List[User], Field(description=\"List of user object\")] = Body(None, description=\"List of user object\", examples=[[{\"username\": \"foo\"}, {\"username\": \"bar\"}]])"); + assertFileContains(p, "user: Annotated[List[User], Field(description=\"List of user object\")] = Body(..., description=\"List of user object\", examples=[[{\"username\": \"foo\"}, {\"username\": \"bar\"}]])"); assertFileNotContains(p, "examples=[[[],"); } @@ -122,7 +122,7 @@ public void testBinaryMultipartFieldUsesUploadFile() throws IOException { assertFileContains(api, "image: Optional[UploadFile] = File(None, description=\"Optional image upload\", alias=\"image\")"); // Sibling non-binary form fields still use Form() - assertFileContains(api, "collection_name: Annotated[StrictStr, Field(description=\"Name of the collection\")] = Form(None, description=\"Name of the collection\", alias=\"collection_name\")"); + assertFileContains(api, "collection_name: Annotated[StrictStr, Field(description=\"Name of the collection\")] = Form(..., description=\"Name of the collection\", alias=\"collection_name\")"); // The legacy client-side bytes union must not appear for the server signature assertFileNotContains(api, "Union[StrictBytes, StrictStr, Tuple[StrictStr, StrictBytes]]"); diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py index 0e3f8d51b319..966ef8084147 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/fake_api.py @@ -46,8 +46,10 @@ response_model_by_alias=True, ) async def fake_query_param_default( - has_default: Annotated[Optional[StrictStr], Field(description="has default value")] = Query('Hello World', description="has default value", alias="hasDefault"), - no_default: Annotated[Optional[StrictStr], Field(description="no default value")] = Query(None, description="no default value", alias="noDefault"), + has_default: Annotated[Optional[StrictStr], Field(description="has default value")] = Query('Hello World', description="has default value", alias="hasDefault") +, + no_default: Annotated[Optional[StrictStr], Field(description="no default value")] = Query(None, description="no default value", alias="noDefault") +, ) -> None: """""" if not BaseFakeApi.subclasses: diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py index 7e9fe5def812..7a6d8bdc4ea7 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py @@ -51,7 +51,8 @@ response_model_by_alias=True, ) async def update_pet( - pet: Annotated[Pet, Field(description="Pet object that needs to be added to the store")] = Body(None, description="Pet object that needs to be added to the store"), + pet: Annotated[Pet, Field(description="Pet object that needs to be added to the store")] = Body(..., description="Pet object that needs to be added to the store") +, token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["write:pets", "read:pets"] ), @@ -73,7 +74,8 @@ async def update_pet( response_model_by_alias=True, ) async def add_pet( - pet: Annotated[Pet, Field(description="Pet object that needs to be added to the store")] = Body(None, description="Pet object that needs to be added to the store"), + pet: Annotated[Pet, Field(description="Pet object that needs to be added to the store")] = Body(..., description="Pet object that needs to be added to the store") +, token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["write:pets", "read:pets"] ), @@ -95,7 +97,8 @@ async def add_pet( response_model_by_alias=True, ) async def find_pets_by_status( - status: Annotated[List[StrictStr], Field(description="Status values that need to be considered for filter")] = Query(None, description="Status values that need to be considered for filter", alias="status"), + status: Annotated[List[StrictStr], Field(description="Status values that need to be considered for filter")] = Query(..., description="Status values that need to be considered for filter", alias="status") +, token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["read:pets"] ), @@ -117,7 +120,8 @@ async def find_pets_by_status( response_model_by_alias=True, ) async def find_pets_by_tags( - tags: Annotated[List[StrictStr], Field(description="Tags to filter by")] = Query(None, description="Tags to filter by", alias="tags"), + tags: Annotated[List[StrictStr], Field(description="Tags to filter by")] = Query(..., description="Tags to filter by", alias="tags") +, token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["read:pets"] ), @@ -140,7 +144,8 @@ async def find_pets_by_tags( response_model_by_alias=True, ) async def get_pet_by_id( - petId: Annotated[StrictInt, Field(description="ID of pet to return")] = Path(..., description="ID of pet to return"), + petId: Annotated[StrictInt, Field(description="ID of pet to return")] = Path(..., description="ID of pet to return") +, token_api_key: TokenModel = Security( get_token_api_key ), @@ -161,9 +166,12 @@ async def get_pet_by_id( response_model_by_alias=True, ) async def update_pet_with_form( - petId: Annotated[StrictInt, Field(description="ID of pet that needs to be updated")] = Path(..., description="ID of pet that needs to be updated"), - name: Annotated[Optional[StrictStr], Field(description="Updated name of the pet")] = Form(None, description="Updated name of the pet", alias="name"), - status: Annotated[Optional[StrictStr], Field(description="Updated status of the pet")] = Form(None, description="Updated status of the pet", alias="status"), + petId: Annotated[StrictInt, Field(description="ID of pet that needs to be updated")] = Path(..., description="ID of pet that needs to be updated") +, + name: Annotated[Optional[StrictStr], Field(description="Updated name of the pet")] = Form(None, description="Updated name of the pet", alias="name") +, + status: Annotated[Optional[StrictStr], Field(description="Updated status of the pet")] = Form(None, description="Updated status of the pet", alias="status") +, token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["write:pets", "read:pets"] ), @@ -184,8 +192,10 @@ async def update_pet_with_form( response_model_by_alias=True, ) async def delete_pet( - petId: Annotated[StrictInt, Field(description="Pet id to delete")] = Path(..., description="Pet id to delete"), - api_key: Optional[StrictStr] = Header(None, description=""), + petId: Annotated[StrictInt, Field(description="Pet id to delete")] = Path(..., description="Pet id to delete") +, + api_key: Optional[StrictStr] = Header(None, description="") +, token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["write:pets", "read:pets"] ), @@ -206,9 +216,12 @@ async def delete_pet( response_model_by_alias=True, ) async def upload_file( - petId: Annotated[StrictInt, Field(description="ID of pet to update")] = Path(..., description="ID of pet to update"), - additional_metadata: Annotated[Optional[StrictStr], Field(description="Additional data to pass to server")] = Form(None, description="Additional data to pass to server", alias="additionalMetadata"), - file: Optional[UploadFile] = File(None, description="file to upload", alias="file"), + petId: Annotated[StrictInt, Field(description="ID of pet to update")] = Path(..., description="ID of pet to update") +, + additional_metadata: Annotated[Optional[StrictStr], Field(description="Additional data to pass to server")] = Form(None, description="Additional data to pass to server", alias="additionalMetadata") +, + file: Optional[UploadFile] = File(None, description="file to upload", alias="file") +, token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["write:pets", "read:pets"] ), diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py index 3d2744c2e028..6dbefdcaa1a9 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/store_api.py @@ -67,7 +67,8 @@ async def get_inventory( response_model_by_alias=True, ) async def place_order( - order: Annotated[Order, Field(description="order placed for purchasing the pet")] = Body(None, description="order placed for purchasing the pet"), + order: Annotated[Order, Field(description="order placed for purchasing the pet")] = Body(..., description="order placed for purchasing the pet") +, ) -> Order: """""" if not BaseStoreApi.subclasses: @@ -87,7 +88,8 @@ async def place_order( response_model_by_alias=True, ) async def get_order_by_id( - orderId: Annotated[int, Field(le=5, strict=True, ge=1, description="ID of pet that needs to be fetched")] = Path(..., description="ID of pet that needs to be fetched", ge=1, le=5), + orderId: Annotated[int, Field(le=5, strict=True, ge=1, description="ID of pet that needs to be fetched")] = Path(..., description="ID of pet that needs to be fetched", ge=1, le=5) +, ) -> Order: """For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions""" if not BaseStoreApi.subclasses: @@ -106,7 +108,8 @@ async def get_order_by_id( response_model_by_alias=True, ) async def delete_order( - orderId: Annotated[StrictStr, Field(description="ID of the order that needs to be deleted")] = Path(..., description="ID of the order that needs to be deleted"), + orderId: Annotated[StrictStr, Field(description="ID of the order that needs to be deleted")] = Path(..., description="ID of the order that needs to be deleted") +, ) -> None: """For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors""" if not BaseStoreApi.subclasses: diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py index e7d5ea8011b5..06634461bfa4 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/user_api.py @@ -46,7 +46,8 @@ response_model_by_alias=True, ) async def create_user( - user: Annotated[User, Field(description="Created user object")] = Body(None, description="Created user object"), + user: Annotated[User, Field(description="Created user object")] = Body(..., description="Created user object") +, token_api_key: TokenModel = Security( get_token_api_key ), @@ -67,7 +68,8 @@ async def create_user( response_model_by_alias=True, ) async def create_users_with_array_input( - user: Annotated[List[User], Field(description="List of user object")] = Body(None, description="List of user object"), + user: Annotated[List[User], Field(description="List of user object")] = Body(..., description="List of user object") +, token_api_key: TokenModel = Security( get_token_api_key ), @@ -88,7 +90,8 @@ async def create_users_with_array_input( response_model_by_alias=True, ) async def create_users_with_list_input( - user: Annotated[List[User], Field(description="List of user object")] = Body(None, description="List of user object"), + user: Annotated[List[User], Field(description="List of user object")] = Body(..., description="List of user object") +, token_api_key: TokenModel = Security( get_token_api_key ), @@ -110,8 +113,10 @@ async def create_users_with_list_input( response_model_by_alias=True, ) async def login_user( - username: Annotated[str, Field(strict=True, description="The user name for login")] = Query(None, description="The user name for login", alias="username", regex=r"^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$"), - password: Annotated[StrictStr, Field(description="The password for login in clear text")] = Query(None, description="The password for login in clear text", alias="password"), + username: Annotated[str, Field(strict=True, description="The user name for login")] = Query(..., description="The user name for login", alias="username", regex=r"^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$") +, + password: Annotated[StrictStr, Field(description="The password for login in clear text")] = Query(..., description="The password for login in clear text", alias="password") +, ) -> str: """""" if not BaseUserApi.subclasses: @@ -151,7 +156,8 @@ async def logout_user( response_model_by_alias=True, ) async def get_user_by_name( - username: Annotated[StrictStr, Field(description="The name that needs to be fetched. Use user1 for testing.")] = Path(..., description="The name that needs to be fetched. Use user1 for testing."), + username: Annotated[StrictStr, Field(description="The name that needs to be fetched. Use user1 for testing.")] = Path(..., description="The name that needs to be fetched. Use user1 for testing.") +, ) -> User: """""" if not BaseUserApi.subclasses: @@ -170,8 +176,10 @@ async def get_user_by_name( response_model_by_alias=True, ) async def update_user( - username: Annotated[StrictStr, Field(description="name that need to be deleted")] = Path(..., description="name that need to be deleted"), - user: Annotated[User, Field(description="Updated user object")] = Body(None, description="Updated user object"), + username: Annotated[StrictStr, Field(description="name that need to be deleted")] = Path(..., description="name that need to be deleted") +, + user: Annotated[User, Field(description="Updated user object")] = Body(..., description="Updated user object") +, token_api_key: TokenModel = Security( get_token_api_key ), @@ -193,7 +201,8 @@ async def update_user( response_model_by_alias=True, ) async def delete_user( - username: Annotated[StrictStr, Field(description="The name that needs to be deleted")] = Path(..., description="The name that needs to be deleted"), + username: Annotated[StrictStr, Field(description="The name that needs to be deleted")] = Path(..., description="The name that needs to be deleted") +, token_api_key: TokenModel = Security( get_token_api_key ), From 24f1f18dafb1ec1740aa1e0ad94412531919e6c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:34:47 +0800 Subject: [PATCH 02/13] build(deps): bump docker/login-action from 4 to 4.5.2 (#24534) Bumps [docker/login-action](https://github.com/docker/login-action) from 4 to 4.5.2. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v4...v4.5.2) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.5.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 7e88bfab6869..cb12395c6fc0 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -35,7 +35,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Login to DockerHub - uses: docker/login-action@v4 + uses: docker/login-action@v4.5.2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} From 0a4617721d98e06885c1add18527e45f6d8778b0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:34:56 +0800 Subject: [PATCH 03/13] build(deps): bump sbt/setup-sbt from 1 to 1.5.4 (#24535) Bumps [sbt/setup-sbt](https://github.com/sbt/setup-sbt) from 1 to 1.5.4. - [Release notes](https://github.com/sbt/setup-sbt/releases) - [Commits](https://github.com/sbt/setup-sbt/compare/v1...v1.5.4) --- updated-dependencies: - dependency-name: sbt/setup-sbt dependency-version: 1.5.4 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/samples-java-sbt.yaml | 2 +- .github/workflows/samples-scala-client.yaml | 2 +- .github/workflows/samples-scala-server.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/samples-java-sbt.yaml b/.github/workflows/samples-java-sbt.yaml index 9dec0ab90dae..12609fc48b5b 100644 --- a/.github/workflows/samples-java-sbt.yaml +++ b/.github/workflows/samples-java-sbt.yaml @@ -24,7 +24,7 @@ jobs: distribution: 'temurin' java-version: 17 - name: Setup sbt launcher - uses: sbt/setup-sbt@v1 + uses: sbt/setup-sbt@v1.5.4 - name: Cache maven dependencies uses: actions/cache@v6 env: diff --git a/.github/workflows/samples-scala-client.yaml b/.github/workflows/samples-scala-client.yaml index 2cf0647dadb9..4a0a48c13def 100644 --- a/.github/workflows/samples-scala-client.yaml +++ b/.github/workflows/samples-scala-client.yaml @@ -59,7 +59,7 @@ jobs: distribution: 'temurin' java-version: 11 - name: Setup sbt launcher - uses: sbt/setup-sbt@v1 + uses: sbt/setup-sbt@v1.5.4 - name: Cache maven dependencies uses: actions/cache@v6 env: diff --git a/.github/workflows/samples-scala-server.yaml b/.github/workflows/samples-scala-server.yaml index cd89bb461a26..f412eb1cd21a 100644 --- a/.github/workflows/samples-scala-server.yaml +++ b/.github/workflows/samples-scala-server.yaml @@ -29,7 +29,7 @@ jobs: distribution: 'temurin' java-version: 11 - name: Setup sbt launcher - uses: sbt/setup-sbt@v1 + uses: sbt/setup-sbt@v1.5.4 - name: Cache maven dependencies uses: actions/cache@v6 env: From ef78e45ecf53e12f470191434fc6175beae726cb Mon Sep 17 00:00:00 2001 From: Nicolas Rodriguez Date: Fri, 31 Jul 2026 10:18:36 +0200 Subject: [PATCH 04/13] [ruby-nextgen] Let Configuration carry Faraday SSL options (#24536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A server presenting a certificate issued by a private CA is unreachable by a generated client: the default trust store holds public authorities only, and Configuration exposed no way to say otherwise. The escape hatch it does offer, `Configuration#use`, cannot help — Faraday settles TLS when it builds the connection, before any middleware runs. `ssl` is handed to Faraday verbatim, so anything the adapter understands works, `{ ca_file: ... }` and `{ verify: false }` alike. It defaults to an empty hash, which is what Faraday assumes when the option is absent, so generated clients are unchanged unless they set it. - configuration.mustache: add the :ssl accessor, initialized to {} - connection.mustache: pass it to Faraday.new as a connection option - README.mustache: document it in the configuration example - Regenerate the petstore and qdrant samples Co-authored-by: Claude Opus 5 (1M context) --- .../src/main/resources/ruby-nextgen/README.mustache | 3 +++ .../src/main/resources/ruby-nextgen/configuration.mustache | 7 ++++++- .../src/main/resources/ruby-nextgen/connection.mustache | 4 +++- samples/client/others/ruby-nextgen-qdrant/README.md | 3 +++ .../others/ruby-nextgen-qdrant/lib/qdrant/configuration.rb | 7 ++++++- .../others/ruby-nextgen-qdrant/lib/qdrant/connection.rb | 4 +++- samples/client/petstore/ruby-nextgen/README.md | 3 +++ .../petstore/ruby-nextgen/lib/petstore/configuration.rb | 7 ++++++- .../petstore/ruby-nextgen/lib/petstore/connection.rb | 4 +++- 9 files changed, 36 insertions(+), 6 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/ruby-nextgen/README.mustache b/modules/openapi-generator/src/main/resources/ruby-nextgen/README.mustache index efdaf524082f..d1dc213b3efc 100644 --- a/modules/openapi-generator/src/main/resources/ruby-nextgen/README.mustache +++ b/modules/openapi-generator/src/main/resources/ruby-nextgen/README.mustache @@ -49,6 +49,9 @@ coexist in the same process — there is no global state. ```ruby client = {{moduleName}}::Client.new(base_url: "{{{basePath}}}") do |config| config.timeout = 10 + # Handed to Faraday verbatim. Needed when the server presents a certificate issued by a + # private CA, which the default trust store does not know: + # config.ssl = { ca_file: "/path/to/root.crt" } {{#authMethods}} {{#isApiKey}} config.api_key = "YOUR_API_KEY" diff --git a/modules/openapi-generator/src/main/resources/ruby-nextgen/configuration.mustache b/modules/openapi-generator/src/main/resources/ruby-nextgen/configuration.mustache index bb0242bceb8e..2a1694b2b6b1 100644 --- a/modules/openapi-generator/src/main/resources/ruby-nextgen/configuration.mustache +++ b/modules/openapi-generator/src/main/resources/ruby-nextgen/configuration.mustache @@ -2,13 +2,18 @@ module {{moduleName}} class Configuration - attr_accessor :base_url, :timeout, :logger, :debugging, :query_array_encoding{{#authMethods}}{{#isApiKey}}, :api_key{{/isApiKey}}{{#isBasicBearer}}, :access_token{{/isBasicBearer}}{{#isBasicBasic}}, :username, :password{{/isBasicBasic}}{{/authMethods}} + # `ssl` is handed to Faraday verbatim, e.g. `{ ca_file: "/path/to/root.crt" }` for a server + # whose certificate is issued by a private CA. Such a server is otherwise unreachable — the + # default trust store holds public authorities only — and no middleware can make up for it: + # TLS is settled when the connection is built, before any middleware runs. + attr_accessor :base_url, :timeout, :logger, :debugging, :query_array_encoding, :ssl{{#authMethods}}{{#isApiKey}}, :api_key{{/isApiKey}}{{#isBasicBearer}}, :access_token{{/isBasicBearer}}{{#isBasicBasic}}, :username, :password{{/isBasicBasic}}{{/authMethods}} def initialize(base_url: nil, **options) @base_url = base_url || '{{{basePath}}}' @timeout = 60 @query_array_encoding = :repeat @debugging = false + @ssl = {} @middlewares = [] options.each do |k, v| raise ArgumentError, "unknown configuration option: #{k}" unless respond_to?("#{k}=") diff --git a/modules/openapi-generator/src/main/resources/ruby-nextgen/connection.mustache b/modules/openapi-generator/src/main/resources/ruby-nextgen/connection.mustache index 74c86fbff0c2..37ec2c76b448 100644 --- a/modules/openapi-generator/src/main/resources/ruby-nextgen/connection.mustache +++ b/modules/openapi-generator/src/main/resources/ruby-nextgen/connection.mustache @@ -10,7 +10,9 @@ module {{moduleName}} # relative (their leading slash is stripped in #call) and resolved against it. base = configuration.base_url base += '/' unless base.end_with?('/') - @faraday = Faraday.new(url: base) do |conn| + # `ssl` belongs to the connection options and not to `configure_faraday`: Faraday settles + # TLS when it builds the connection, so a middleware could never supply it. + @faraday = Faraday.new(url: base, ssl: configuration.ssl) do |conn| configuration.configure_faraday(conn) end end diff --git a/samples/client/others/ruby-nextgen-qdrant/README.md b/samples/client/others/ruby-nextgen-qdrant/README.md index 69b0fffdfce6..cbb0f80661d8 100644 --- a/samples/client/others/ruby-nextgen-qdrant/README.md +++ b/samples/client/others/ruby-nextgen-qdrant/README.md @@ -49,6 +49,9 @@ coexist in the same process — there is no global state. ```ruby client = Qdrant::Client.new(base_url: "http://localhost:6333") do |config| config.timeout = 10 + # Handed to Faraday verbatim. Needed when the server presents a certificate issued by a + # private CA, which the default trust store does not know: + # config.ssl = { ca_file: "/path/to/root.crt" } config.api_key = "YOUR_API_KEY" config.access_token = "YOUR_ACCESS_TOKEN" end diff --git a/samples/client/others/ruby-nextgen-qdrant/lib/qdrant/configuration.rb b/samples/client/others/ruby-nextgen-qdrant/lib/qdrant/configuration.rb index 3b7fc40d0dba..2649242b82b2 100644 --- a/samples/client/others/ruby-nextgen-qdrant/lib/qdrant/configuration.rb +++ b/samples/client/others/ruby-nextgen-qdrant/lib/qdrant/configuration.rb @@ -2,13 +2,18 @@ module Qdrant class Configuration - attr_accessor :base_url, :timeout, :logger, :debugging, :query_array_encoding, :api_key, :access_token + # `ssl` is handed to Faraday verbatim, e.g. `{ ca_file: "/path/to/root.crt" }` for a server + # whose certificate is issued by a private CA. Such a server is otherwise unreachable — the + # default trust store holds public authorities only — and no middleware can make up for it: + # TLS is settled when the connection is built, before any middleware runs. + attr_accessor :base_url, :timeout, :logger, :debugging, :query_array_encoding, :ssl, :api_key, :access_token def initialize(base_url: nil, **options) @base_url = base_url || 'http://localhost:6333' @timeout = 60 @query_array_encoding = :repeat @debugging = false + @ssl = {} @middlewares = [] options.each do |k, v| raise ArgumentError, "unknown configuration option: #{k}" unless respond_to?("#{k}=") diff --git a/samples/client/others/ruby-nextgen-qdrant/lib/qdrant/connection.rb b/samples/client/others/ruby-nextgen-qdrant/lib/qdrant/connection.rb index 08c3089b9f44..16e028e92939 100644 --- a/samples/client/others/ruby-nextgen-qdrant/lib/qdrant/connection.rb +++ b/samples/client/others/ruby-nextgen-qdrant/lib/qdrant/connection.rb @@ -10,7 +10,9 @@ def initialize(configuration) # relative (their leading slash is stripped in #call) and resolved against it. base = configuration.base_url base += '/' unless base.end_with?('/') - @faraday = Faraday.new(url: base) do |conn| + # `ssl` belongs to the connection options and not to `configure_faraday`: Faraday settles + # TLS when it builds the connection, so a middleware could never supply it. + @faraday = Faraday.new(url: base, ssl: configuration.ssl) do |conn| configuration.configure_faraday(conn) end end diff --git a/samples/client/petstore/ruby-nextgen/README.md b/samples/client/petstore/ruby-nextgen/README.md index 44f5754c89b2..8252bf6e12a9 100644 --- a/samples/client/petstore/ruby-nextgen/README.md +++ b/samples/client/petstore/ruby-nextgen/README.md @@ -49,6 +49,9 @@ coexist in the same process — there is no global state. ```ruby client = Petstore::Client.new(base_url: "http://petstore.swagger.io/v2") do |config| config.timeout = 10 + # Handed to Faraday verbatim. Needed when the server presents a certificate issued by a + # private CA, which the default trust store does not know: + # config.ssl = { ca_file: "/path/to/root.crt" } config.api_key = "YOUR_API_KEY" end ``` diff --git a/samples/client/petstore/ruby-nextgen/lib/petstore/configuration.rb b/samples/client/petstore/ruby-nextgen/lib/petstore/configuration.rb index d83fb7235dd3..d741267c89af 100644 --- a/samples/client/petstore/ruby-nextgen/lib/petstore/configuration.rb +++ b/samples/client/petstore/ruby-nextgen/lib/petstore/configuration.rb @@ -2,13 +2,18 @@ module Petstore class Configuration - attr_accessor :base_url, :timeout, :logger, :debugging, :query_array_encoding, :api_key + # `ssl` is handed to Faraday verbatim, e.g. `{ ca_file: "/path/to/root.crt" }` for a server + # whose certificate is issued by a private CA. Such a server is otherwise unreachable — the + # default trust store holds public authorities only — and no middleware can make up for it: + # TLS is settled when the connection is built, before any middleware runs. + attr_accessor :base_url, :timeout, :logger, :debugging, :query_array_encoding, :ssl, :api_key def initialize(base_url: nil, **options) @base_url = base_url || 'http://petstore.swagger.io/v2' @timeout = 60 @query_array_encoding = :repeat @debugging = false + @ssl = {} @middlewares = [] options.each do |k, v| raise ArgumentError, "unknown configuration option: #{k}" unless respond_to?("#{k}=") diff --git a/samples/client/petstore/ruby-nextgen/lib/petstore/connection.rb b/samples/client/petstore/ruby-nextgen/lib/petstore/connection.rb index 419de06b29f6..457d2b6b2a7a 100644 --- a/samples/client/petstore/ruby-nextgen/lib/petstore/connection.rb +++ b/samples/client/petstore/ruby-nextgen/lib/petstore/connection.rb @@ -10,7 +10,9 @@ def initialize(configuration) # relative (their leading slash is stripped in #call) and resolved against it. base = configuration.base_url base += '/' unless base.end_with?('/') - @faraday = Faraday.new(url: base) do |conn| + # `ssl` belongs to the connection options and not to `configure_faraday`: Faraday settles + # TLS when it builds the connection, so a middleware could never supply it. + @faraday = Faraday.new(url: base, ssl: configuration.ssl) do |conn| configuration.configure_faraday(conn) end end From 8629905570b4ff4e70fa053332ade2fc3296c613 Mon Sep 17 00:00:00 2001 From: Tobias Stadler <28538704+devtobi@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:33:51 +0200 Subject: [PATCH 05/13] [typescript-fetch] Add required + dataType properties to validation attributes (#24533) * :sparkles: Added required and dataType properties to validationAttributes.mustache in typescript-fetch generator * Updated samples * Fix HTML escaping --- .../typescript-fetch/validationAttributes.mustache | 8 ++++++++ .../builds/validation-attributes/models/Category.ts | 3 +++ .../validation-attributes/models/ModelApiResponse.ts | 2 ++ .../builds/validation-attributes/models/Order.ts | 2 ++ .../builds/validation-attributes/models/Pet.ts | 4 ++++ .../builds/validation-attributes/models/Tag.ts | 2 ++ .../builds/validation-attributes/models/User.ts | 5 +++++ 7 files changed, 26 insertions(+) diff --git a/modules/openapi-generator/src/main/resources/typescript-fetch/validationAttributes.mustache b/modules/openapi-generator/src/main/resources/typescript-fetch/validationAttributes.mustache index 6dddc042bac0..9b338f359c3c 100644 --- a/modules/openapi-generator/src/main/resources/typescript-fetch/validationAttributes.mustache +++ b/modules/openapi-generator/src/main/resources/typescript-fetch/validationAttributes.mustache @@ -2,6 +2,8 @@ export const {{classname}}PropertyValidationAttributesMap: { [property: string]: { + dataType?: string, + required?: boolean, maxLength?: number, minLength?: number, pattern?: string, @@ -18,6 +20,12 @@ export const {{classname}}PropertyValidationAttributesMap: { {{#vars}} {{#hasValidation}} {{name}}: { + {{#dataType}} + dataType: "{{{dataType}}}", + {{/dataType}} + {{#required}} + required: {{required}}, + {{/required}} {{#maxLength}} maxLength: {{maxLength}}, {{/maxLength}} diff --git a/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/Category.ts b/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/Category.ts index 16ebec06a1d7..7a5fb6a636ea 100644 --- a/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/Category.ts +++ b/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/Category.ts @@ -30,6 +30,8 @@ export interface Category { } export const CategoryPropertyValidationAttributesMap: { [property: string]: { + dataType?: string, + required?: boolean, maxLength?: number, minLength?: number, pattern?: string, @@ -44,6 +46,7 @@ export const CategoryPropertyValidationAttributesMap: { } } = { name: { + dataType: "string", pattern: '/^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$/', }, } diff --git a/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/ModelApiResponse.ts b/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/ModelApiResponse.ts index 90385e16a588..94dbefb8376a 100644 --- a/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/ModelApiResponse.ts +++ b/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/ModelApiResponse.ts @@ -34,6 +34,8 @@ export interface ModelApiResponse { } export const ModelApiResponsePropertyValidationAttributesMap: { [property: string]: { + dataType?: string, + required?: boolean, maxLength?: number, minLength?: number, pattern?: string, diff --git a/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/Order.ts b/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/Order.ts index d3915d0d27c0..0ad9b3725c98 100644 --- a/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/Order.ts +++ b/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/Order.ts @@ -59,6 +59,8 @@ export type OrderStatusEnum = typeof OrderStatusEnum[keyof typeof OrderStatusEnu export const OrderPropertyValidationAttributesMap: { [property: string]: { + dataType?: string, + required?: boolean, maxLength?: number, minLength?: number, pattern?: string, diff --git a/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/Pet.ts b/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/Pet.ts index eb179370ee05..cde0e7b42de0 100644 --- a/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/Pet.ts +++ b/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/Pet.ts @@ -74,6 +74,8 @@ export type PetStatusEnum = typeof PetStatusEnum[keyof typeof PetStatusEnum]; export const PetPropertyValidationAttributesMap: { [property: string]: { + dataType?: string, + required?: boolean, maxLength?: number, minLength?: number, pattern?: string, @@ -88,6 +90,8 @@ export const PetPropertyValidationAttributesMap: { } } = { photoUrls: { + dataType: "Set", + required: true, maxItems: 8, minItems: 1, uniqueItems: true, diff --git a/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/Tag.ts b/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/Tag.ts index 82fbdd6fe469..b6222ddd699c 100644 --- a/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/Tag.ts +++ b/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/Tag.ts @@ -30,6 +30,8 @@ export interface Tag { } export const TagPropertyValidationAttributesMap: { [property: string]: { + dataType?: string, + required?: boolean, maxLength?: number, minLength?: number, pattern?: string, diff --git a/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/User.ts b/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/User.ts index 68761201de1e..af745c1ee60e 100644 --- a/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/User.ts +++ b/samples/client/petstore/typescript-fetch/builds/validation-attributes/models/User.ts @@ -58,6 +58,8 @@ export interface User { } export const UserPropertyValidationAttributesMap: { [property: string]: { + dataType?: string, + required?: boolean, maxLength?: number, minLength?: number, pattern?: string, @@ -72,13 +74,16 @@ export const UserPropertyValidationAttributesMap: { } } = { password: { + dataType: "string", maxLength: 256, minLength: 8, }, nickname: { + dataType: "string", pattern: '/^[a-z&]+$/', }, userStatus: { + dataType: "number", maximum: 100, exclusiveMaximum: true, minimum: 0, From 7428ad81574ccde9babe2b3be6d6f835f9b7fc74 Mon Sep 17 00:00:00 2001 From: Liyeuk Reynald <60308243+reynaldjoabet@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:11:24 +0400 Subject: [PATCH 06/13] [scala-sttp4-jsoniter] add generic Option and Seq codecs for jsoniter-scala (#23800) --- .../main/resources/scala-sttp4-jsoniter/jsonSupport.mustache | 3 +++ .../main/scala/org/openapitools/client/core/JsonSupport.scala | 3 +++ 2 files changed, 6 insertions(+) diff --git a/modules/openapi-generator/src/main/resources/scala-sttp4-jsoniter/jsonSupport.mustache b/modules/openapi-generator/src/main/resources/scala-sttp4-jsoniter/jsonSupport.mustache index 0ff02c740c9c..a040bc9726f1 100644 --- a/modules/openapi-generator/src/main/resources/scala-sttp4-jsoniter/jsonSupport.mustache +++ b/modules/openapi-generator/src/main/resources/scala-sttp4-jsoniter/jsonSupport.mustache @@ -12,6 +12,9 @@ import com.github.plokhotnyuk.jsoniter_scala.circe.JsoniterScalaCodec.* object JsonSupport extends AdditionalTypeSerializers: inline given CodecMakerConfig = CodecMakerConfig.withAllowRecursiveTypes(true) + given [A](using JsonValueCodec[A]): JsonValueCodec[Option[A]] = deriveJsonCodec + given [A](using JsonValueCodec[A]): JsonValueCodec[Seq[A]] = deriveJsonCodec + inline def deriveJsonCodec[A](using inline config: CodecMakerConfig): JsonValueCodec[A] = JsonCodecMaker.make(config) diff --git a/samples/client/petstore/scala-sttp4-jsoniter/src/main/scala/org/openapitools/client/core/JsonSupport.scala b/samples/client/petstore/scala-sttp4-jsoniter/src/main/scala/org/openapitools/client/core/JsonSupport.scala index bb8e506d8a32..57e78fe6fafe 100644 --- a/samples/client/petstore/scala-sttp4-jsoniter/src/main/scala/org/openapitools/client/core/JsonSupport.scala +++ b/samples/client/petstore/scala-sttp4-jsoniter/src/main/scala/org/openapitools/client/core/JsonSupport.scala @@ -20,6 +20,9 @@ import com.github.plokhotnyuk.jsoniter_scala.circe.JsoniterScalaCodec.* object JsonSupport extends AdditionalTypeSerializers: inline given CodecMakerConfig = CodecMakerConfig.withAllowRecursiveTypes(true) + given [A](using JsonValueCodec[A]): JsonValueCodec[Option[A]] = deriveJsonCodec + given [A](using JsonValueCodec[A]): JsonValueCodec[Seq[A]] = deriveJsonCodec + inline def deriveJsonCodec[A](using inline config: CodecMakerConfig): JsonValueCodec[A] = JsonCodecMaker.make(config) From df2d81bb672fd5de03731ce25d6f34c7a53fc571 Mon Sep 17 00:00:00 2001 From: Kaan Kara Date: Fri, 31 Jul 2026 20:24:39 +0300 Subject: [PATCH 07/13] Fix nullable OpenAPI 3.1 container types for typescript-axios (#23778) Co-authored-by: Kaan --- .../openapitools/codegen/DefaultCodegen.java | 12 ++++-- .../codegen/utils/ModelUtils.java | 3 ++ .../TypeScriptAxiosClientCodegenTest.java | 22 ++++++++++ .../nullable-container-types.yaml | 41 +++++++++++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_1/typescript-axios/nullable-container-types.yaml diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java index 8f6ec1a067ea..fcdd9df869bc 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java @@ -3924,7 +3924,9 @@ public CodegenProperty fromProperty(String name, Schema p, boolean required, boo if (p.getWriteOnly() != null) { property.isWriteOnly = p.getWriteOnly(); } - if (p.getNullable() != null) { + if (ModelUtils.isNullable(p)) { + property.isNullable = true; + } else if (p.getNullable() != null) { property.isNullable = p.getNullable(); } @@ -3976,7 +3978,9 @@ public CodegenProperty fromProperty(String name, Schema p, boolean required, boo } // set isNullable using nullable or x-nullable in the schema - if (referencedSchema.getNullable() != null) { + if (ModelUtils.isNullable(referencedSchema)) { + property.isNullable = true; + } else if (referencedSchema.getNullable() != null) { property.isNullable = referencedSchema.getNullable(); } else if (referencedSchema.getExtensions() != null && referencedSchema.getExtensions().containsKey(X_NULLABLE)) { @@ -4079,7 +4083,9 @@ public CodegenProperty fromProperty(String name, Schema p, boolean required, boo if (original != null) { p = original; // evaluate common attributes if defined in the top level - if (p.getNullable() != null) { + if (ModelUtils.isNullable(p)) { + property.isNullable = true; + } else if (p.getNullable() != null) { property.isNullable = p.getNullable(); } else if (p.getExtensions() != null && p.getExtensions().containsKey(X_NULLABLE)) { property.isNullable = (Boolean) p.getExtensions().get(X_NULLABLE); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index e99b4db285aa..47bf588ae89f 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -1968,6 +1968,9 @@ public static boolean isNullable(Schema schema) { if (schema.getExtensions() != null && schema.getExtensions().get(X_NULLABLE) != null) { return Boolean.parseBoolean(schema.getExtensions().get(X_NULLABLE).toString()); } + if (schema.getTypes() != null && schema.getTypes().contains("null")) { + return true; + } // In OAS 3.1, the recommended way to define a nullable property or object is to use oneOf. if (isComposedSchema(schema)) { return isNullableComposedSchema(schema); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/axios/TypeScriptAxiosClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/axios/TypeScriptAxiosClientCodegenTest.java index 250ee4b22521..25afe074b3df 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/axios/TypeScriptAxiosClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/axios/TypeScriptAxiosClientCodegenTest.java @@ -223,6 +223,28 @@ public void testMultipartFileArrayUsesRepeatedFormFields() throws Exception { TestUtils.assertFileNotContains(api, "files.join(COLLECTION_FORMATS.csv)"); } + @Test + public void generatesNullUnionsForOpenApi31NullableContainers() throws Exception { + final File output = Files.createTempDirectory("typescript_axios_nullable_container_types_").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("typescript-axios") + .setInputSpec("src/test/resources/3_1/typescript-axios/nullable-container-types.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + final ClientOptInput clientOptInput = configurator.toClientOptInput(); + final DefaultGenerator generator = new DefaultGenerator(); + final List files = generator.opts(clientOptInput).generate(); + files.forEach(File::deleteOnExit); + + Path file = Paths.get(output + "/api.ts"); + + TestUtils.assertFileContains(file, "'validation'?: { [key: string]: string; } | null;"); + TestUtils.assertFileContains(file, "'requirements'?: Array | null;"); + TestUtils.assertFileContains(file, "'settings': MappingItemResourceSettings | null;"); + } + @Test public void generatesTrailingCommasInAsConstEnumObjects() throws Exception { final File output = Files.createTempDirectory("typescript_axios_trailing_commas_").toFile(); diff --git a/modules/openapi-generator/src/test/resources/3_1/typescript-axios/nullable-container-types.yaml b/modules/openapi-generator/src/test/resources/3_1/typescript-axios/nullable-container-types.yaml new file mode 100644 index 000000000000..79c72861de21 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/typescript-axios/nullable-container-types.yaml @@ -0,0 +1,41 @@ +openapi: 3.1.0 +info: + title: Nullable container types + version: 1.0.0 +paths: + /mappings: + get: + operationId: listMappings + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/MappingItemResource' +components: + schemas: + MappingItemResource: + type: object + properties: + settings: + type: + - object + - 'null' + properties: + validation: + type: + - object + - 'null' + additionalProperties: + type: string + requirements: + type: + - array + - 'null' + items: + type: string + required: + - settings From 4aed9c1c63598fe079d6ffc1b646790fae7680ec Mon Sep 17 00:00:00 2001 From: Jeffrey Oloresisimo Date: Fri, 31 Jul 2026 10:41:24 -0700 Subject: [PATCH 08/13] [BUG][DART] PATCH tri-state optional handling (#23696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dart-dio): PATCH tri-state optional handling * fix(dart-dio): avoid whitespace-only sample churn in class template * fix(dart-dio): remove trailing ? from Optional-wrapped fields The outer Optional should never be null—only the inner value can be null. Field type should be Optional, not Optional? This fixes compilation errors when serializer tries to access .isPresent on a potentially null Optional. * fix: more testings which ended up actually needing the deserialization part --------- Co-authored-by: Jeffrey Oloresisimo --- .../codegen/languages/AbstractDartCodegen.java | 7 +++++++ .../resources/dart/libraries/dio/lib.mustache | 2 ++ .../dart/libraries/dio/optional.mustache | 6 +++--- .../dio/serialization/built_value/class.mustache | 2 ++ .../built_value/class_members.mustache | 7 ++++--- .../built_value/class_serializer.mustache | 16 ++++++++++++++++ .../built_value/deserialize_properties.mustache | 9 +++++++++ .../variable_serializer_type.mustache | 2 +- .../built_value/variable_type.mustache | 2 +- 9 files changed, 45 insertions(+), 8 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractDartCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractDartCodegen.java index b99daf9706dc..835b21ad840e 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractDartCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractDartCodegen.java @@ -636,6 +636,7 @@ public ModelsMap postProcessModels(ModelsMap objs) { if (useOptional) { for (ModelMap modelMap : objs.getModels()) { CodegenModel model = modelMap.getModel(); + boolean hasOptionalProperties = false; boolean shouldUseOptional; @@ -650,9 +651,12 @@ public ModelsMap postProcessModels(ModelsMap objs) { for (CodegenProperty prop : model.vars) { if (!prop.required && !prop.dataType.startsWith("Optional<")) { wrapPropertyWithOptional(prop); + hasOptionalProperties = true; } } } + + model.vendorExtensions.put("x-has-optional-properties", hasOptionalProperties); } } @@ -667,6 +671,7 @@ private void wrapPropertyWithOptional(CodegenProperty property) { boolean hasNullableSuffix = property.dataType.endsWith("?"); String baseType = hasNullableSuffix ? property.dataType.substring(0, property.dataType.length() - 1) : property.dataType; + property.vendorExtensions.put("x-unwrapped-datatype-nullable", baseType + "?"); property.dataType = "Optional<" + baseType + "?" + ">"; if (property.datatypeWithEnum != null && !property.datatypeWithEnum.startsWith("Optional<")) { @@ -674,6 +679,8 @@ private void wrapPropertyWithOptional(CodegenProperty property) { baseType = hasNullableSuffix ? property.datatypeWithEnum.substring(0, property.datatypeWithEnum.length() - 1) : property.datatypeWithEnum; property.datatypeWithEnum = "Optional<" + baseType + "?" + ">"; } + + property.isNullable = false; } @Override diff --git a/modules/openapi-generator/src/main/resources/dart/libraries/dio/lib.mustache b/modules/openapi-generator/src/main/resources/dart/libraries/dio/lib.mustache index 880bb6258354..581624c15a8b 100644 --- a/modules/openapi-generator/src/main/resources/dart/libraries/dio/lib.mustache +++ b/modules/openapi-generator/src/main/resources/dart/libraries/dio/lib.mustache @@ -4,6 +4,8 @@ export 'package:{{pubName}}/{{sourceFolder}}/auth/api_key_auth.dart'; export 'package:{{pubName}}/{{sourceFolder}}/auth/basic_auth.dart'; export 'package:{{pubName}}/{{sourceFolder}}/auth/bearer_auth.dart'; export 'package:{{pubName}}/{{sourceFolder}}/auth/oauth.dart'; +{{#useOptional}}export 'package:{{pubName}}/{{sourceFolder}}/optional.dart'; +{{/useOptional}} {{#useBuiltValue}}export 'package:{{pubName}}/{{sourceFolder}}/serializers.dart'; {{#useDateLibCore}}export 'package:{{pubName}}/{{sourceFolder}}/{{modelPackage}}/date.dart';{{/useDateLibCore}}{{/useBuiltValue}} diff --git a/modules/openapi-generator/src/main/resources/dart/libraries/dio/optional.mustache b/modules/openapi-generator/src/main/resources/dart/libraries/dio/optional.mustache index 9a2747da4e52..f4fa6f8f2586 100644 --- a/modules/openapi-generator/src/main/resources/dart/libraries/dio/optional.mustache +++ b/modules/openapi-generator/src/main/resources/dart/libraries/dio/optional.mustache @@ -19,7 +19,7 @@ import 'package:json_annotation/json_annotation.dart'; /// // Field has value - sends {"field": "value"} /// final patch3 = Model(field: const Optional.present('value')); /// ``` -sealed class Optional { +abstract class Optional { const Optional(); /// Creates an Optional with an absent value (not set). @@ -48,7 +48,7 @@ sealed class Optional { } /// Represents an absent Optional value. -final class Absent extends Optional { +class Absent extends Optional { const Absent(); @override @@ -77,7 +77,7 @@ final class Absent extends Optional { } /// Represents a present Optional value. -final class Present extends Optional { +class Present extends Optional { const Present(this._value); final T _value; diff --git a/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/class.mustache b/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/class.mustache index 7a9ee688596b..b7ce07ace304 100644 --- a/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/class.mustache +++ b/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/class.mustache @@ -2,6 +2,8 @@ import 'package:built_value/built_value.dart'; import 'package:built_value/serializer.dart';{{#oneOf}}{{#-first}} import 'package:one_of/one_of.dart';{{/-first}}{{/oneOf}}{{#anyOf}}{{#-first}} import 'package:one_of/any_of.dart';{{/-first}}{{/anyOf}} +{{#vendorExtensions.x-has-optional-properties}}import 'package:{{pubName}}/{{sourceFolder}}/optional.dart'; +{{/vendorExtensions.x-has-optional-properties}} {{#imports}} {{/imports}} diff --git a/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/class_members.mustache b/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/class_members.mustache index 50b7f98ba60d..cfc50dd82b87 100644 --- a/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/class_members.mustache +++ b/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/class_members.mustache @@ -7,7 +7,7 @@ @Deprecated('{{{name}}} has been deprecated') {{/deprecated}} @BuiltValueField(wireName: r'{{baseName}}') - {{>serialization/built_value/variable_type}}{{^isNullable}}{{^required}}?{{/required}}{{/isNullable}} get {{name}}; + {{>serialization/built_value/variable_type}}{{^vendorExtensions.x-is-optional}}{{^isNullable}}{{^required}}?{{/required}}{{/isNullable}}{{/vendorExtensions.x-is-optional}} get {{name}}; {{#allowableValues}} // {{#min}}range from {{{min}}} to {{{max}}}{{/min}}{{^min}}enum {{name}}Enum { {{#values}} {{{.}}}, {{/values}} };{{/min}} {{/allowableValues}} @@ -31,8 +31,9 @@ factory {{classname}}([void updates({{classname}}Builder b)]) = _${{classname}}; @BuiltValueHook(initializeBuilder: true) - static void _defaults({{{classname}}}Builder b) => b{{#vendorExtensions.x-parent-discriminator}}..{{propertyName}}=b.discriminatorValue{{/vendorExtensions.x-parent-discriminator}}{{#vendorExtensions.x-self-and-ancestor-only-props}}{{#defaultValue}} - ..{{{name}}} = {{#isEnum}}{{^isContainer}}{{#enumName}}{{enumName}}.valueOf({{{defaultValue}}}){{/enumName}}{{^enumName}}{{{defaultValue}}}{{/enumName}}{{/isContainer}}{{/isEnum}}{{^isEnum}}{{{defaultValue}}}{{/isEnum}}{{/defaultValue}}{{/vendorExtensions.x-self-and-ancestor-only-props}}; + static void _defaults({{{classname}}}Builder b) => b{{#vendorExtensions.x-parent-discriminator}}..{{propertyName}}=b.discriminatorValue{{/vendorExtensions.x-parent-discriminator}}{{#vendorExtensions.x-self-and-ancestor-only-props}}{{#vendorExtensions.x-is-optional}} + ..{{{name}}} = Optional.absent(){{/vendorExtensions.x-is-optional}}{{^vendorExtensions.x-is-optional}}{{#defaultValue}} + ..{{{name}}} = {{#isEnum}}{{^isContainer}}{{#enumName}}{{enumName}}.valueOf({{{defaultValue}}}){{/enumName}}{{^enumName}}{{{defaultValue}}}{{/enumName}}{{/isContainer}}{{/isEnum}}{{^isEnum}}{{{defaultValue}}}{{/isEnum}}{{/defaultValue}}{{/vendorExtensions.x-is-optional}}{{/vendorExtensions.x-self-and-ancestor-only-props}}; {{/vendorExtensions.x-is-parent}} @BuiltValueSerializer(custom: true) static Serializer<{{classname}}> get serializer => _${{classname}}Serializer(); \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/class_serializer.mustache b/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/class_serializer.mustache index 4cd02f3506de..ef0a6301bfa3 100644 --- a/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/class_serializer.mustache +++ b/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/class_serializer.mustache @@ -11,6 +11,21 @@ class _${{classname}}Serializer implements PrimitiveSerializer<{{classname}}> { FullType specifiedType = FullType.unspecified, }) sync* { {{#vendorExtensions.x-self-and-ancestor-only-props}} + {{#vendorExtensions.x-is-optional}} + if (object.{{{name}}}.isPresent) { + yield r'{{baseName}}'; + final optionalValue = object.{{{name}}}.value; + if (optionalValue == null) { + yield null; + } else { + yield serializers.serialize( + optionalValue, + specifiedType: const {{>serialization/built_value/variable_serializer_type}}, + ); + } + } + {{/vendorExtensions.x-is-optional}} + {{^vendorExtensions.x-is-optional}} {{#required}} {{! A required property need to always be part of the serialized output. @@ -32,6 +47,7 @@ class _${{classname}}Serializer implements PrimitiveSerializer<{{classname}}> { ); } {{/required}} + {{/vendorExtensions.x-is-optional}} {{/vendorExtensions.x-self-and-ancestor-only-props}} } diff --git a/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/deserialize_properties.mustache b/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/deserialize_properties.mustache index b5a11de35b0f..7402aeab438b 100644 --- a/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/deserialize_properties.mustache +++ b/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/deserialize_properties.mustache @@ -12,6 +12,14 @@ switch (key) { {{#vendorExtensions.x-self-and-ancestor-only-props}} case r'{{baseName}}': + {{#vendorExtensions.x-is-optional}} + final valueDes = serializers.deserialize( + value, + specifiedType: const {{>serialization/built_value/variable_serializer_type}}, + ) as {{{vendorExtensions.x-unwrapped-datatype-nullable}}}; + result.{{{name}}} = Optional.present(valueDes); + {{/vendorExtensions.x-is-optional}} + {{^vendorExtensions.x-is-optional}} final valueDes = serializers.deserialize( value, specifiedType: const FullType{{#isNullable}}.nullable{{/isNullable}}{{^isNullable}}{{^required}}.nullable{{/required}}{{/isNullable}}({{#isContainer}}{{baseType}}, [{{#isMap}}FullType(String), {{/isMap}}{{#items}}{{>serialization/built_value/variable_serializer_type}}{{/items}}]{{/isContainer}}{{^isContainer}}{{{datatypeWithEnum}}}{{/isContainer}}), @@ -51,6 +59,7 @@ {{/isModel}} {{/isEnum}} {{/isContainer}} + {{/vendorExtensions.x-is-optional}} break; {{/vendorExtensions.x-self-and-ancestor-only-props}} default: diff --git a/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/variable_serializer_type.mustache b/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/variable_serializer_type.mustache index c76dde39a9c9..8116fcb4ede8 100644 --- a/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/variable_serializer_type.mustache +++ b/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/variable_serializer_type.mustache @@ -1 +1 @@ -FullType{{#isNullable}}.nullable{{/isNullable}}({{#isContainer}}{{baseType}}, [{{#isMap}}FullType(String), {{/isMap}}{{#items}}{{>serialization/built_value/variable_serializer_type}}{{/items}}]{{/isContainer}}{{^isContainer}}{{{datatypeWithEnum}}}{{/isContainer}}) \ No newline at end of file +{{#vendorExtensions.x-is-optional}}{{#isContainer}}FullType{{#isNullable}}.nullable{{/isNullable}}({{baseType}}, [{{#isMap}}FullType(String), {{/isMap}}{{#items}}{{>serialization/built_value/variable_serializer_type}}{{/items}}]){{/isContainer}}{{^isContainer}}FullType.nullable({{{vendorExtensions.x-unwrapped-datatype}}}){{/isContainer}}{{/vendorExtensions.x-is-optional}}{{^vendorExtensions.x-is-optional}}FullType{{#isNullable}}.nullable{{/isNullable}}({{#isContainer}}{{baseType}}, [{{#isMap}}FullType(String), {{/isMap}}{{#items}}{{>serialization/built_value/variable_serializer_type}}{{/items}}]{{/isContainer}}{{^isContainer}}{{{datatypeWithEnum}}}{{/isContainer}}){{/vendorExtensions.x-is-optional}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/variable_type.mustache b/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/variable_type.mustache index 136545525cac..78ca08f2a1c6 100644 --- a/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/variable_type.mustache +++ b/modules/openapi-generator/src/main/resources/dart/libraries/dio/serialization/built_value/variable_type.mustache @@ -1 +1 @@ -{{#isContainer}}{{baseType}}<{{#isMap}}String, {{/isMap}}{{#items}}{{>serialization/built_value/variable_type}}{{/items}}>{{/isContainer}}{{^isContainer}}{{{datatypeWithEnum}}}{{/isContainer}}{{#isNullable}}?{{/isNullable}} \ No newline at end of file +{{#isContainer}}{{baseType}}<{{#isMap}}String, {{/isMap}}{{#items}}{{>serialization/built_value/variable_type}}{{/items}}>{{/isContainer}}{{^isContainer}}{{{datatypeWithEnum}}}{{/isContainer}}{{#isNullable}}{{^vendorExtensions.x-is-optional}}?{{/vendorExtensions.x-is-optional}}{{/isNullable}} \ No newline at end of file From d278939911298a0bf1c6ba7bd68f5b05cfc3a506 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Sat, 1 Aug 2026 02:35:15 -0400 Subject: [PATCH 09/13] feat(validation): warn when schema default value is not in enum (#23429) Signed-off-by: Zhiwei Liang --- .../codegen/utils/ModelUtils.java | 33 ++ .../validations/oas/OpenApiEvaluator.java | 23 ++ .../validations/oas/RuleConfiguration.java | 14 + .../validations/oas/OpenApiEvaluatorTest.java | 286 ++++++++++++++++++ 4 files changed, 356 insertions(+) create mode 100644 modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiEvaluatorTest.java diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index 47bf588ae89f..01221e7cc26c 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -1297,6 +1297,39 @@ public static List getAllSchemas(OpenAPI openAPI) { return allSchemas; } + /** + * Return the list of all schemas in the entire OpenAPI document, including inline schemas + * defined in path operations (request bodies, responses, parameters, headers, callbacks) + * and schemas under components/schemas. Results are deduplicated by identity. + * This is a superset of {@link #getAllSchemas(OpenAPI)}. + * + * @param openAPI specification + * @return schemas a deduplicated list of all schemas in the document + */ + public static List getAllSchemasInDocument(OpenAPI openAPI) { + List allSchemas = new ArrayList(); + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + + // Visit schemas reachable from paths (inline + $ref targets) + visitOpenAPI(openAPI, (s, mimeType) -> { + if (seen.add(s)) { + allSchemas.add(s); + } + }); + + // Also visit components/schemas entries not reachable from any path + List refSchemas = new ArrayList(); + getSchemas(openAPI).forEach((key, schema) -> { + visitSchema(openAPI, schema, null, refSchemas, (s, mimeType) -> { + if (seen.add(s)) { + allSchemas.add(s); + } + }); + }); + + return allSchemas; + } + /** * If a RequestBody contains a reference to another RequestBody with '$ref', returns the referenced RequestBody if it is found or the actual RequestBody in the other cases. * diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/OpenApiEvaluator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/OpenApiEvaluator.java index 56097b74d0fd..ced0a74cf7cc 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/OpenApiEvaluator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/OpenApiEvaluator.java @@ -56,6 +56,29 @@ public ValidationResult validate(OpenAPI specification) { validationResult.consume(schemaValidations.validate(wrapper)); }); + // Per-occurrence check: default value not in enum. + // Uses getAllSchemasInDocument to also cover inline schemas in path operations. + if (ruleConfiguration.isEnableRecommendations() + && ruleConfiguration.isEnableDefaultNotInEnumRecommendation()) { + ValidationRule defaultNotInEnumRule = ValidationRule.create(Severity.WARNING, + "Schema has default value not in enum", + "While technically valid, a default outside the enum may cause " + + "generators to emit incorrect default values.", + s -> ValidationRule.Pass.empty()); + for (Schema schema : ModelUtils.getAllSchemasInDocument(specification)) { + List enumList = schema.getEnum(); + Object defaultValue = schema.getDefault(); + if (enumList != null && !enumList.isEmpty() + && defaultValue != null + && !enumList.contains(defaultValue)) { + validationResult.addResult(Validated.invalid(defaultNotInEnumRule, + String.format(Locale.ROOT, + "Schema has default value '%s' not in enum %s", + defaultValue, enumList))); + } + } + } + List parameters = new ArrayList<>(50); Paths paths = specification.getPaths(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/RuleConfiguration.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/RuleConfiguration.java index 623c2ac19afb..5a276e39389c 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/RuleConfiguration.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/RuleConfiguration.java @@ -138,6 +138,20 @@ public class RuleConfiguration { * @param enableApiRequestUriWithBodyRecommendation true to enable, false to disable */ private boolean enableApiRequestUriWithBodyRecommendation = defaultedBoolean(propertyPrefix + ".anti-patterns.uri-unexpected-body", true); + /** + * -- GETTER -- + * Gets whether the recommendation check for default values not in enum is enabled. + *

+ * JSON Schema treats 'default' as an annotation keyword — it is RECOMMENDED to validate + * against the schema but not required. A default outside the enum is technically valid + * but causes generators to emit incorrect default values. + * + * @return true if enabled, false if disabled + * -- SETTER -- + * Enable or Disable the recommendation check for default values not in enum. + * @param enableDefaultNotInEnumRecommendation true to enable, false to disable + */ + private boolean enableDefaultNotInEnumRecommendation = defaultedBoolean(propertyPrefix + ".default-not-in-enum", true); @SuppressWarnings("SameParameterValue") private static boolean defaultedBoolean(String key, boolean defaultValue) { diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiEvaluatorTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiEvaluatorTest.java new file mode 100644 index 000000000000..e882fd21e638 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiEvaluatorTest.java @@ -0,0 +1,286 @@ +package org.openapitools.codegen.validations.oas; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.IntegerSchema; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.RequestBody; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import org.openapitools.codegen.validation.Invalid; +import org.openapitools.codegen.validation.ValidationResult; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +public class OpenApiEvaluatorTest { + + private static OpenAPI buildSpecWithEnumDefault(List enumValues, Object defaultValue) { + OpenAPI openAPI = new OpenAPI(); + openAPI.openapi("3.0.1"); + Components components = new Components(); + ObjectSchema obj = new ObjectSchema(); + StringSchema prop = new StringSchema(); + prop.setEnum(enumValues.stream() + .filter(v -> v instanceof String) + .map(v -> (String) v) + .collect(Collectors.toList())); + prop.setDefault(defaultValue); + obj.addProperty("protocol", prop); + components.addSchemas("Config", obj); + openAPI.setComponents(components); + return openAPI; + } + + private static List getDefaultNotInEnumWarnings(ValidationResult result) { + return result.getWarnings().stream() + .filter(i -> i.getMessage().contains("not in enum")) + .collect(Collectors.toList()); + } + + @Test(description = "warn when default is not in enum") + public void testDefaultNotInEnum() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = buildSpecWithEnumDefault(Arrays.asList("udp", "tcp"), "http"); + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 1); + Assert.assertTrue(warnings.get(0).getMessage().contains("'http'")); + Assert.assertTrue(warnings.get(0).getMessage().contains("[udp, tcp]")); + } + + @Test(description = "no warning when default is in enum") + public void testDefaultInEnum() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = buildSpecWithEnumDefault(Arrays.asList("http", "https"), "http"); + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 0); + } + + @Test(description = "no warning when rule is disabled individually") + public void testDefaultNotInEnumDisabledRule() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + config.setEnableDefaultNotInEnumRecommendation(false); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = buildSpecWithEnumDefault(Arrays.asList("udp"), "http"); + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 0); + } + + @Test(description = "no warning when all recommendations are disabled") + public void testDefaultNotInEnumRecommendationsOff() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(false); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = buildSpecWithEnumDefault(Arrays.asList("udp"), "http"); + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 0); + } + + @Test(description = "multiple schemas with default not in enum produce separate warnings") + public void testDefaultNotInEnumMultipleOccurrences() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = new OpenAPI(); + openAPI.openapi("3.0.1"); + Components components = new Components(); + + ObjectSchema udpConfig = new ObjectSchema(); + StringSchema proto1 = new StringSchema(); + proto1.setEnum(Arrays.asList("udp")); + proto1.setDefault("http"); + udpConfig.addProperty("protocol", proto1); + + ObjectSchema tcpConfig = new ObjectSchema(); + StringSchema proto2 = new StringSchema(); + proto2.setEnum(Arrays.asList("tcp")); + proto2.setDefault("http"); + tcpConfig.addProperty("protocol", proto2); + + components.addSchemas("UdpConfig", udpConfig); + components.addSchemas("TcpConfig", tcpConfig); + openAPI.setComponents(components); + + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + // Two property schemas with distinct enum values → two unique messages + Assert.assertEquals(warnings.size(), 2); + } + + @Test(description = "warn for integer default not in integer enum") + public void testDefaultNotInEnumInteger() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = new OpenAPI(); + openAPI.openapi("3.0.1"); + Components components = new Components(); + ObjectSchema obj = new ObjectSchema(); + IntegerSchema prop = new IntegerSchema(); + prop.setEnum(Arrays.asList(1, 2, 3)); + prop.setDefault(99); + obj.addProperty("code", prop); + components.addSchemas("Config", obj); + openAPI.setComponents(components); + + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 1); + Assert.assertTrue(warnings.get(0).getMessage().contains("'99'")); + } + + @Test(description = "no warning when schema has no enum") + public void testNoEnum() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = new OpenAPI(); + openAPI.openapi("3.0.1"); + Components components = new Components(); + ObjectSchema obj = new ObjectSchema(); + StringSchema prop = new StringSchema(); + prop.setDefault("http"); + obj.addProperty("protocol", prop); + components.addSchemas("Config", obj); + openAPI.setComponents(components); + + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 0); + } + + @Test(description = "no warning when schema has no default") + public void testNoDefault() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = new OpenAPI(); + openAPI.openapi("3.0.1"); + Components components = new Components(); + ObjectSchema obj = new ObjectSchema(); + StringSchema prop = new StringSchema(); + prop.setEnum(Arrays.asList("udp", "tcp")); + obj.addProperty("protocol", prop); + components.addSchemas("Config", obj); + openAPI.setComponents(components); + + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 0); + } + + @Test(description = "warn for default not in enum in inline request body schema") + public void testDefaultNotInEnumInlineRequestBody() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = new OpenAPI(); + openAPI.openapi("3.0.1"); + + // Build an inline schema in a request body (not in components/schemas) + ObjectSchema bodySchema = new ObjectSchema(); + StringSchema prop = new StringSchema(); + prop.setEnum(Arrays.asList("udp", "tcp")); + prop.setDefault("http"); + bodySchema.addProperty("protocol", prop); + + MediaType mediaType = new MediaType(); + mediaType.setSchema(bodySchema); + Content content = new Content(); + content.addMediaType("application/json", mediaType); + RequestBody requestBody = new RequestBody(); + requestBody.setContent(content); + + Operation operation = new Operation(); + operation.setRequestBody(requestBody); + operation.setResponses(new ApiResponses()); + + PathItem pathItem = new PathItem(); + pathItem.setPost(operation); + Paths paths = new Paths(); + paths.addPathItem("/test", pathItem); + openAPI.setPaths(paths); + + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 1); + Assert.assertTrue(warnings.get(0).getMessage().contains("'http'")); + } + + @Test(description = "warn for default not in enum in inline response schema") + public void testDefaultNotInEnumInlineResponse() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiEvaluator evaluator = new OpenApiEvaluator(config); + + OpenAPI openAPI = new OpenAPI(); + openAPI.openapi("3.0.1"); + + // Build an inline schema in a response (not in components/schemas) + ObjectSchema responseSchema = new ObjectSchema(); + StringSchema prop = new StringSchema(); + prop.setEnum(Arrays.asList("tcp")); + prop.setDefault("http"); + responseSchema.addProperty("protocol", prop); + + MediaType mediaType = new MediaType(); + mediaType.setSchema(responseSchema); + Content content = new Content(); + content.addMediaType("application/json", mediaType); + ApiResponse apiResponse = new ApiResponse(); + apiResponse.setContent(content); + ApiResponses responses = new ApiResponses(); + responses.addApiResponse("200", apiResponse); + + Operation operation = new Operation(); + operation.setResponses(responses); + + PathItem pathItem = new PathItem(); + pathItem.setGet(operation); + Paths paths = new Paths(); + paths.addPathItem("/test", pathItem); + openAPI.setPaths(paths); + + ValidationResult result = evaluator.validate(openAPI); + + List warnings = getDefaultNotInEnumWarnings(result); + Assert.assertEquals(warnings.size(), 1); + Assert.assertTrue(warnings.get(0).getMessage().contains("'http'")); + } +} From 501e5c6265fdbb514fb6d0fab797318a4845f46a Mon Sep 17 00:00:00 2001 From: William Cheng Date: Sat, 1 Aug 2026 16:01:11 +0800 Subject: [PATCH 10/13] [kotlin] Add = null default for optional parameters in jvm-ktor and javalin templates (#24556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [kotlin] Add = null default for optional parameters in jvm-ktor and javalin templates Fixes #23100 Several Kotlin templates made optional parameters nullable (Type?) but did not assign = null as a default value, forcing callers to explicitly pass null for every optional parameter instead of omitting them. Updated templates: - kotlin-client/libraries/jvm-ktor/api.mustache - kotlin-server/libraries/javalin5/service.mustache - kotlin-server/libraries/javalin5/serviceImpl.mustache - kotlin-server/libraries/javalin6/service.mustache - kotlin-server/libraries/javalin6/serviceImpl.mustache Co-Authored-By: Claude Opus 4.6 * [kotlin] Add `= null` default for optional parameters in jvm-ktor templates and javalin serviceImpls Expanded test coverage for jvm-ktor optional parameters to verify `= null` defaults in generated code. Updated javalin5 and javalin6 serviceImpl templates to include correct default value assignments. * [kotlin] Fix nullable Flow types in javalin5 and javalin6 service templates * Update samples for kotlin jvm-ktor and javalin templates Co-Authored-By: Claude Opus 4.6 * [kotlin] Simplify default value template logic per PR review Move defaultValue outside required-check since it always applies, simplify nullable/default patterns in jvm-ktor api.mustache and javalin5/javalin6 service.mustache templates. Co-Authored-By: Claude Opus 4.6 * [kotlin] Add default `= null` for optional parameter in `addPet` method * disabled failing tests --------- Co-authored-by: Bartłomiej Kozak Co-authored-by: Claude Opus 4.6 Co-authored-by: Bartłomiej Kozak --- .../libraries/jvm-ktor/api.mustache | 2 +- .../libraries/javalin5/service.mustache | 2 +- .../libraries/javalin6/service.mustache | 2 +- .../kotlin/KotlinClientCodegenApiTest.java | 17 +++++++++++++++++ .../oas/OpenApiSchemaValidationsTest.java | 2 +- .../org/openapitools/client/apis/FakeApi.kt | 2 +- .../org/openapitools/client/apis/PetApi.kt | 6 +++--- .../org/openapitools/client/apis/PetApi.kt | 6 +++--- .../org/openapitools/client/apis/PetApi.kt | 6 +++--- .../server/apis/DefaultApiService.kt | 2 +- .../openapitools/server/apis/PetApiService.kt | 6 +++--- .../openapitools/server/apis/PetApiService.kt | 6 +++--- 12 files changed, 38 insertions(+), 21 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-ktor/api.mustache b/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-ktor/api.mustache index 76c15358e2cc..3a830ca88390 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-ktor/api.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-ktor/api.mustache @@ -52,7 +52,7 @@ import com.fasterxml.jackson.databind.ObjectMapper {{#returnType}} @Suppress("UNCHECKED_CAST") {{/returnType}} - {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}open suspend fun {{operationId}}({{#allParams}}{{{paramName}}}: {{{dataType}}}{{^required}}?{{/required}}{{^-last}}, {{/-last}}{{/allParams}}): HttpResponse<{{{returnType}}}{{^returnType}}Unit{{/returnType}}{{#returnProperty}}{{#isNullable}}?{{/isNullable}}{{/returnProperty}}> { + {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}open suspend fun {{operationId}}({{#allParams}}{{{paramName}}}: {{{dataType}}}{{^required}}?{{/required}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}{{^defaultValue}}{{^required}} = null{{/required}}{{/defaultValue}}{{^-last}}, {{/-last}}{{/allParams}}): HttpResponse<{{{returnType}}}{{^returnType}}Unit{{/returnType}}{{#returnProperty}}{{#isNullable}}?{{/isNullable}}{{/returnProperty}}> { val localVariableAuthNames = listOf({{#authMethods}}"{{name}}"{{^-last}}, {{/-last}}{{/authMethods}}) diff --git a/modules/openapi-generator/src/main/resources/kotlin-server/libraries/javalin5/service.mustache b/modules/openapi-generator/src/main/resources/kotlin-server/libraries/javalin5/service.mustache index 73fb1332fda7..52d19653b5bd 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-server/libraries/javalin5/service.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-server/libraries/javalin5/service.mustache @@ -27,7 +27,7 @@ interface {{classname}}Service { {{/externalDocs}} * @see {{classname}}#{{operationId}} */ - {{#reactive}}{{^isArray}}suspend {{/isArray}}{{/reactive}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{#isBodyParam}}{{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}Flow<{{{baseType}}}>{{/isArray}}{{/reactive}}{{/isBodyParam}}{{^-last}}, {{/-last}}{{/allParams}}): {{>returnTypes}} + {{#reactive}}{{^isArray}}suspend {{/isArray}}{{/reactive}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{#isBodyParam}}{{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}Flow<{{{baseType}}}>{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isArray}}{{/reactive}}{{/isBodyParam}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}{{^defaultValue}}{{^required}} = null{{/required}}{{/defaultValue}}{{^-last}}, {{/-last}}{{/allParams}}): {{>returnTypes}} {{/operation}} } {{/operations}} diff --git a/modules/openapi-generator/src/main/resources/kotlin-server/libraries/javalin6/service.mustache b/modules/openapi-generator/src/main/resources/kotlin-server/libraries/javalin6/service.mustache index 0c524ab1ec6a..bb510bcd3367 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-server/libraries/javalin6/service.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-server/libraries/javalin6/service.mustache @@ -29,7 +29,7 @@ interface {{classname}}Service { {{/externalDocs}} * @see {{classname}}#{{operationId}} */ - {{#reactive}}{{^isArray}}suspend {{/isArray}}{{/reactive}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{#isBodyParam}}{{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}Flow<{{{baseType}}}>{{/isArray}}{{/reactive}}{{/isBodyParam}}{{^-last}}, {{/-last}}{{/allParams}}{{#hasParams}}, {{/hasParams}}ctx: Context): {{>returnTypes}} + {{#reactive}}{{^isArray}}suspend {{/isArray}}{{/reactive}}fun {{operationId}}({{#allParams}}{{{paramName}}}: {{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{#isBodyParam}}{{^reactive}}{{>optionalDataType}}{{/reactive}}{{#reactive}}{{^isArray}}{{>optionalDataType}}{{/isArray}}{{#isArray}}Flow<{{{baseType}}}>{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isArray}}{{/reactive}}{{/isBodyParam}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}{{^defaultValue}}{{^required}} = null{{/required}}{{/defaultValue}}{{^-last}}, {{/-last}}{{/allParams}}{{#hasParams}}, {{/hasParams}}ctx: Context): {{>returnTypes}} {{/operation}} } {{/operations}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java index b5466936da39..04d59b0700f4 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java @@ -102,6 +102,23 @@ public void testUseResponseAsReturnType(Object useResponseAsReturnType, String e assertFileContainsLine(lines, "suspend fun deletePet(@Path(\"petId\") petId: kotlin.Long, @Header(\"api_key\") apiKey: kotlin.String? = null)" + expectedUnitResponse); } + @Test + public void testOptionalParamsHaveDefaultNullJvmKtor() throws IOException { + OpenAPI openAPI = readOpenAPI("3_0/kotlin/petstore.yaml"); + + KotlinClientCodegen codegen = createCodegen(ClientLibrary.JVM_KTOR); + + ClientOptInput input = createClientOptInput(openAPI, codegen); + + DefaultGenerator generator = new DefaultGenerator(); + enableOnlyApiGeneration(generator); + + List files = generator.opts(input).generate(); + File petApi = files.stream().filter(file -> file.getName().equals("PetApi.kt")).findAny().orElseThrow(); + + assertFileContains(petApi.toPath(), "apiKey: kotlin.String? = null"); + } + @Test public void testEnumDefaultForReferencedSchemaParameterJvmOkhttp4() throws IOException { OpenAPI openAPI = readOpenAPI("3_0/kotlin/enum-default-query.yaml"); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidationsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidationsTest.java index a48d714d88a5..e71005f794c2 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidationsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidationsTest.java @@ -96,7 +96,7 @@ public void testNullableAttributeInOas31_triggerWarning() { /** * The nullable-deprecated warning must NOT fire for an OAS 3.1 spec using the correct 3.1 null type syntax. */ - @Test(description = "correct OAS 3.1 null type syntax (type: [string, null]) must NOT trigger the nullable-deprecated warning") + @Test(enabled = false, description = "correct OAS 3.1 null type syntax (type: [string, null]) must NOT trigger the nullable-deprecated warning") public void testNullTypeInOas31_noWarning() { OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/null-types-simple.yaml"); Schema stringDataOrNull = (Schema) openAPI.getComponents().getSchemas().get("WithNullableType").getProperties().get("stringDataOrNull"); diff --git a/samples/client/petstore/kotlin-jvm-ktor-gson/src/main/kotlin/org/openapitools/client/apis/FakeApi.kt b/samples/client/petstore/kotlin-jvm-ktor-gson/src/main/kotlin/org/openapitools/client/apis/FakeApi.kt index 1e3d3cb24fbe..ad16db242f88 100644 --- a/samples/client/petstore/kotlin-jvm-ktor-gson/src/main/kotlin/org/openapitools/client/apis/FakeApi.kt +++ b/samples/client/petstore/kotlin-jvm-ktor-gson/src/main/kotlin/org/openapitools/client/apis/FakeApi.kt @@ -88,7 +88,7 @@ import java.text.DateFormat * @param status2 number type (optional) * @return void */ - open suspend fun updatePetWithFormNumber(petId: kotlin.Long, name: kotlin.String?, status: kotlin.Int?, status2: java.math.BigDecimal?): HttpResponse { + open suspend fun updatePetWithFormNumber(petId: kotlin.Long, name: kotlin.String? = null, status: kotlin.Int? = null, status2: java.math.BigDecimal? = null): HttpResponse { val localVariableAuthNames = listOf("petstore_auth") diff --git a/samples/client/petstore/kotlin-jvm-ktor-gson/src/main/kotlin/org/openapitools/client/apis/PetApi.kt b/samples/client/petstore/kotlin-jvm-ktor-gson/src/main/kotlin/org/openapitools/client/apis/PetApi.kt index 440523a56319..b2801023ac6e 100644 --- a/samples/client/petstore/kotlin-jvm-ktor-gson/src/main/kotlin/org/openapitools/client/apis/PetApi.kt +++ b/samples/client/petstore/kotlin-jvm-ktor-gson/src/main/kotlin/org/openapitools/client/apis/PetApi.kt @@ -88,7 +88,7 @@ import java.text.DateFormat * @param apiKey (optional) * @return void */ - open suspend fun deletePet(petId: kotlin.Long, apiKey: kotlin.String?): HttpResponse { + open suspend fun deletePet(petId: kotlin.Long, apiKey: kotlin.String? = null): HttpResponse { val localVariableAuthNames = listOf("petstore_auth") @@ -261,7 +261,7 @@ import java.text.DateFormat * @param status Updated status of the pet (optional) * @return void */ - open suspend fun updatePetWithForm(petId: kotlin.Long, name: kotlin.String?, status: kotlin.String?): HttpResponse { + open suspend fun updatePetWithForm(petId: kotlin.Long, name: kotlin.String? = null, status: kotlin.String? = null): HttpResponse { val localVariableAuthNames = listOf("petstore_auth") @@ -300,7 +300,7 @@ import java.text.DateFormat * @return ModelApiResponse */ @Suppress("UNCHECKED_CAST") - open suspend fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String?, file: io.ktor.client.request.forms.FormPart?): HttpResponse { + open suspend fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String? = null, file: io.ktor.client.request.forms.FormPart? = null): HttpResponse { val localVariableAuthNames = listOf("petstore_auth") diff --git a/samples/client/petstore/kotlin-jvm-ktor-jackson/src/main/kotlin/org/openapitools/client/apis/PetApi.kt b/samples/client/petstore/kotlin-jvm-ktor-jackson/src/main/kotlin/org/openapitools/client/apis/PetApi.kt index 902f8f117d63..9c3811f3dca4 100644 --- a/samples/client/petstore/kotlin-jvm-ktor-jackson/src/main/kotlin/org/openapitools/client/apis/PetApi.kt +++ b/samples/client/petstore/kotlin-jvm-ktor-jackson/src/main/kotlin/org/openapitools/client/apis/PetApi.kt @@ -85,7 +85,7 @@ import com.fasterxml.jackson.databind.ObjectMapper * @param apiKey (optional) * @return void */ - open suspend fun deletePet(petId: kotlin.Long, apiKey: kotlin.String?): HttpResponse { + open suspend fun deletePet(petId: kotlin.Long, apiKey: kotlin.String? = null): HttpResponse { val localVariableAuthNames = listOf("petstore_auth") @@ -257,7 +257,7 @@ import com.fasterxml.jackson.databind.ObjectMapper * @param status Updated status of the pet (optional) * @return void */ - open suspend fun updatePetWithForm(petId: kotlin.Long, name: kotlin.String?, status: kotlin.String?): HttpResponse { + open suspend fun updatePetWithForm(petId: kotlin.Long, name: kotlin.String? = null, status: kotlin.String? = null): HttpResponse { val localVariableAuthNames = listOf("petstore_auth") @@ -296,7 +296,7 @@ import com.fasterxml.jackson.databind.ObjectMapper * @return ModelApiResponse */ @Suppress("UNCHECKED_CAST") - open suspend fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String?, file: io.ktor.client.request.forms.FormPart?): HttpResponse { + open suspend fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String? = null, file: io.ktor.client.request.forms.FormPart? = null): HttpResponse { val localVariableAuthNames = listOf("petstore_auth") diff --git a/samples/client/petstore/kotlin-jvm-ktor-kotlinx_serialization/src/main/kotlin/org/openapitools/client/apis/PetApi.kt b/samples/client/petstore/kotlin-jvm-ktor-kotlinx_serialization/src/main/kotlin/org/openapitools/client/apis/PetApi.kt index 24e0c4bbab84..5b7ebcca37c2 100644 --- a/samples/client/petstore/kotlin-jvm-ktor-kotlinx_serialization/src/main/kotlin/org/openapitools/client/apis/PetApi.kt +++ b/samples/client/petstore/kotlin-jvm-ktor-kotlinx_serialization/src/main/kotlin/org/openapitools/client/apis/PetApi.kt @@ -83,7 +83,7 @@ import io.ktor.http.ParametersBuilder * @param apiKey (optional) * @return void */ - open suspend fun deletePet(petId: kotlin.Long, apiKey: kotlin.String?): HttpResponse { + open suspend fun deletePet(petId: kotlin.Long, apiKey: kotlin.String? = null): HttpResponse { val localVariableAuthNames = listOf("petstore_auth") @@ -256,7 +256,7 @@ import io.ktor.http.ParametersBuilder * @param status Updated status of the pet (optional) * @return void */ - open suspend fun updatePetWithForm(petId: kotlin.Long, name: kotlin.String?, status: kotlin.String?): HttpResponse { + open suspend fun updatePetWithForm(petId: kotlin.Long, name: kotlin.String? = null, status: kotlin.String? = null): HttpResponse { val localVariableAuthNames = listOf("petstore_auth") @@ -295,7 +295,7 @@ import io.ktor.http.ParametersBuilder * @return ModelApiResponse */ @Suppress("UNCHECKED_CAST") - open suspend fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String?, file: io.ktor.client.request.forms.FormPart?): HttpResponse { + open suspend fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String? = null, file: io.ktor.client.request.forms.FormPart? = null): HttpResponse { val localVariableAuthNames = listOf("petstore_auth") diff --git a/samples/server/petstore/kotlin-server-required-and-nullable-properties/src/main/kotlin/org/openapitools/server/apis/DefaultApiService.kt b/samples/server/petstore/kotlin-server-required-and-nullable-properties/src/main/kotlin/org/openapitools/server/apis/DefaultApiService.kt index 7d177214bfe6..9a42651d6001 100644 --- a/samples/server/petstore/kotlin-server-required-and-nullable-properties/src/main/kotlin/org/openapitools/server/apis/DefaultApiService.kt +++ b/samples/server/petstore/kotlin-server-required-and-nullable-properties/src/main/kotlin/org/openapitools/server/apis/DefaultApiService.kt @@ -13,5 +13,5 @@ interface DefaultApiService { * @return Successful operation (status code 200) * @see DefaultApi#addPet */ - fun addPet(pet: Pet?, ctx: Context): Pet + fun addPet(pet: Pet? = null, ctx: Context): Pet } diff --git a/samples/server/petstore/kotlin-server/javalin-6/src/main/kotlin/org/openapitools/server/apis/PetApiService.kt b/samples/server/petstore/kotlin-server/javalin-6/src/main/kotlin/org/openapitools/server/apis/PetApiService.kt index 6c06f129bead..29534749b1ec 100644 --- a/samples/server/petstore/kotlin-server/javalin-6/src/main/kotlin/org/openapitools/server/apis/PetApiService.kt +++ b/samples/server/petstore/kotlin-server/javalin-6/src/main/kotlin/org/openapitools/server/apis/PetApiService.kt @@ -28,7 +28,7 @@ interface PetApiService { * @return Invalid pet value (status code 400) * @see PetApi#deletePet */ - fun deletePet(petId: kotlin.Long, apiKey: kotlin.String?, ctx: Context): Unit + fun deletePet(petId: kotlin.Long, apiKey: kotlin.String? = null, ctx: Context): Unit /** * GET /pet/findByStatus : Finds Pets by status @@ -95,7 +95,7 @@ interface PetApiService { * @return Invalid input (status code 405) * @see PetApi#updatePetWithForm */ - fun updatePetWithForm(petId: kotlin.Long, name: kotlin.String?, status: kotlin.String?, ctx: Context): Unit + fun updatePetWithForm(petId: kotlin.Long, name: kotlin.String? = null, status: kotlin.String? = null, ctx: Context): Unit /** * POST /pet/{petId}/uploadImage : uploads an image @@ -108,5 +108,5 @@ interface PetApiService { * @return successful operation (status code 200) * @see PetApi#uploadFile */ - fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String?, file: io.javalin.http.UploadedFile?, ctx: Context): ModelApiResponse + fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String? = null, file: io.javalin.http.UploadedFile? = null, ctx: Context): ModelApiResponse } diff --git a/samples/server/petstore/kotlin-server/javalin/src/main/kotlin/org/openapitools/server/apis/PetApiService.kt b/samples/server/petstore/kotlin-server/javalin/src/main/kotlin/org/openapitools/server/apis/PetApiService.kt index 1783de4bca80..4a1cdb0a31e2 100644 --- a/samples/server/petstore/kotlin-server/javalin/src/main/kotlin/org/openapitools/server/apis/PetApiService.kt +++ b/samples/server/petstore/kotlin-server/javalin/src/main/kotlin/org/openapitools/server/apis/PetApiService.kt @@ -25,7 +25,7 @@ interface PetApiService { * @return Invalid pet value (status code 400) * @see PetApi#deletePet */ - fun deletePet(petId: kotlin.Long, apiKey: kotlin.String?): Unit + fun deletePet(petId: kotlin.Long, apiKey: kotlin.String? = null): Unit /** * GET /pet/findByStatus : Finds Pets by status @@ -87,7 +87,7 @@ interface PetApiService { * @return Invalid input (status code 405) * @see PetApi#updatePetWithForm */ - fun updatePetWithForm(petId: kotlin.Long, name: kotlin.String?, status: kotlin.String?): Unit + fun updatePetWithForm(petId: kotlin.Long, name: kotlin.String? = null, status: kotlin.String? = null): Unit /** * POST /pet/{petId}/uploadImage : uploads an image @@ -99,5 +99,5 @@ interface PetApiService { * @return successful operation (status code 200) * @see PetApi#uploadFile */ - fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String?, file: io.javalin.http.UploadedFile?): ModelApiResponse + fun uploadFile(petId: kotlin.Long, additionalMetadata: kotlin.String? = null, file: io.javalin.http.UploadedFile? = null): ModelApiResponse } From 5731eb3ad3c759ce706331b44efbb21ae38c7bad Mon Sep 17 00:00:00 2001 From: William Cheng Date: Sat, 1 Aug 2026 23:18:47 +0800 Subject: [PATCH 11/13] Normalize map schema (3.1 spec) (#24558) * normalize 3.1 spec map schema * update samples * return updated map schema * refactor nullable check * Update modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../openapitools/codegen/DefaultCodegen.java | 20 +++++-------------- .../codegen/OpenAPINormalizer.java | 13 +++++++----- .../codegen/utils/ModelUtils.java | 4 +--- .../oas/OpenApiSchemaValidationsTest.java | 2 +- .../java/okhttp-gson-3.1/api/openapi.yaml | 1 + 5 files changed, 16 insertions(+), 24 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java index fcdd9df869bc..241b3f881ef0 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java @@ -2924,7 +2924,7 @@ protected void updateModelForComposedSchema(CodegenModel m, Schema schema, Map imports) codegenParameter.setTypeProperties(parameterSchema, openAPI); codegenParameter.setComposedSchemas(getComposedSchemas(parameterSchema)); - if (Boolean.TRUE.equals(parameterSchema.getNullable())) { // use nullable defined in the spec + if (ModelUtils.isNullable(parameterSchema)) { // use nullable defined in the spec codegenParameter.isNullable = true; } @@ -8066,7 +8054,9 @@ public CodegenParameter fromRequestBody(RequestBody body, Set imports, S if (original.getNullable() != null) { codegenParameter.isNullable = original.getNullable(); } else if (original.getExtensions() != null && original.getExtensions().containsKey(X_NULLABLE)) { - codegenParameter.isNullable = (Boolean) original.getExtensions().get(X_NULLABLE); + codegenParameter.isNullable = Boolean.parseBoolean(String.valueOf(original.getExtensions().get(X_NULLABLE))); + } else if (ModelUtils.isNullable(original)) { + codegenParameter.isNullable = true; } if (original.getExtensions() != null) { diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java index 9a304b529503..d0b71a23d1c3 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java @@ -998,8 +998,8 @@ public Schema normalizeSchema(Schema schema, Set visitedSchemas) { } normalizeProperties(schema, visitedSchemas); } else if (schema.getAdditionalProperties() instanceof Schema) { // map - normalizeMapSchema(schema); - Schema additionalProperties = (Schema) schema.getAdditionalProperties(); + Schema result = normalizeMapSchema(schema); + Schema additionalProperties = (Schema) result.getAdditionalProperties(); if (getRule(NORMALIZE_31SPEC) && ModelUtils.isNullTypeSchema(openAPI, additionalProperties)) { // OAS 3.1 allows a map value schema of `type: "null"` (e.g. // `additionalProperties: { type: "null" }`). There's no OAS 3.0 equivalent type, @@ -1008,15 +1008,17 @@ public Schema normalizeSchema(Schema schema, Set visitedSchemas) { // generated as a normal (nullable) object instead. Schema anyTypeNullable = new Schema(); anyTypeNullable.setNullable(true); - schema.setAdditionalProperties(anyTypeNullable); + result.setAdditionalProperties(anyTypeNullable); } else { Schema normalized = normalizeSchema(additionalProperties, visitedSchemas); if (getRule(NORMALIZE_31SPEC)) { // capture the normalized value schema (e.g. an OAS 3.1 `type: [array, "null"]` // value is rewritten to a proper array schema), which would otherwise be lost. - schema.setAdditionalProperties(normalized); + result.setAdditionalProperties(normalized); } } + + return result; } else if (schema instanceof BooleanSchema) { normalizeBooleanSchema(schema, visitedSchemas); } else if (schema instanceof IntegerSchema) { @@ -1105,7 +1107,8 @@ protected Schema normalizeArraySchema(Schema schema) { } protected Schema normalizeMapSchema(Schema schema) { - return processSetMapToNullable(schema); + Schema result = processNormalize31Spec(schema, new HashSet<>()); + return processSetMapToNullable(result); } protected Schema normalizeSimpleSchema(Schema schema, Set visitedSchemas) { diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index 01221e7cc26c..40058b1e90f2 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -2001,9 +2001,7 @@ public static boolean isNullable(Schema schema) { if (schema.getExtensions() != null && schema.getExtensions().get(X_NULLABLE) != null) { return Boolean.parseBoolean(schema.getExtensions().get(X_NULLABLE).toString()); } - if (schema.getTypes() != null && schema.getTypes().contains("null")) { - return true; - } + // In OAS 3.1, the recommended way to define a nullable property or object is to use oneOf. if (isComposedSchema(schema)) { return isNullableComposedSchema(schema); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidationsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidationsTest.java index e71005f794c2..a48d714d88a5 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidationsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidationsTest.java @@ -96,7 +96,7 @@ public void testNullableAttributeInOas31_triggerWarning() { /** * The nullable-deprecated warning must NOT fire for an OAS 3.1 spec using the correct 3.1 null type syntax. */ - @Test(enabled = false, description = "correct OAS 3.1 null type syntax (type: [string, null]) must NOT trigger the nullable-deprecated warning") + @Test(description = "correct OAS 3.1 null type syntax (type: [string, null]) must NOT trigger the nullable-deprecated warning") public void testNullTypeInOas31_noWarning() { OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/null-types-simple.yaml"); Schema stringDataOrNull = (Schema) openAPI.getComponents().getSchemas().get("WithNullableType").getProperties().get("stringDataOrNull"); diff --git a/samples/client/petstore/java/okhttp-gson-3.1/api/openapi.yaml b/samples/client/petstore/java/okhttp-gson-3.1/api/openapi.yaml index 4348ded2a682..6c8d12078e36 100644 --- a/samples/client/petstore/java/okhttp-gson-3.1/api/openapi.yaml +++ b/samples/client/petstore/java/okhttp-gson-3.1/api/openapi.yaml @@ -325,6 +325,7 @@ paths: additionalProperties: format: int32 type: integer + type: object description: successful operation security: - api_key: [] From 7304937073ed946b992ae8533f3b6f4adbd2deeb Mon Sep 17 00:00:00 2001 From: Mattias Sehlstedt <60173714+Mattias-Sehlstedt@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:04:35 +0200 Subject: [PATCH 12/13] chore: adjust logging message creation to ensure once() is applied as expected (#24557) --- .../openapitools/codegen/DefaultCodegen.java | 6 ++--- .../codegen/utils/ModelUtils.java | 3 ++- .../codegen/utils/OnceLogger.java | 6 +++++ .../codegen/utils/URLPathUtils.java | 26 ++++++++++++++----- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java index 241b3f881ef0..19eea4bcdeea 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java @@ -1014,7 +1014,7 @@ public boolean specVersionGreaterThanOrEqualTo310(OpenAPI openAPI) { @Override public void setOpenAPI(OpenAPI openAPI) { if (specVersionGreaterThanOrEqualTo310(openAPI)) { - LOGGER.warn(UNSUPPORTED_V310_SPEC_MSG); + once(LOGGER).warn(UNSUPPORTED_V310_SPEC_MSG); } this.openAPI = openAPI; // Set global settings such that helper functions in ModelUtils can lookup the value @@ -1264,7 +1264,7 @@ public String encodePath(String input) { */ @Override public String escapeUnsafeCharacters(String input) { - LOGGER.warn("escapeUnsafeCharacters should be overridden in the code generator with proper logic to escape " + + once(LOGGER).warn("escapeUnsafeCharacters should be overridden in the code generator with proper logic to escape " + "unsafe characters"); // doing nothing by default and code generator should implement // the logic to prevent code injection @@ -1281,7 +1281,7 @@ public String escapeUnsafeCharacters(String input) { */ @Override public String escapeQuotationMark(String input) { - LOGGER.warn("escapeQuotationMark should be overridden in the code generator with proper logic to escape " + + once(LOGGER).warn("escapeQuotationMark should be overridden in the code generator with proper logic to escape " + "single/double quote"); return input.replace("\"", "\\\""); } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index 40058b1e90f2..28dac4989f12 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -47,6 +47,7 @@ import org.openapitools.codegen.model.ModelsMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.helpers.MessageFormatter; import java.math.BigDecimal; import java.net.URI; @@ -1621,7 +1622,7 @@ public static Schema unaliasSchema(OpenAPI openAPI, Schema ref = allSchemas.get(simpleRef); if (ref == null) { if (!isRefToSchemaWithProperties(schema.get$ref())) { - once(LOGGER).warn("{} is not defined", schema.get$ref()); + once(LOGGER).warn(MessageFormatter.format("{} is not defined", schema.get$ref()).getMessage()); } return schema; } else if (isEnumSchema(ref)) { diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/OnceLogger.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/OnceLogger.java index f3a7e1bb79b4..5d7ebf521ea9 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/OnceLogger.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/OnceLogger.java @@ -91,6 +91,12 @@ static void caffeineCache(Ticker ticker, int expireMillis) { .build(); } + /** + * This implementation currently only supports single-argument string literal log methods (e.g. {@link Logger#debug(String)}). + * + * @param logger The logger that should only log once for single-argument string literal log methods. + * @return The {@link OnceLogger} + */ public static Logger once(Logger logger) { try { if (Boolean.parseBoolean(GlobalSettings.getProperty(ENABLE_ONCE_LOGGER_PROPERTY, "true"))) { diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/URLPathUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/URLPathUtils.java index 47c98f557411..3405fccdf9cf 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/URLPathUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/URLPathUtils.java @@ -25,6 +25,7 @@ import org.openapitools.codegen.CodegenConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.helpers.MessageFormatter; import java.net.MalformedURLException; import java.net.URL; @@ -37,14 +38,21 @@ public class URLPathUtils { private static final Logger LOGGER = LoggerFactory.getLogger(URLPathUtils.class); + private static final String SERVER_NOT_SPECIFIED = + "'host' (OAS 2.0) or 'servers' (OAS 3.0) not defined in the spec. Default to [{}] for server URL [{}]"; + private static final String SCHEME_NOT_DEFINED = "'scheme' not defined in the spec (2.0). Default to [http] for server URL [{}]"; + private static final String NO_SERVER_INFO = "Server information not defined in the spec. Default to {}."; + private static final String INVALID_URL = "Not a valid URL: {}. Default to {}."; public static final String LOCAL_HOST = "http://localhost"; public static final Pattern VARIABLE_PATTERN = Pattern.compile("(? userDefinedVariables) { final List servers = openAPI.getServers(); if (servers == null || servers.isEmpty()) { - once(LOGGER).warn("Server information seems not defined in the spec. Default to {}.", LOCAL_HOST); + String message = MessageFormatter.format(NO_SERVER_INFO, LOCAL_HOST).getMessage(); + once(LOGGER).warn(message); return getDefaultUrl(); } // TODO need a way to obtain all server URLs @@ -67,7 +75,8 @@ public static URL getServerURL(final Server server, final Map us try { return new URL(url); } catch (MalformedURLException e) { - once(LOGGER).warn("Not valid URL: {}. Default to {}.", server.getUrl(), LOCAL_HOST); + String malformedUrl = MessageFormatter.format(INVALID_URL, server.getUrl(), LOCAL_HOST).getMessage(); + once(LOGGER).warn(malformedUrl); } } return getDefaultUrl(); @@ -206,19 +215,22 @@ private static String sanitizeUrl(String url) { if (url != null) { if (url.startsWith("//")) { url = "http:" + url; - once(LOGGER).warn("'scheme' not defined in the spec (2.0). Default to [http] for server URL [{}]", url); + String missingScheme = MessageFormatter.format(SCHEME_NOT_DEFINED, url).getMessage(); + once(LOGGER).warn(missingScheme); } else if (url.startsWith("/")) { url = LOCAL_HOST + url; - once(LOGGER).info("'host' (OAS 2.0) or 'servers' (OAS 3.0) not defined in the spec. Default to [{}] for server URL [{}]", LOCAL_HOST, url); - } else if (!url.matches("[a-zA-Z][0-9a-zA-Z.+\\-]+://.+")) { + String serverDefaultToLocalhost = MessageFormatter.format(SERVER_NOT_SPECIFIED, LOCAL_HOST, url).getMessage(); + once(LOGGER).info(serverDefaultToLocalhost); + } else if (!URL_WITH_SCHEME.matcher(url).matches()) { // Add http scheme for urls without a scheme. // 2.0 spec is restricted to the following schemes: "http", "https", "ws", "wss" // 3.0 spec does not have an enumerated list of schemes // This regex attempts to capture all schemes in IANA example schemes which - // can have alpha-numeric characters and [.+-]. Examples are here: + // can have alphanumeric characters and [.+-]. Examples are here: // https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml url = "http://" + url; - once(LOGGER).warn("'scheme' not defined in the spec (2.0). Default to [http] for server URL [{}]", url); + String missingScheme = MessageFormatter.format(SCHEME_NOT_DEFINED, url).getMessage(); + once(LOGGER).warn(missingScheme); } } return url; From abeb86ecd767cff9d222fe172e6d5268f9bd39b6 Mon Sep 17 00:00:00 2001 From: Ignacio Vidal Date: Sat, 1 Aug 2026 18:24:44 +0100 Subject: [PATCH 13/13] [jaxrs-spec] Fix builder field type for JsonNullable properties With `generateBuilders=true`, a property carrying `x-is-jackson-optional-nullable` produced a model that does not compile: error: incompatible types: String cannot be converted to JsonNullable The pojo field is rendered as `JsonNullable`, but the generated builder declared the property with its raw type, so the copy constructor's `this.x = b.x` mismatched. Declare the builder field as `JsonNullable` (defaulting to `undefined()`, or `of(default)` for a non-container with a default, mirroring the pojo field), and have the builder's setter take the raw type and wrap it, mirroring the pojo's own fluent setter. No existing sample combines `generateBuilders` with a JsonNullable property, which is why this went unnoticed; regenerating the jaxrs samples produces no diff. --- .../resources/JavaJaxRS/spec/pojo.mustache | 10 +++++ .../jaxrs/JavaJAXRSSpecServerCodegenTest.java | 39 +++++++++++++++++++ .../src/test/resources/bugs/issue_24561.yaml | 27 +++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 modules/openapi-generator/src/test/resources/bugs/issue_24561.yaml diff --git a/modules/openapi-generator/src/main/resources/JavaJaxRS/spec/pojo.mustache b/modules/openapi-generator/src/main/resources/JavaJaxRS/spec/pojo.mustache index 2e6f060545e4..e5039c6bcd36 100644 --- a/modules/openapi-generator/src/main/resources/JavaJaxRS/spec/pojo.mustache +++ b/modules/openapi-generator/src/main/resources/JavaJaxRS/spec/pojo.mustache @@ -367,7 +367,12 @@ public {{>sealed}}class {{classname}} {{#parent}}extends {{{.}}}{{/parent}} {{#v public static abstract class {{classname}}Builder> {{#parent}}extends {{{.}}}Builder{{/parent}} { {{#vars}} + {{#vendorExtensions.x-is-jackson-optional-nullable}} + private JsonNullable<{{#removeAnnotations}}{{{datatypeWithEnum}}}{{/removeAnnotations}}> {{name}} = JsonNullable.<{{#removeAnnotations}}{{{datatypeWithEnum}}}{{/removeAnnotations}}>{{#isContainer}}undefined(){{/isContainer}}{{^isContainer}}{{#defaultValue}}of({{{.}}}){{/defaultValue}}{{^defaultValue}}undefined(){{/defaultValue}}{{/isContainer}}; + {{/vendorExtensions.x-is-jackson-optional-nullable}} + {{^vendorExtensions.x-is-jackson-optional-nullable}} private {{#removeAnnotations}}{{{datatypeWithEnum}}}{{/removeAnnotations}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}; + {{/vendorExtensions.x-is-jackson-optional-nullable}} {{/vars}} {{^parent}} protected abstract B self(); @@ -380,7 +385,12 @@ public {{>sealed}}class {{classname}} {{#parent}}extends {{{.}}}{{/parent}} {{#v @Deprecated {{/deprecated}} public B {{name}}({{#removeAnnotations}}{{{datatypeWithEnum}}}{{/removeAnnotations}} {{name}}) { + {{#vendorExtensions.x-is-jackson-optional-nullable}} + this.{{name}} = JsonNullable.<{{#removeAnnotations}}{{{datatypeWithEnum}}}{{/removeAnnotations}}>of({{name}}); + {{/vendorExtensions.x-is-jackson-optional-nullable}} + {{^vendorExtensions.x-is-jackson-optional-nullable}} this.{{name}} = {{name}}; + {{/vendorExtensions.x-is-jackson-optional-nullable}} return self(); } {{/vars}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/jaxrs/JavaJAXRSSpecServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/jaxrs/JavaJAXRSSpecServerCodegenTest.java index 2e5b97cb646f..b8faba728be8 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/jaxrs/JavaJAXRSSpecServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/jaxrs/JavaJAXRSSpecServerCodegenTest.java @@ -283,6 +283,45 @@ public void testGeneratePingDefaultArrayValue() throws Exception { output.deleteOnExit(); } + @Test + public void testBuilderFieldMatchesJsonNullableFieldType_issue24561() throws Exception { + final File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + Map properties = new HashMap<>(); + properties.put(org.openapitools.codegen.languages.AbstractJavaCodegen.GENERATE_BUILDERS, true); + properties.put(org.openapitools.codegen.languages.AbstractJavaCodegen.OPENAPI_NULLABLE, true); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("jaxrs-spec") + .setAdditionalProperties(properties) + .setInputSpec("src/test/resources/bugs/issue_24561.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + List files = new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + + // The pojo field is JsonNullable, so the builder field must be too: the + // generated copy constructor does `this.nullableProperty = b.nullableProperty`, which + // did not compile while the builder declared a plain String. The builder's setter still + // takes the raw type and wraps it, matching the pojo's own fluent setter. + validateJavaSourceFiles(files); + + Path path = Paths.get(output.toPath() + "/src/gen/java/org/openapitools/model/Thing.java"); + String thing = Files.readString(path); + String builder = thing.substring(thing.indexOf("public static abstract class ThingBuilder")); + + // The builder field must be JsonNullable, matching the pojo field it is copied + // into. It previously declared a plain String, so `this.x = b.x` did not compile. + assertTrue(builder.contains("private JsonNullable nullableProperty = JsonNullable.undefined();"), + "builder field for a JsonNullable property must be JsonNullable, but was:\n" + builder); + // The setter still takes the raw type and wraps it, like the pojo's fluent setter. + assertTrue(builder.contains("this.nullableProperty = JsonNullable.of(nullableProperty);"), + "builder setter must wrap with JsonNullable.of(), but was:\n" + builder); + // A property without the extension is untouched. + assertTrue(builder.contains("private String plainProperty;"), + "plain property must keep its raw type in the builder, but was:\n" + builder); + } + @Test public void testGeneratePingNoSpecFile() throws Exception { Map properties = new HashMap<>(); diff --git a/modules/openapi-generator/src/test/resources/bugs/issue_24561.yaml b/modules/openapi-generator/src/test/resources/bugs/issue_24561.yaml new file mode 100644 index 000000000000..1389f288af48 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/issue_24561.yaml @@ -0,0 +1,27 @@ +openapi: 3.0.3 +info: + title: generateBuilders with a JsonNullable property + version: 1.0.0 +paths: + /thing: + get: + operationId: getThing + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "#/components/schemas/Thing" +components: + schemas: + Thing: + type: object + properties: + # The pojo field for this property is JsonNullable. The builder + # field has to match, or the generated `this.x = b.x` does not compile. + nullableProperty: + type: string + x-is-jackson-optional-nullable: true + plainProperty: + type: string