diff --git a/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/GenerationConfigurationConverter.java b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/GenerationConfigurationConverter.java index 999398f322..fa7106f61c 100644 --- a/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/GenerationConfigurationConverter.java +++ b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/GenerationConfigurationConverter.java @@ -10,6 +10,7 @@ import java.util.Map; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import org.openapitools.codegen.ClientOptInput; import org.openapitools.codegen.CodegenConstants; @@ -46,7 +47,7 @@ class GenerationConfigurationConverter static final String COPYRIGHT_PROPERTY_KEY = "copyrightHeader"; static final String SAP_COPYRIGHT_HEADER = - "Copyright (c) " + Year.now() + " SAP SE or an SAP affiliate company. All rights reserved."; + "Copyright (c) " + Year.now() + " SAP SE or an SAP affiliate company. " + "All rights reserved."; static final String TEMPLATE_DIRECTORY = Paths.get("openapi-generator").resolve("mustache-templates").toString(); static final String LIBRARY_NAME = JavaClientCodegen.RESTTEMPLATE; static final String SUPPORT_URL_QUERY = "supportUrlQuery"; @@ -132,6 +133,9 @@ private static void setGlobalSettings( @Nonnull final GenerationConfiguration co void preprocessSpecification( @Nonnull final OpenAPI openAPI, @Nonnull final GenerationConfiguration config ) { + // Simplify oneOf/anyOf schemas that have only a single option + //simplifyComposedSchemas(openAPI); + if( !FIX_RESPONSE_SCHEMA_TITLES.isEnabled(config) ) { return; } @@ -157,6 +161,182 @@ private static void setGlobalSettings( @Nonnull final GenerationConfiguration co }); } + /** + * Simplifies oneOf/anyOf schemas that have only a single option by removing the composition constraint and + * flattening to the referenced schema. This prevents the creation of unnecessary wrapper classes. + * + * @param openAPI + * the OpenAPI specification to preprocess + */ + @SuppressWarnings( "rawtypes" ) + private static void simplifyComposedSchemas( @Nonnull final OpenAPI openAPI ) + { + final Components components = openAPI.getComponents(); + if( components == null || components.getSchemas() == null ) { + return; + } + + final Map schemas = components.getSchemas(); + final Map schemaReplacements = identifyWrapperSchemas(schemas); + + if( !schemaReplacements.isEmpty() ) { + replaceSchemaReferences(openAPI, schemaReplacements); + schemaReplacements.keySet().forEach(schemas::remove); + } + + // Simplify remaining composed schemas + new HashMap<>(schemas).forEach(( name, schema ) -> simplifyComposedSchema(schema)); + } + + /** + * Identifies wrapper schemas that have only oneOf/anyOf with a single reference. + */ + @SuppressWarnings( { "rawtypes" } ) + private static Map identifyWrapperSchemas( @Nonnull final Map schemas ) + { + final Map replacements = new HashMap<>(); + + for( final Map.Entry entry : schemas.entrySet() ) { + final Schema schema = entry.getValue(); + final Schema referencedSchema = extractSingleComposedOption(schema); + + if( referencedSchema != null && referencedSchema.get$ref() != null ) { + final String refName = extractRefName(referencedSchema.get$ref()); + replacements.put(entry.getKey(), refName); + log.error("Identified wrapper schema {} that should be replaced with {}", entry.getKey(), refName); + } + } + + return replacements; + } + + /** + * Extracts the single referenced schema from oneOf or anyOf, if it's the only composition option. + */ + @SuppressWarnings( { "rawtypes", "unchecked" } ) + @Nullable + private static Schema extractSingleComposedOption( @Nonnull final Schema schema ) + { + if( schema.getEnum() != null ) { + return null; + } + + if( isSimpleComposition(schema.getOneOf(), schema.getAnyOf(), schema.getAllOf(), schema.getProperties()) ) { + if( schema.getOneOf() != null && schema.getOneOf().size() == 1 ) { + return (Schema) schema.getOneOf().get(0); + } + if( schema.getAnyOf() != null && schema.getAnyOf().size() == 1 ) { + return (Schema) schema.getAnyOf().get(0); + } + } + return null; + } + + /** + * Checks if a schema has only a single composition type with no other composition or properties. + */ + private static boolean isSimpleComposition( + @Nullable final java.util.List oneOf, + @Nullable final java.util.List anyOf, + @Nullable final java.util.List allOf, + @Nullable final Map properties ) + { + final boolean hasOneOf = oneOf != null && oneOf.size() == 1; + final boolean hasAnyOf = anyOf != null && anyOf.size() == 1; + + // Ensure only one composition type is present, and no properties + return ((hasOneOf ^ hasAnyOf) && allOf == null && properties == null); + } + + /** + * Extracts the schema name from a $ref string like "#/components/schemas/SchemaName". + */ + private static String extractRefName( @Nonnull final String ref ) + { + return ref.substring(ref.lastIndexOf('/') + 1); + } + + /** + * Replaces all references to wrapper schemas with the actual referenced schema throughout the OpenAPI spec. + */ + private static + void + replaceSchemaReferences( @Nonnull final OpenAPI openAPI, @Nonnull final Map replacements ) + { + final Components components = openAPI.getComponents(); + if( components != null && components.getParameters() != null ) { + components.getParameters().values().forEach(param -> replaceSchemaRef(param.getSchema(), replacements)); + } + + if( openAPI.getPaths() != null ) { + openAPI.getPaths().values().forEach(pathItem -> pathItem.readOperationsMap().values().forEach(operation -> { + if( operation.getParameters() != null ) { + operation.getParameters().forEach(param -> replaceSchemaRef(param.getSchema(), replacements)); + } + })); + } + } + + /** + * Replaces a schema reference if it matches one of the wrapper schemas. + */ + @SuppressWarnings( "rawtypes" ) + private static + void + replaceSchemaRef( @Nullable final Schema schema, @Nonnull final Map replacements ) + { + if( schema != null && schema.get$ref() != null ) { + final String refName = extractRefName(schema.get$ref()); + final String newRefName = replacements.get(refName); + if( newRefName != null ) { + schema.set$ref("#/components/schemas/" + newRefName); + log.error("Replaced schema reference {} with {}", refName, newRefName); + } + } + } + + /** + * Recursively simplifies a single schema by clearing oneOf/anyOf constraints and processing nested schemas. + */ + @SuppressWarnings( { "rawtypes", "unchecked" } ) + private static void simplifyComposedSchema( @Nullable final Schema schema ) + { + if( schema == null ) { + return; + } + + // Clear single-option composition constraints + clearSingleOptionComposition(schema); + + // Recursively simplify nested schemas + if( schema.getProperties() != null ) { + schema.getProperties().values().forEach(s -> simplifyComposedSchema((Schema) s)); + } + if( schema.getItems() != null ) { + simplifyComposedSchema(schema.getItems()); + } + if( schema.getAdditionalProperties() instanceof Schema ) { + simplifyComposedSchema((Schema) schema.getAdditionalProperties()); + } + if( schema.getAllOf() != null ) { + schema.getAllOf().forEach(s -> simplifyComposedSchema((Schema) s)); + } + } + + /** + * Clears oneOf/anyOf constraints when they have only a single option. + */ + @SuppressWarnings( { "rawtypes", "unchecked" } ) + private static void clearSingleOptionComposition( @Nonnull final Schema schema ) + { + if( schema.getOneOf() != null && schema.getOneOf().size() == 1 ) { + schema.setOneOf(null); + } + if( schema.getAnyOf() != null && schema.getAnyOf().size() == 1 ) { + schema.setAnyOf(null); + } + } + private static Map getAdditionalProperties( @Nonnull final GenerationConfiguration config ) { log.info("Using {} as {}.", ApiMaturity.class.getSimpleName(), config.getApiMaturity()); @@ -186,7 +366,8 @@ private static Map getAdditionalProperties( @Nonnull final Gener config.getAdditionalProperties().forEach(( k, v ) -> { if( result.containsKey(k) ) { final var msg = - "Replacing default value \"{}\" for additional property \"{}\" with \"{}\" from user provided configuration."; + "Replacing default value \"{}\" for additional property \"{}\" with \"{}\" from user " + + "provided configuration."; log.info(msg, result.get(k), k, v); } result.put(k, v); diff --git a/datamodel/openapi/openapi-generator/src/test/java/com/sap/cloud/sdk/datamodel/openapi/generator/DataModelGeneratorIntegrationTest.java b/datamodel/openapi/openapi-generator/src/test/java/com/sap/cloud/sdk/datamodel/openapi/generator/DataModelGeneratorIntegrationTest.java index 2bf6cc632b..27487ab474 100644 --- a/datamodel/openapi/openapi-generator/src/test/java/com/sap/cloud/sdk/datamodel/openapi/generator/DataModelGeneratorIntegrationTest.java +++ b/datamodel/openapi/openapi-generator/src/test/java/com/sap/cloud/sdk/datamodel/openapi/generator/DataModelGeneratorIntegrationTest.java @@ -46,7 +46,7 @@ enum TestCase ApiMaturity.RELEASED, false, true, - 4, + 5, Map.of(), Map.of()), API_CLASS_VENDOR_EXTENSION_JSON( diff --git a/datamodel/openapi/openapi-generator/src/test/resources/DataModelGeneratorApacheIntegrationTest/api-class-vendor-extension-yaml/input/sodastore.yaml b/datamodel/openapi/openapi-generator/src/test/resources/DataModelGeneratorApacheIntegrationTest/api-class-vendor-extension-yaml/input/sodastore.yaml index 0acd4ad2df..4d9f0a2dd1 100644 --- a/datamodel/openapi/openapi-generator/src/test/resources/DataModelGeneratorApacheIntegrationTest/api-class-vendor-extension-yaml/input/sodastore.yaml +++ b/datamodel/openapi/openapi-generator/src/test/resources/DataModelGeneratorApacheIntegrationTest/api-class-vendor-extension-yaml/input/sodastore.yaml @@ -36,6 +36,23 @@ paths: schema: $ref: '#/components/schemas/Soda' + delete: + summary: String parameter test + description: Should simplify the parameter to a string + parameters: + - $ref: '#/components/parameters/OneOfStringParam' + tags: + - OneOfStringTest + responses: + '200': + description: A list of soda products + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Soda' + /sodas/{sodaId}: get: summary: Get details of a specific soda @@ -103,7 +120,26 @@ paths: description: Soda not found components: + parameters: + OneOfStringParam: + name: executionId + description: The Id of an execution + schema: + $ref: '#/components/schemas/OneOfString' + in: query + required: true schemas: + OneOfString: + type: string + example: aa97b177-9383-4934-8543-0f91b7a0283a + oneOf: + - $ref: '#/components/schemas/String' + description: ID of the deployment/execution + String: + description: Generic ID + pattern: ^[\w.-]{4,64}$ + type: string + example: aa97b177-9383-4934-8543-0f91b7a0283a Soda: type: object properties: diff --git a/datamodel/openapi/openapi-generator/src/test/resources/DataModelGeneratorApacheIntegrationTest/api-class-vendor-extension-yaml/output/com/sap/cloud/sdk/services/apiclassvendorextension/api/OneOfStringTestApi.java b/datamodel/openapi/openapi-generator/src/test/resources/DataModelGeneratorApacheIntegrationTest/api-class-vendor-extension-yaml/output/com/sap/cloud/sdk/services/apiclassvendorextension/api/OneOfStringTestApi.java new file mode 100644 index 0000000000..9baf1b772d --- /dev/null +++ b/datamodel/openapi/openapi-generator/src/test/resources/DataModelGeneratorApacheIntegrationTest/api-class-vendor-extension-yaml/output/com/sap/cloud/sdk/services/apiclassvendorextension/api/OneOfStringTestApi.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026 SAP SE or an SAP affiliate company. All rights reserved. + */ + +package com.sap.cloud.sdk.services.apiclassvendorextension.api; + +import com.fasterxml.jackson.core.type.TypeReference; + +import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiResponse; +import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; +import com.sap.cloud.sdk.services.openapi.apache.apiclient.BaseApi; +import com.sap.cloud.sdk.services.openapi.apache.apiclient.Pair; + + +import com.sap.cloud.sdk.services.apiclassvendorextension.model.OneOfString; +import com.sap.cloud.sdk.services.apiclassvendorextension.model.Soda; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.StringJoiner; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; + + +/** + * Soda Store API in version 1.0.0. + *

+ * API for managing sodas in a soda store + */ +public class OneOfStringTestApi extends BaseApi { + + /** + * Instantiates this API class to invoke operations on the Soda Store API. + * + * @param httpDestination The destination that API should be used with + */ + public OneOfStringTestApi( @Nonnull final Destination httpDestination ) + { + super(httpDestination); + } + + /** + * Instantiates this API class to invoke operations on the Soda Store API based on a given {@link ApiClient}. + * + * @param apiClient + * ApiClient to invoke the API on + */ + public OneOfStringTestApi(@Nonnull final ApiClient apiClient) { + super(apiClient); + } + + /** + * Creates a new API instance with additional default headers. + * + * @param defaultHeaders Additional headers to include in all requests + * @return A new API instance with the combined headers + */ + public OneOfStringTestApi withDefaultHeaders(@Nonnull final Map defaultHeaders) { + final var api = new OneOfStringTestApi(apiClient); + api.defaultHeaders.putAll(this.defaultHeaders); + api.defaultHeaders.putAll(defaultHeaders); + return api; + } + + + /** + *

String parameter test + *

Should simplify the parameter to a string + *

200 - A list of soda products + * @param executionId + * The Id of an execution + * @return List<Soda> + * @throws OpenApiRequestException if an error occurs while attempting to invoke the API + */ + @Nonnull + public List sodasDelete(@Nonnull final OneOfString executionId) throws OpenApiRequestException { + + // verify the required parameter 'executionId' is set + if (executionId == null) { + throw new OpenApiRequestException("Missing the required parameter 'executionId' when calling sodasDelete") + .statusCode(400); + } + + // create path and map variables + final String localVarPath = "/sodas"; + + final StringJoiner localVarQueryStringJoiner = new StringJoiner("&"); + final List localVarQueryParams = new ArrayList(); + final List localVarCollectionQueryParams = new ArrayList(); + final Map localVarHeaderParams = new HashMap(defaultHeaders); + final Map localVarFormParams = new HashMap(); + + localVarQueryParams.addAll(ApiClient.parameterToPair("executionId", executionId)); + + final String[] localVarAccepts = { + "application/json" + }; + final String localVarAccept = ApiClient.selectHeaderAccept(localVarAccepts); + final String[] localVarContentTypes = { + + }; + final String localVarContentType = ApiClient.selectHeaderContentType(localVarContentTypes); + + final TypeReference> localVarReturnType = new TypeReference>() {}; + + return apiClient.invokeAPI( + localVarPath, + "DELETE", + localVarQueryParams, + localVarCollectionQueryParams, + localVarQueryStringJoiner.toString(), + null, + localVarHeaderParams, + localVarFormParams, + localVarAccept, + localVarContentType, + localVarReturnType + ); + } + } diff --git a/datamodel/openapi/openapi-generator/src/test/resources/DataModelGeneratorIntegrationTest/api-class-vendor-extension-yaml/input/sodastore.yaml b/datamodel/openapi/openapi-generator/src/test/resources/DataModelGeneratorIntegrationTest/api-class-vendor-extension-yaml/input/sodastore.yaml index 0acd4ad2df..4d9f0a2dd1 100644 --- a/datamodel/openapi/openapi-generator/src/test/resources/DataModelGeneratorIntegrationTest/api-class-vendor-extension-yaml/input/sodastore.yaml +++ b/datamodel/openapi/openapi-generator/src/test/resources/DataModelGeneratorIntegrationTest/api-class-vendor-extension-yaml/input/sodastore.yaml @@ -36,6 +36,23 @@ paths: schema: $ref: '#/components/schemas/Soda' + delete: + summary: String parameter test + description: Should simplify the parameter to a string + parameters: + - $ref: '#/components/parameters/OneOfStringParam' + tags: + - OneOfStringTest + responses: + '200': + description: A list of soda products + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Soda' + /sodas/{sodaId}: get: summary: Get details of a specific soda @@ -103,7 +120,26 @@ paths: description: Soda not found components: + parameters: + OneOfStringParam: + name: executionId + description: The Id of an execution + schema: + $ref: '#/components/schemas/OneOfString' + in: query + required: true schemas: + OneOfString: + type: string + example: aa97b177-9383-4934-8543-0f91b7a0283a + oneOf: + - $ref: '#/components/schemas/String' + description: ID of the deployment/execution + String: + description: Generic ID + pattern: ^[\w.-]{4,64}$ + type: string + example: aa97b177-9383-4934-8543-0f91b7a0283a Soda: type: object properties: diff --git a/datamodel/openapi/openapi-generator/src/test/resources/DataModelGeneratorIntegrationTest/api-class-vendor-extension-yaml/output/com/sap/cloud/sdk/services/apiclassvendorextension/api/OneOfStringTestApi.java b/datamodel/openapi/openapi-generator/src/test/resources/DataModelGeneratorIntegrationTest/api-class-vendor-extension-yaml/output/com/sap/cloud/sdk/services/apiclassvendorextension/api/OneOfStringTestApi.java new file mode 100644 index 0000000000..ebe375fdd8 --- /dev/null +++ b/datamodel/openapi/openapi-generator/src/test/resources/DataModelGeneratorIntegrationTest/api-class-vendor-extension-yaml/output/com/sap/cloud/sdk/services/apiclassvendorextension/api/OneOfStringTestApi.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026 SAP SE or an SAP affiliate company. All rights reserved. + */ + +package com.sap.cloud.sdk.services.apiclassvendorextension.api; + +import com.sap.cloud.sdk.services.openapi.core.OpenApiRequestException; +import com.sap.cloud.sdk.services.openapi.core.OpenApiResponse; +import com.sap.cloud.sdk.services.openapi.core.AbstractOpenApiService; +import com.sap.cloud.sdk.services.openapi.apiclient.ApiClient; + +import com.sap.cloud.sdk.services.apiclassvendorextension.model.Soda; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import com.google.common.annotations.Beta; + +import com.sap.cloud.sdk.cloudplatform.connectivity.Destination; + +/** + * Soda Store API in version 1.0.0. + * + * API for managing sodas in a soda store + */ +public class OneOfStringTestApi extends AbstractOpenApiService { + /** + * Instantiates this API class to invoke operations on the Soda Store API. + * + * @param httpDestination The destination that API should be used with + */ + public OneOfStringTestApi( @Nonnull final Destination httpDestination ) + { + super(httpDestination); + } + + /** + * Instantiates this API class to invoke operations on the Soda Store API based on a given {@link ApiClient}. + * + * @param apiClient + * ApiClient to invoke the API on + */ + @Beta + public OneOfStringTestApi( @Nonnull final ApiClient apiClient ) + { + super(apiClient); + } + + /** + *

String parameter test

+ *

Should simplify the parameter to a string

+ *

200 - A list of soda products + * @param executionId + * The Id of an execution + * @return List<Soda> + * @throws OpenApiRequestException if an error occurs while attempting to invoke the API + */ + @Nonnull + public List sodasDelete( @Nonnull final String executionId) throws OpenApiRequestException { + final Object localVarPostBody = null; + + // verify the required parameter 'executionId' is set + if (executionId == null) { + throw new OpenApiRequestException("Missing the required parameter 'executionId' when calling sodasDelete"); + } + + final String localVarPath = UriComponentsBuilder.fromPath("/sodas").build().toUriString(); + + final MultiValueMap localVarQueryParams = new LinkedMultiValueMap(); + final HttpHeaders localVarHeaderParams = new HttpHeaders(); + final MultiValueMap localVarFormParams = new LinkedMultiValueMap(); + + localVarQueryParams.putAll(apiClient.parameterToMultiValueMap(null, "executionId", executionId)); + + final String[] localVarAccepts = { + "application/json" + }; + final List localVarAccept = apiClient.selectHeaderAccept(localVarAccepts); + final String[] localVarContentTypes = { }; + final MediaType localVarContentType = apiClient.selectHeaderContentType(localVarContentTypes); + + final String[] localVarAuthNames = new String[] { }; + + final ParameterizedTypeReference> localVarReturnType = new ParameterizedTypeReference>() {}; + return apiClient.invokeAPI(localVarPath, HttpMethod.DELETE, localVarQueryParams, localVarPostBody, localVarHeaderParams, localVarFormParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + } +}