diff --git a/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/CustomJavaClientCodegen.java b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/CustomJavaClientCodegen.java index 720e6b47e..a84a68083 100644 --- a/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/CustomJavaClientCodegen.java +++ b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/CustomJavaClientCodegen.java @@ -1,27 +1,14 @@ package com.sap.cloud.sdk.datamodel.openapi.generator; -import static com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomProperties.FIX_REDUNDANT_IS_BOOLEAN_PREFIX; -import static com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomProperties.FIX_REMOVE_UNUSED_COMPONENTS; -import static com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomProperties.USE_EXCLUDE_PATHS; -import static com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomProperties.USE_EXCLUDE_PROPERTIES; -import static com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomProperties.USE_FLOAT_ARRAYS; -import static com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomProperties.USE_ONE_OF_CREATORS; - -import java.util.Arrays; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.Set; -import java.util.function.Predicate; -import java.util.regex.Pattern; +import java.util.function.Consumer; +import java.util.function.Function; import javax.annotation.Nonnull; import javax.annotation.Nullable; import org.openapitools.codegen.CodegenModel; -import org.openapitools.codegen.CodegenOperation; import org.openapitools.codegen.CodegenProperty; import org.openapitools.codegen.languages.JavaClientCodegen; import org.openapitools.codegen.model.ModelMap; @@ -33,44 +20,53 @@ import io.swagger.v3.oas.models.media.Schema; import lombok.extern.slf4j.Slf4j; -@SuppressWarnings( "PMD.TooManyStaticImports" ) @Slf4j class CustomJavaClientCodegen extends JavaClientCodegen { + private final List customizations; private final GenerationConfiguration config; - private static final Predicate DOUBLE_IS_PATTERN = Pattern.compile("^isIs[A-Z]").asPredicate(); - private static final Set PRIMITIVES = Set.of("String", "Integer", "Long", "Double", "Float", "Byte"); public CustomJavaClientCodegen( @Nonnull final GenerationConfiguration config ) { this.config = config; + this.customizations = GeneratorCustomization.getCustomizations(config); } - @Override - public void preprocessOpenAPI( @Nonnull final OpenAPI openAPI ) + private , ValueT> ValueT chainedContextReturn( + @Nonnull final Class handlerClass, + @Nonnull final HandlerT rootHandler, + @Nonnull final Function, ValueT> initiator ) { - if( USE_EXCLUDE_PROPERTIES.isEnabled(config) ) { - final String[] exclusions = USE_EXCLUDE_PROPERTIES.getValue(config).trim().split("[,\\s]+"); - for( final String exclusion : exclusions ) { - final String[] split = exclusion.split("\\.", 2); - preprocessRemoveProperty(openAPI, split[0], split[1]); + var chainedContext = rootHandler. chained(config, null); + for( final GeneratorCustomization customization : customizations ) { + if( handlerClass.isInstance(customization) ) { + chainedContext = handlerClass.cast(customization).chained(config, chainedContext); } } + return initiator.apply(chainedContext); + } - if( USE_EXCLUDE_PATHS.isEnabled(config) ) { - final String[] exclusions = USE_EXCLUDE_PATHS.getValue(config).trim().split("[,\\s]+"); - for( final String exclusion : exclusions ) { - if( !openAPI.getPaths().keySet().remove(exclusion) ) { - log.error("Could not remove path {}", exclusion); - } + private > void chainedContextVoid( + @Nonnull final Class handlerClass, + @Nonnull final HandlerT rootHandler, + @Nonnull final Consumer> initiator ) + { + var chainedContext = rootHandler.chained(config, null); + for( final GeneratorCustomization customization : customizations ) { + if( handlerClass.isInstance(customization) ) { + chainedContext = handlerClass.cast(customization).chained(config, chainedContext); } } + initiator.accept(chainedContext); + } - super.preprocessOpenAPI(openAPI); - - if( FIX_REMOVE_UNUSED_COMPONENTS.isEnabled(config) ) { - preprocessRemoveRedundancies(openAPI); - } + @Override + public void preprocessOpenAPI( @Nonnull final OpenAPI openAPI ) + { + chainedContextVoid( + GeneratorCustomization.PreProcessOpenAPI.class, + ( context, openAPI1 ) -> super.preprocessOpenAPI(openAPI1), + context -> context.get().preprocessOpenAPI(context, openAPI)); } @Override @@ -78,12 +74,10 @@ public void preprocessOpenAPI( @Nonnull final OpenAPI openAPI ) void updatePropertyForArray( @Nonnull final CodegenProperty property, @Nonnull final CodegenProperty innerProperty ) { - super.updatePropertyForArray(property, innerProperty); - - if( USE_FLOAT_ARRAYS.isEnabled(config) && innerProperty.isNumber && property.isArray ) { - property.datatypeWithEnum = "float[]"; - property.vendorExtensions.put("isPrimitiveArray", true); - } + chainedContextVoid( + GeneratorCustomization.UpdatePropertyForArray.class, + ( context, property1, innerProperty1 ) -> super.updatePropertyForArray(property1, innerProperty1), + context -> context.get().updatePropertyForArray(context, property, innerProperty)); } @SuppressWarnings( { "rawtypes", "RedundantSuppression" } ) @@ -91,21 +85,20 @@ public void preprocessOpenAPI( @Nonnull final OpenAPI openAPI ) @Nullable public String toDefaultValue( @Nonnull final CodegenProperty cp, @Nonnull final Schema schema ) { - if( USE_FLOAT_ARRAYS.isEnabled(config) && "float[]".equals(cp.datatypeWithEnum) ) { - return null; - } - return super.toDefaultValue(cp, schema); + return chainedContextReturn( + GeneratorCustomization.ToDefaultValue.class, + ( context, cp1, schema1 ) -> super.toDefaultValue(cp1, schema1), + context -> context.get().toDefaultValue(context, cp, schema)); } @Override @Nullable public String toBooleanGetter( @Nullable final String name ) { - final String result = super.toBooleanGetter(name); - if( FIX_REDUNDANT_IS_BOOLEAN_PREFIX.isEnabled(config) && result != null && DOUBLE_IS_PATTERN.test(result) ) { - return "is" + result.substring(4); - } - return result; + return chainedContextReturn( + GeneratorCustomization.ToBooleanGetter.class, + ( context, name1 ) -> super.toBooleanGetter(name1), + context -> context.get().toBooleanGetter(context, name)); } // Custom processor to inject "x-return-nullable" extension @@ -115,14 +108,10 @@ public String toBooleanGetter( @Nullable final String name ) OperationsMap postProcessOperationsWithModels( @Nonnull final OperationsMap ops, @Nonnull final List allModels ) { - for( final CodegenOperation op : ops.getOperations().getOperation() ) { - final var noContent = - op.isResponseOptional - || op.responses == null - || op.responses.stream().anyMatch(r -> "204".equals(r.code)); - op.vendorExtensions.put("x-return-nullable", op.returnType != null && noContent); - } - return super.postProcessOperationsWithModels(ops, allModels); + return chainedContextReturn( + GeneratorCustomization.PostProcessOperationsWithModels.class, + ( context, ops1, allModels1 ) -> super.postProcessOperationsWithModels(ops1, allModels1), + context -> context.get().postProcessOperationsWithModels(context, ops, allModels)); } @SuppressWarnings( { "rawtypes", "RedundantSuppression" } ) @@ -132,182 +121,19 @@ protected void updateModelForComposedSchema( @Nonnull final Schema schema, @Nonnull final Map allDefinitions ) { - super.updateModelForComposedSchema(m, schema, allDefinitions); - - if( USE_ONE_OF_CREATORS.isEnabled(config) ) { - useCreatorsForInterfaceSubtypes(m); - } - } - - /** - * Remove property from specification. - * - * @param openAPI - * The OpenAPI specification to update. - * @param schemaName - * The name of the schema to update. - * @param propertyName - * The name of the property to remove. - */ - @SuppressWarnings( { "rawtypes", "unchecked", "ReplaceInefficientStreamCount" } ) - private void preprocessRemoveProperty( - @Nonnull final OpenAPI openAPI, - @Nonnull final String schemaName, - @Nonnull final String propertyName ) - { - final var schema = openAPI.getComponents().getSchemas().get(schemaName); - if( schema == null ) { - log.error("Could not find schema {} to remove property {} from.", schemaName, propertyName); - return; - } - boolean removed = false; - - final Predicate remove = - s -> s != null && s.getProperties() != null && s.getProperties().remove(propertyName) != null; - final var schemasQueued = new LinkedList(); - final var schemasDone = new HashSet(); - schemasQueued.add(schema); - - while( !schemasQueued.isEmpty() ) { - final var s = schemasQueued.remove(); - if( s == null || !schemasDone.add(s) ) { - continue; - } - // check removal of direct schema property - removed |= remove.test(s); - - // check for allOf, anyOf, oneOf - for( final List list : Arrays.asList(s.getAllOf(), s.getAnyOf(), s.getOneOf()) ) { - if( list != null ) { - schemasQueued.addAll(list); - } - } - } - if( !removed ) { - log.error("Could not remove property {} from schema {}.", propertyName, schemaName); - } - } - - /** - * Remove unused schema components. - * - * @param openAPI - * The OpenAPI specification to update. - */ - @SuppressWarnings( { "rawtypes", "unchecked" } ) - private void preprocessRemoveRedundancies( @Nonnull final OpenAPI openAPI ) - { - final var queue = new LinkedList(); - final var done = new HashSet(); - final var refs = new LinkedHashSet(); - final var pattern = Pattern.compile("\\$ref: #/components/schemas/(\\w+)"); - - // find and queue schemas nested in paths - for( final var path : openAPI.getPaths().values() ) { - final var m = pattern.matcher(path.toString()); - while( m.find() ) { - final var name = m.group(1); - final var schema = openAPI.getComponents().getSchemas().get(name); - queue.add(schema); - refs.add(m.group(0).split(" ")[1]); - } - } - - while( !queue.isEmpty() ) { - final var s = queue.remove(); - if( s == null || !done.add(s) ) { - continue; - } - - // check for $ref attribute - final var ref = s.get$ref(); - if( ref != null ) { - refs.add(ref); - final var refName = ref.substring(ref.lastIndexOf('/') + 1); - queue.add(openAPI.getComponents().getSchemas().get(refName)); - } - - // check for direct properties - if( s.getProperties() != null ) { - for( final var s1 : s.getProperties().values() ) { - queue.add((Schema) s1); - } - } - - // check for array items - if( s.getItems() != null ) { - queue.add(s.getItems()); - } - - // check for allOf, anyOf, oneOf - for( final List list : Arrays.asList(s.getAllOf(), s.getAnyOf(), s.getOneOf()) ) { - if( list != null ) { - queue.addAll(list); - } - } - } - - // remove all schemas that have not been marked "used" - openAPI.getComponents().getSchemas().keySet().removeIf(schema -> { - if( !refs.contains("#/components/schemas/" + schema) ) { - log.info("Removing unused schema {}", schema); - return true; - } - return false; - }); - } - - /** - * Use JsonCreator for interface sub-types in case there are any primitives. - * - * @param m - * The model to update. - */ - private void useCreatorsForInterfaceSubtypes( @Nonnull final CodegenModel m ) - { - if( m.discriminator != null ) { - return; - } - boolean useCreators = false; - for( final Set candidates : List.of(m.anyOf, m.oneOf) ) { - int nonPrimitives = 0; - final var candidatesSingle = new HashSet(); - final var candidatesMultiple = new HashSet(); - - for( final String candidate : candidates ) { - if( candidate.startsWith("List<") ) { - final var c1 = candidate.substring(5, candidate.length() - 1); - candidatesMultiple.add(c1); - useCreators = true; - } else { - candidatesSingle.add(candidate); - useCreators |= PRIMITIVES.contains(candidate); - if( !PRIMITIVES.contains(candidate) ) { - nonPrimitives++; - } - } - } - if( useCreators ) { - if( nonPrimitives > 1 ) { - final var msg = - "Generating interface with mixed multiple non-primitive and primitive sub-types: {}. Deserialization may not work."; - log.warn(msg, m.name); - } - candidates.clear(); - final var monads = Map.of("single", candidatesSingle, "multiple", candidatesMultiple); - m.vendorExtensions.put("x-monads", monads); - m.vendorExtensions.put("x-is-one-of-interface", true); // enforce template usage - } - } + chainedContextVoid( + GeneratorCustomization.UpdateModelForComposedSchema.class, + ( context, m1, schema1, allDef1 ) -> super.updateModelForComposedSchema(m1, schema1, allDef1), + context -> context.get().updateModelForComposedSchema(context, m, schema, allDefinitions)); } @SuppressWarnings( { "rawtypes", "RedundantSuppression" } ) @Override protected void updateModelForObject( @Nonnull final CodegenModel m, @Nonnull final Schema schema ) { - // Disable additional attributes to prevent model classes from extending "HashMap" - // SAP Cloud SDK offers custom field APIs to handle additional attributes already - schema.setAdditionalProperties(Boolean.FALSE); - super.updateModelForObject(m, schema); + chainedContextVoid( + GeneratorCustomization.UpdateModelForObject.class, + ( context, m1, schema1 ) -> super.updateModelForObject(m1, schema1), + context -> context.get().updateModelForObject(context, m, schema)); } } diff --git a/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/GeneratorCustomProperties.java b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/GeneratorCustomProperties.java deleted file mode 100644 index 8c64fea0d..000000000 --- a/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/GeneratorCustomProperties.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.sap.cloud.sdk.datamodel.openapi.generator; - -import javax.annotation.Nonnull; - -import com.sap.cloud.sdk.datamodel.openapi.generator.model.GenerationConfiguration; - -import lombok.RequiredArgsConstructor; - -/** - * Optional feature toggles, may be used internally only. - */ -@RequiredArgsConstructor -enum GeneratorCustomProperties -{ - /** - * Use JsonCreator instead of sub-type deduction for oneOf and anyOf schemas. - */ - USE_ONE_OF_CREATORS("useOneOfCreators", "false"), - - /** - * Fix isIsBoolean() to isBoolean() for fields specified as `"isBoolean":{"type":"boolean"}`. - */ - FIX_REDUNDANT_IS_BOOLEAN_PREFIX("fixRedundantIsBooleanPrefix", "false"), - - /** - * Use float arrays instead of big-decimal lists. - */ - USE_FLOAT_ARRAYS("useFloatArrays", "false"), - - /** - * Exclude generation of properties, e.g. `schemaName1.propertyNameA, schemaName2.propertyNameB`. - */ - USE_EXCLUDE_PROPERTIES("excludeProperties", "false"), - - /** - * Exclude generation of APIs that match a provided path, e.g. `/api/v1/health, /deployments/{id}/completions`. - */ - USE_EXCLUDE_PATHS("excludePaths", "false"), - - /** - * Remove schema components that are unused, before generating them. - */ - FIX_REMOVE_UNUSED_COMPONENTS("removeUnusedComponents", "false"); - - private final String key; - private final String defaultValue; - - /** - * Check if the feature is enabled. - * - * @param config - * The generation configuration. - * @return True if the feature is enabled, false otherwise. - */ - public boolean isEnabled( @Nonnull final GenerationConfiguration config ) - { - final var value = getValue(config); - return !value.isEmpty() && !"false".equalsIgnoreCase(value.trim()); - } - - /** - * Get the value of the feature. - * - * @param config - * The generation configuration. - * @return The value of the feature. - */ - @Nonnull - public String getValue( @Nonnull final GenerationConfiguration config ) - { - return config.getAdditionalProperties().getOrDefault(key, defaultValue); - } -} diff --git a/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/GeneratorCustomization.java b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/GeneratorCustomization.java new file mode 100644 index 000000000..52d623f43 --- /dev/null +++ b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/GeneratorCustomization.java @@ -0,0 +1,441 @@ +package com.sap.cloud.sdk.datamodel.openapi.generator; + +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.StreamSupport; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.openapitools.codegen.CodegenModel; +import org.openapitools.codegen.CodegenProperty; +import org.openapitools.codegen.model.ModelMap; +import org.openapitools.codegen.model.OperationsMap; + +import com.sap.cloud.sdk.datamodel.openapi.generator.model.GenerationConfiguration; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; + +/** + * Optional feature toggles, may be used internally only. + */ +public interface GeneratorCustomization +{ + /** + * Get the configuration key. + */ + @Nullable + default String getConfigKey() + { + return null; + } + + /** + * Get the default active value of the feature. + * + * @return The default active value of the feature (false). + */ + @Nullable + default String getConfigValueDefault() + { + return null; + } + + /** + * Check if the feature is enabled. + * + * @param config + * The generation configuration. + * @return True if the feature is enabled, false otherwise. + */ + default boolean isConfigEnabled( @Nonnull final GenerationConfiguration config ) + { + final var value = getConfigValue(config); + return value != null && !value.isEmpty() && !"false".equalsIgnoreCase(value.trim()); + } + + /** + * Get the value of the feature. + * + * @param config + * The generation configuration. + * @return The value of the feature. + */ + @Nullable + default String getConfigValue( @Nonnull final GenerationConfiguration config ) + { + return config.getAdditionalProperties().getOrDefault(getConfigKey(), getConfigValueDefault()); + } + + /** + * Get the available customizations. + * + * @return The customizations. + */ + @Nonnull + static List getCustomizations() + { + final var customizationLoader = ServiceLoader.load(GeneratorCustomization.class); + return StreamSupport.stream(customizationLoader.spliterator(), false).toList(); + } + + /** + * Get the customizations for the given configuration. + * + * @param config + * The generation configuration. + * @return The customizations. + */ + @Nonnull + static List getCustomizations( @Nonnull final GenerationConfiguration config ) + { + return getCustomizations().stream().filter(c -> c.isConfigEnabled(config)).toList(); + } + + /** + * Context for customization. + * + * @param + * The customization handler type. + */ + interface ChainElement + { + /** + * Get the customization handler. + * + * @return The customization handler. + */ + @Nonnull + HandlerT get(); + + /** + * Get the generation configuration. + * + * @return The generation configuration. + */ + @Nonnull + GenerationConfiguration config(); + } + + /** + * Context for customization using chained methods without return type. + * + * @param + * The customization handler type. + */ + interface ChainElementVoid> extends ChainElement + { + /** + * Get next customization handler. + * + * @return The customization handler. + */ + @Nullable + ChainElementVoid next(); + + /** + * Continue with the next customization. + * + * @param next + * The next customization. + */ + default void doNext( @Nonnull final Consumer> next ) + { + next.accept(next()); + } + } + + /** + * Context for customizationusing chained methods with return type. + * + * @param + * The customization handler type. + * @param + * The return value type. + */ + interface ChainElementReturn, ValueT> extends ChainElement + { + /** + * Get next customization handler. + * + * @return The customization handler. + */ + @Nullable + ChainElementReturn next(); + + /** + * Continue with the next customization. + * + * @param next + * The next customization. + * @return The return value. + */ + @SuppressWarnings( "PMD.NullAnnotationMissingOnPublicMethod" ) + default ValueT doNext( @Nonnull final Function, ValueT> next ) + { + return next.apply(next()); + } + } + + /** + * Marker interface to chain customizations without return type. + * + * @param + * The customization handler type. + */ + interface ChainableVoid> + { + /** + * Helper method to attach a chain the customization. + * + * @param config + * The generation configuration. + * @param next + * The next customization. + * @return The customization chain. + */ + @Nonnull + default + ChainElementVoid + chained( @Nonnull final GenerationConfiguration config, @Nullable final ChainElementVoid next ) + { + @SuppressWarnings( "unchecked" ) + final HandlerT self = (HandlerT) this; + return new ChainElementVoid<>() + { + @Nonnull + @Override + public HandlerT get() + { + return self; + } + + @Nullable + @Override + public ChainElementVoid next() + { + return next; + } + + @Nonnull + @Override + public GenerationConfiguration config() + { + return config; + } + }; + } + } + + /** + * Marker interface to chain customizations with return type. + * + * @param + * The customization handler type. + */ + interface ChainableReturn> + { + /** + * Helper method to attach a chain the customization. + * + * @param config + * The generation configuration. + * @param next + * The next customization. + * @param + * The return value type. + * @return The customization chain. + */ + @Nonnull + default ChainElementReturn chained( + @Nonnull final GenerationConfiguration config, + @Nullable final ChainElementReturn next ) + { + @SuppressWarnings( "unchecked" ) + final HandlerT self = (HandlerT) this; + return new ChainElementReturn<>() + { + @Nonnull + @Override + public HandlerT get() + { + return self; + } + + @Nullable + @Override + public ChainElementReturn next() + { + return next; + } + + @Nonnull + @Override + public GenerationConfiguration config() + { + return config; + } + }; + } + } + + /** + * Update the model for a composed schema. + */ + interface UpdatePropertyForArray extends GeneratorCustomization, ChainableVoid + { + /** + * Update the model for a composed schema. + * + * @param chain + * The customization chain. + * @param property + * The property. + * @param innerProperty + * The inner property. + */ + void updatePropertyForArray( + @Nonnull final ChainElementVoid chain, + @Nonnull final CodegenProperty property, + @Nonnull final CodegenProperty innerProperty ); + } + + /** + * Get the default value. + */ + interface ToDefaultValue extends GeneratorCustomization, ChainableReturn + { + /** + * Get the default value. + * + * @param chain + * The customization chain. + * @param cp + * The codegen property. + * @param schema + * The schema. + * @return The default value. + */ + @Nullable + @SuppressWarnings( "rawtypes" ) + String toDefaultValue( + @Nonnull final ChainElementReturn chain, + @Nonnull final CodegenProperty cp, + @Nonnull final Schema schema ); + } + + /** + * Get the boolean getter. + */ + interface ToBooleanGetter extends GeneratorCustomization, ChainableReturn + { + /** + * Get the boolean getter. + * + * @param chain + * The customization chain. + * @param name + * The name. + * @return The boolean getter. + */ + @Nullable + String toBooleanGetter( + @Nonnull final ChainElementReturn chain, + @Nullable final String name ); + } + + /** + * Update the model for a composed schema. + */ + interface UpdateModelForComposedSchema extends GeneratorCustomization, ChainableVoid + { + /** + * Update the model for a composed schema. + * + * @param chain + * The customization chain. + * @param m + * The model. + * @param schema + * The schema. + * @param allDefinitions + * The definitions. + */ + @SuppressWarnings( "rawtypes" ) + void updateModelForComposedSchema( + @Nonnull final ChainElementVoid chain, + @Nonnull final CodegenModel m, + @Nonnull final Schema schema, + @Nonnull final Map allDefinitions ); + } + + /** + * Post-process operations with models. + */ + interface PostProcessOperationsWithModels + extends + GeneratorCustomization, + ChainableReturn + { + /** + * Post-process operations with models. + * + * @param chain + * The customization chain. + * @param ops + * The operations. + * @param allModels + * The models. + * @return The operations. + */ + @Nonnull + OperationsMap postProcessOperationsWithModels( + @Nonnull final ChainElementReturn chain, + @Nonnull final OperationsMap ops, + @Nonnull final List allModels ); + } + + /** + * Update the model for an object. + */ + interface UpdateModelForObject extends GeneratorCustomization, ChainableVoid + { + /** + * Update the model for an object. + * + * @param chain + * The customization chain. + * @param m + * The model. + * @param schema + * The schema. + */ + @SuppressWarnings( "rawtypes" ) + void updateModelForObject( + @Nonnull final ChainElementVoid chain, + @Nonnull final CodegenModel m, + @Nonnull final Schema schema ); + } + + /** + * Pre-process the OpenAPI model. + */ + interface PreProcessOpenAPI extends GeneratorCustomization, ChainableVoid + { + /** + * Preprocess the OpenAPI model. + * + * @param chain + * The customization chain. + * @param openAPI + * The OpenAPI model. + */ + void preprocessOpenAPI( + @Nonnull final ChainElementVoid chain, + @Nonnull final OpenAPI openAPI ); + } +} diff --git a/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/FixAdditionalProperties.java b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/FixAdditionalProperties.java new file mode 100644 index 000000000..dfea3ee1f --- /dev/null +++ b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/FixAdditionalProperties.java @@ -0,0 +1,36 @@ +package com.sap.cloud.sdk.datamodel.openapi.generator.customization; + +import javax.annotation.Nonnull; + +import org.openapitools.codegen.CodegenModel; + +import com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomization; + +import io.swagger.v3.oas.models.media.Schema; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Fix generation of additionalProperties:true leading to + * class Foo extends HashMap<String,Object> + */ +@Slf4j +@Getter +public class FixAdditionalProperties implements GeneratorCustomization.UpdateModelForObject +{ + private final String configKey = "fixAdditionalProperties"; + private final String configValueDefault = "true"; + + @SuppressWarnings( "rawtypes" ) + @Override + public void updateModelForObject( + @Nonnull final ChainElementVoid chain, + @Nonnull final CodegenModel m, + @Nonnull final Schema schema ) + { + // Disable additional attributes to prevent model classes from extending "HashMap" + // SAP Cloud SDK offers custom field APIs to handle additional attributes already + schema.setAdditionalProperties(Boolean.FALSE); + chain.doNext(next -> next.get().updateModelForObject(next, m, schema)); + } +} diff --git a/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/FixIsIsBooleanPrefix.java b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/FixIsIsBooleanPrefix.java new file mode 100644 index 000000000..0fa13cbaa --- /dev/null +++ b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/FixIsIsBooleanPrefix.java @@ -0,0 +1,36 @@ +package com.sap.cloud.sdk.datamodel.openapi.generator.customization; + +import java.util.function.Predicate; +import java.util.regex.Pattern; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomization; + +import lombok.Getter; + +/** + * Fix isIsBoolean() to isBoolean() for fields specified as `"isBoolean":{"type":"boolean"}`. + */ +@Getter +public class FixIsIsBooleanPrefix implements GeneratorCustomization.ToBooleanGetter +{ + private static final Predicate DOUBLE_IS_PATTERN = Pattern.compile("^isIs[A-Z]").asPredicate(); + + private final String configKey = "fixRedundantIsBooleanPrefix"; + + @Nullable + @Override + public + String + toBooleanGetter( @Nonnull final ChainElementReturn chain, @Nullable final String name ) + { + final String superValue = chain.doNext(next -> next.get().toBooleanGetter(next, name)); + + if( superValue != null && DOUBLE_IS_PATTERN.test(superValue) ) { + return "is" + superValue.substring(4); + } + return superValue; + } +} diff --git a/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/FixRemoveUnusedComponents.java b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/FixRemoveUnusedComponents.java new file mode 100644 index 000000000..018f8003b --- /dev/null +++ b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/FixRemoveUnusedComponents.java @@ -0,0 +1,94 @@ +package com.sap.cloud.sdk.datamodel.openapi.generator.customization; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.regex.Pattern; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomization; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Remove unused schema components. + */ +@Slf4j +@Getter +public class FixRemoveUnusedComponents implements GeneratorCustomization.PreProcessOpenAPI +{ + private final String configKey = "removeUnusedComponents"; + + @SuppressWarnings( { "rawtypes", "unchecked" } ) + @Override + public + void + preprocessOpenAPI( @Nonnull final ChainElementVoid chain, @Nonnull final OpenAPI openAPI ) + { + // process rest of the chain + chain.doNext(next -> next.get().preprocessOpenAPI(next, openAPI)); + + final var queue = new LinkedList(); + final var done = new HashSet(); + final var refs = new LinkedHashSet(); + final var pattern = Pattern.compile("\\$ref: #/components/schemas/(\\w+)"); + for( final var path : openAPI.getPaths().values() ) { + final var m = pattern.matcher(path.toString()); + while( m.find() ) { + final var name = m.group(1); + final var schema = openAPI.getComponents().getSchemas().get(name); + queue.add(schema); + refs.add(m.group(0).split(" ")[1]); + } + } + + while( !queue.isEmpty() ) { + final var s = queue.remove(); + if( s == null || !done.add(s) ) { + continue; + } + + // collect $ref attributes to mark schema used + final var ref = s.get$ref(); + if( ref != null ) { + refs.add(ref); + final var refName = ref.substring(ref.lastIndexOf('/') + 1); + queue.add(openAPI.getComponents().getSchemas().get(refName)); + } + + // check for direct properties + if( s.getProperties() != null ) { + for( final var s1 : s.getProperties().values() ) { + queue.add((Schema) s1); + } + } + + // check for array items + if( s.getItems() != null ) { + queue.add(s.getItems()); + } + + // check for allOf, anyOf, oneOf + for( final List list : Arrays.asList(s.getAllOf(), s.getAnyOf(), s.getOneOf()) ) { + if( list != null ) { + queue.addAll(list); + } + } + } + + // remove all schemas that have not been marked "used" + openAPI.getComponents().getSchemas().keySet().removeIf(schema -> { + if( !refs.contains("#/components/schemas/" + schema) ) { + log.info("Removing unused schema {}", schema); + return true; + } + return false; + }); + } +} diff --git a/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/FixReturnNullable.java b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/FixReturnNullable.java new file mode 100644 index 000000000..4d3ff25a8 --- /dev/null +++ b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/FixReturnNullable.java @@ -0,0 +1,44 @@ +package com.sap.cloud.sdk.datamodel.openapi.generator.customization; + +import java.util.List; + +import javax.annotation.Nonnull; + +import org.openapitools.codegen.CodegenOperation; +import org.openapitools.codegen.model.ModelMap; +import org.openapitools.codegen.model.OperationsMap; + +import com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomization; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Fix nullable return object via injecting "x-return-nullable" extension. + */ +@Slf4j +@Getter +public class FixReturnNullable implements GeneratorCustomization.PostProcessOperationsWithModels +{ + private final String configKey = "fixReturnNullable"; + private final String configValueDefault = "true"; + + @Override + @Nonnull + public OperationsMap postProcessOperationsWithModels( + @Nonnull final ChainElementReturn chain, + @Nonnull final OperationsMap ops, + @Nonnull final List allModels ) + { + final OperationsMap superOps = + chain.doNext(next -> next.get().postProcessOperationsWithModels(next, ops, allModels)); + for( final CodegenOperation op : superOps.getOperations().getOperation() ) { + final var noContent = + op.isResponseOptional + || op.responses == null + || op.responses.stream().anyMatch(r -> "204".equals(r.code)); + op.vendorExtensions.put("x-return-nullable", op.returnType != null && noContent); + } + return superOps; + } +} diff --git a/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/UseExcludePaths.java b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/UseExcludePaths.java new file mode 100644 index 000000000..207a90556 --- /dev/null +++ b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/UseExcludePaths.java @@ -0,0 +1,40 @@ +package com.sap.cloud.sdk.datamodel.openapi.generator.customization; + +import java.util.Objects; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomization; + +import io.swagger.v3.oas.models.OpenAPI; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Remove selected paths from the OpenAPI specification. + */ +@Getter +@Slf4j +public class UseExcludePaths implements GeneratorCustomization.PreProcessOpenAPI +{ + private final String configKey = "excludePaths"; + + @Override + public + void + preprocessOpenAPI( @Nonnull final ChainElementVoid chain, @Nonnull final OpenAPI openAPI ) + { + // remove selected properties + final String excludePathsRaw = Objects.requireNonNull(getConfigValue(chain.config())); + final String[] excludePaths = excludePathsRaw.split("[,\\s]+"); + + for( final var removePath : excludePaths ) { + if( !openAPI.getPaths().keySet().remove(removePath) ) { + log.error("Could not remove path {}", removePath); + } + } + + // process rest of chain + chain.doNext(next -> next.get().preprocessOpenAPI(next, openAPI)); + } +} diff --git a/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/UseExcludeProperties.java b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/UseExcludeProperties.java new file mode 100644 index 000000000..343fbbaf0 --- /dev/null +++ b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/UseExcludeProperties.java @@ -0,0 +1,75 @@ +package com.sap.cloud.sdk.datamodel.openapi.generator.customization; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomization; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Remove selected properties from the OpenAPI specification. + */ +@Getter +@Slf4j +public class UseExcludeProperties implements GeneratorCustomization.PreProcessOpenAPI +{ + private final String configKey = "excludeProperties"; + + @SuppressWarnings( { "rawtypes", "unchecked", "ReplaceInefficientStreamCount" } ) + @Override + public + void + preprocessOpenAPI( @Nonnull final ChainElementVoid chain, @Nonnull final OpenAPI openAPI ) + { + // remove selected properties + final String excludePropertiesRaw = Objects.requireNonNull(getConfigValue(chain.config())); + final String[] excludeProperties = excludePropertiesRaw.trim().split("[,\\s]+"); + + for( final var removeProperty : excludeProperties ) { + final var split = removeProperty.split("\\.", 2); + final var schema = openAPI.getComponents().getSchemas().get(split[0]); + if( schema == null ) { + log.error("Could not find schema {} to remove property {} from.", split[0], split[1]); + continue; + } + boolean removed = false; + + final var schemasQueued = new LinkedList(); + final var schemasDone = new HashSet(); + schemasQueued.add(schema); + while( !schemasQueued.isEmpty() ) { + final var s = schemasQueued.remove(); + if( s == null || !schemasDone.add(s) ) { + continue; + } + + // check removal of direct schema property + if( s.getProperties() != null && s.getProperties().remove(split[1]) != null ) { + removed = true; + } + + // check for allOf, anyOf, oneOf + for( final List list : Arrays.asList(s.getAllOf(), s.getAnyOf(), s.getOneOf()) ) { + if( list != null ) { + schemasQueued.addAll(list); + } + } + } + if( !removed ) { + log.error("Could not remove property {}", removeProperty); + } + } + + // process rest of chain + chain.doNext(next -> next.get().preprocessOpenAPI(next, openAPI)); + } +} diff --git a/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/UseFloatArrays.java b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/UseFloatArrays.java new file mode 100644 index 000000000..3b23a8919 --- /dev/null +++ b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/UseFloatArrays.java @@ -0,0 +1,52 @@ +package com.sap.cloud.sdk.datamodel.openapi.generator.customization; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.openapitools.codegen.CodegenProperty; + +import com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomization; + +import io.swagger.v3.oas.models.media.Schema; +import lombok.Getter; + +/** + * Use float arrays instead of big-decimal lists. + */ +@Getter +public class UseFloatArrays + implements + GeneratorCustomization.ToDefaultValue, + GeneratorCustomization.UpdatePropertyForArray +{ + private final String configKey = "useFloatArrays"; + + @Override + public void updatePropertyForArray( + @Nonnull final ChainElementVoid chain, + @Nonnull final CodegenProperty property, + @Nonnull final CodegenProperty innerProperty ) + { + if( innerProperty.isNumber && property.isArray ) { + property.dataType = "float[]"; + property.datatypeWithEnum = "float[]"; + property.isArray = false; // set false to omit `add{{nameInPascalCase}}Item(...)` convenience method + property.vendorExtensions.put("isPrimitiveArray", true); + } + chain.doNext(next -> next.get().updatePropertyForArray(next, property, innerProperty)); + } + + @Override + @SuppressWarnings( "rawtypes" ) + @Nullable + public String toDefaultValue( + @Nonnull final ChainElementReturn chain, + @Nonnull final CodegenProperty cp, + @Nonnull final Schema schema ) + { + if( "float[]".equals(cp.dataType) ) { + return null; + } + return chain.doNext(next -> next.get().toDefaultValue(next, cp, schema)); + } +} diff --git a/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/UseOneOfCreators.java b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/UseOneOfCreators.java new file mode 100644 index 000000000..46f76f044 --- /dev/null +++ b/datamodel/openapi/openapi-generator/src/main/java/com/sap/cloud/sdk/datamodel/openapi/generator/customization/UseOneOfCreators.java @@ -0,0 +1,75 @@ +package com.sap.cloud.sdk.datamodel.openapi.generator.customization; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.annotation.Nonnull; + +import org.openapitools.codegen.CodegenModel; + +import com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomization; + +import io.swagger.v3.oas.models.media.Schema; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Use JsonCreator instead of sub-type deduction for oneOf and anyOf schemas. + */ +@Slf4j +@Getter +public class UseOneOfCreators implements GeneratorCustomization.UpdateModelForComposedSchema +{ + private static final Set PRIMITIVES = Set.of("String", "Integer", "Long", "Double", "Float", "Byte"); + + private final String configKey = "useOneOfCreators"; + + @Override + @SuppressWarnings( "rawtypes" ) + public void updateModelForComposedSchema( + @Nonnull final ChainElementVoid chain, + @Nonnull final CodegenModel m, + @Nonnull final Schema schema, + @Nonnull final Map allDefinitions ) + { + // run chain first + chain.doNext(next -> next.get().updateModelForComposedSchema(next, m, schema, allDefinitions)); + + if( m.discriminator != null ) { + return; + } + boolean useCreators = false; + for( final Set candidates : List.of(m.anyOf, m.oneOf) ) { + int nonPrimitives = 0; + final var candidatesSingle = new HashSet(); + final var candidatesMultiple = new HashSet(); + + for( final String candidate : candidates ) { + if( candidate.startsWith("List<") ) { + final var c1 = candidate.substring(5, candidate.length() - 1); + candidatesMultiple.add(c1); + useCreators = true; + } else { + candidatesSingle.add(candidate); + useCreators |= PRIMITIVES.contains(candidate); + if( !PRIMITIVES.contains(candidate) ) { + nonPrimitives++; + } + } + } + if( useCreators ) { + if( nonPrimitives > 1 ) { + final var msg = + "Generating interface with mixed multiple non-primitive and primitive sub-types: {}. Deserialization may not work."; + log.warn(msg, m.name); + } + candidates.clear(); + final var monads = Map.of("single", candidatesSingle, "multiple", candidatesMultiple); + m.vendorExtensions.put("x-monads", monads); + m.vendorExtensions.put("x-is-one-of-interface", true); // enforce template usage + } + } + } +} diff --git a/datamodel/openapi/openapi-generator/src/main/resources/META-INF/services/com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomization b/datamodel/openapi/openapi-generator/src/main/resources/META-INF/services/com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomization new file mode 100644 index 000000000..e967257a2 --- /dev/null +++ b/datamodel/openapi/openapi-generator/src/main/resources/META-INF/services/com.sap.cloud.sdk.datamodel.openapi.generator.GeneratorCustomization @@ -0,0 +1,8 @@ +com.sap.cloud.sdk.datamodel.openapi.generator.customization.FixAdditionalProperties +com.sap.cloud.sdk.datamodel.openapi.generator.customization.FixIsIsBooleanPrefix +com.sap.cloud.sdk.datamodel.openapi.generator.customization.FixReturnNullable +com.sap.cloud.sdk.datamodel.openapi.generator.customization.FixRemoveUnusedComponents +com.sap.cloud.sdk.datamodel.openapi.generator.customization.UseFloatArrays +com.sap.cloud.sdk.datamodel.openapi.generator.customization.UseOneOfCreators +com.sap.cloud.sdk.datamodel.openapi.generator.customization.UseExcludePaths +com.sap.cloud.sdk.datamodel.openapi.generator.customization.UseExcludeProperties