Repository: victools/jsonschema-generator
Pull request #87 (2020-04-25 13:07:52)

Option to include Map values as additionalProperties


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c4e4618..2eb7fcf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ## [Unreleased]
 ### `jsonschema-generator`
 #### Added
+- New `Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES` to include `additionalProperties` with a schema for each `Map<K, V>`'s value type `V`
 - New `Option.DEFINITION_FOR_MAIN_SCHEMA` to allow moving main/target schema into `definitions`/`$defs`
 - New `Option.PLAIN_DEFINITION_KEYS` to ensure keys in `definitions`/`$defs` adhere to the reduced set of allowed characters expected by OpenAPI
 - New `SchemaGenerator.buildMultipleSchemaDefinitions()` allowing to generate JSON Schema elements (e.g. for building an OpenAPI description)
diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/Option.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/Option.java
index 3130deb..9cf80ca 100644
--- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/Option.java
+++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/Option.java
@@ -195,6 +195,10 @@ public enum Option {
      * Without this option, all methods' return values are deemed not-nullable unless a specific check says otherwise.
      */
     NULLABLE_METHOD_RETURN_VALUES_BY_DEFAULT(null, null),
+    /**
+     * Whether a {@link java.util.Map Map&lt;K, V&gt;} schema's "additionalProperties" should be set to its declared value type {@code V}.
+     */
+    MAP_VALUES_AS_ADDITIONAL_PROPERTIES(AdditionalPropertiesModule::forMapValues, null),
     /**
      * Whether a schema's "additionalProperties" should be set to "false" if no specific configuration says otherwise.
      * <br>
diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/SchemaCleanUpUtils.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/SchemaCleanUpUtils.java
index ebebae8..6596656 100644
--- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/SchemaCleanUpUtils.java
+++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/SchemaCleanUpUtils.java
@@ -262,13 +262,13 @@ public class SchemaCleanUpUtils {
      */
     public String ensureDefinitionKeyIsUriCompatible(String definitionKey) {
         return definitionKey
-                // removing white-spaces
-                .replaceAll("[ ]+", "")
                 // marking arrays with an asterisk instead of square brackets
                 .replaceAll("\\[\\]", "*")
                 // indicating generics in parentheses instead of angled brackets
                 .replaceAll("<", "(")
-                .replaceAll(">", ")");
+                .replaceAll(">", ")")
+                // removing white-spaces and any other remaining invalid characters
+                .replaceAll("[^a-zA-Z0-9\\.\\-_\\$\\*\\(\\),]+", "");
     }
 
     /**
@@ -279,8 +279,6 @@ public class SchemaCleanUpUtils {
      */
     public String ensureDefinitionKeyIsPlain(String definitionKey) {
         return definitionKey
-                // removing white-spaces
-                .replaceAll("[ ]+", "")
                 // avoid dollar symbols for inner types
                 .replaceAll("\\$", "-")
                 // marking arrays with three dots instead of square brackets
@@ -288,6 +286,8 @@ public class SchemaCleanUpUtils {
                 // indicating generics in underscores instead of angled brackets
                 .replaceAll("[<>]", "_")
                 // use dots instead of commas between type parameters
-                .replaceAll(",", ".");
+                .replaceAll(",", ".")
+                // removing white-spaces and any other remaining invalid characters
+                .replaceAll("[^a-zA-Z0-9\\.\\-_]+", "");
     }
 }
diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/module/AdditionalPropertiesModule.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/module/AdditionalPropertiesModule.java
index e4b1a44..bca9ae5 100644
--- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/module/AdditionalPropertiesModule.java
+++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/module/AdditionalPropertiesModule.java
@@ -16,56 +16,60 @@
 
 package com.github.victools.jsonschema.generator.impl.module;
 
+import com.github.victools.jsonschema.generator.ConfigFunction;
 import com.github.victools.jsonschema.generator.Module;
 import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
 import com.github.victools.jsonschema.generator.TypeScope;
 import java.lang.reflect.Type;
-import java.util.function.Predicate;
+import java.util.Map;
 
 /**
  * Default module being included if {@code Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT} is enabled.
  */
 public class AdditionalPropertiesModule implements Module {
 
+    /**
+     * Create module instance that specifically allows additional properties on {@link Map} instances.
+     *
+     * @return module instance
+     */
+    public static AdditionalPropertiesModule forMapValues() {
+        return new AdditionalPropertiesModule(scope -> {
+            if (scope.getType().isInstanceOf(Map.class)) {
+                // within a Map<Key, Value> allow additionalProperties of the Value type
+                // if no type parameters are defined, this will result in additionalProperties to be omitted (by way of returning Object.class)
+                return scope.getTypeParameterFor(Map.class, 1);
+            }
+            return null;
+        });
+    }
+
     /**
      * Create module instance that forbids additional properties everywhere but on container types.
      * <br>
-     * This assumes that the respective {@link SimpleTypeModule} instance is being applied first and already enforces the "additionProperties" keyword
-     * to be omitted on other non-object schemas.
+     * This assumes that the respective {@link SimpleTypeModule} instance is being applied first and already enforces the "additionalProperties"
+     * keyword to be omitted on other non-object schemas.
      *
      * @return module instance
      */
     public static AdditionalPropertiesModule forbiddenForAllObjectsButContainers() {
-        return new AdditionalPropertiesModule(scope -> !scope.isContainerType());
+        return new AdditionalPropertiesModule(scope -> scope.isContainerType() ? null : Void.class);
     }
 
-    private final Predicate<TypeScope> exclusionCheck;
+    private final ConfigFunction<TypeScope, Type> additionalPropertiesResolver;
 
     /**
      * Constructor.
      *
-     * @param exclusionCheck determining whether additionalProperties should be forbidden on a given scope
+     * @param additionalPropertiesResolver resolver for additionalProperties
      */
-    public AdditionalPropertiesModule(Predicate<TypeScope> exclusionCheck) {
-        this.exclusionCheck = exclusionCheck;
+    public AdditionalPropertiesModule(ConfigFunction<TypeScope, Type> additionalPropertiesResolver) {
+        this.additionalPropertiesResolver = additionalPropertiesResolver;
     }
 
     @Override
     public void applyToConfigBuilder(SchemaGeneratorConfigBuilder builder) {
         builder.forTypesInGeneral()
-                .withAdditionalPropertiesResolver(this::resolveAdditionalProperties);
-    }
-
-    /**
-     * Either forbid additionProperties (according to the specified check) or leave it up to other configurations.
-     *
-     * @param scope type scope for which to determine whether additionalProperties should be forbidden
-     * @return either Void.class to indicate forbidden additionalProperties or null
-     */
-    private Type resolveAdditionalProperties(TypeScope scope) {
-        if (this.exclusionCheck.test(scope)) {
-            return Void.class;
-        }
-        return null;
+                .withAdditionalPropertiesResolver(this.additionalPropertiesResolver);
     }
 }


Test patch:
diff --git a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/impl/module/AdditionalPropertiesModuleTest.java b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/impl/module/AdditionalPropertiesModuleTest.java
new file mode 100644
index 0000000..6bb01b5
--- /dev/null
+++ b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/impl/module/AdditionalPropertiesModuleTest.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2020 VicTools.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.github.victools.jsonschema.generator.impl.module;
+
+import com.github.victools.jsonschema.generator.AbstractTypeAwareTest;
+import com.github.victools.jsonschema.generator.ConfigFunction;
+import com.github.victools.jsonschema.generator.Module;
+import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
+import com.github.victools.jsonschema.generator.SchemaGeneratorGeneralConfigPart;
+import com.github.victools.jsonschema.generator.SchemaVersion;
+import com.github.victools.jsonschema.generator.TypeContext;
+import com.github.victools.jsonschema.generator.TypeScope;
+import java.lang.reflect.Type;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import junitparams.JUnitParamsRunner;
+import junitparams.Parameters;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+/**
+ * Test for the {@link AdditionalPropertiesModule} class.
+ */
+@RunWith(JUnitParamsRunner.class)
+public class AdditionalPropertiesModuleTest extends AbstractTypeAwareTest {
+
+    private SchemaGeneratorConfigBuilder builder;
+    private SchemaGeneratorGeneralConfigPart generalConfigPart;
+
+    public AdditionalPropertiesModuleTest() {
+        super(TestMapSubType.class);
+    }
+
+    @Before
+    public void setUp() {
+        this.builder = Mockito.mock(SchemaGeneratorConfigBuilder.class);
+        this.generalConfigPart = Mockito.spy(new SchemaGeneratorGeneralConfigPart());
+        Mockito.when(this.builder.forTypesInGeneral()).thenReturn(this.generalConfigPart);
+        this.prepareContextForVersion(SchemaVersion.DRAFT_2019_09);
+    }
+
+    @Test
+    public void testApplyToConfigBuilderForMapValuesModule() {
+        AdditionalPropertiesModule.forMapValues()
+                .applyToConfigBuilder(this.builder);
+
+        Mockito.verify(this.builder).forTypesInGeneral();
+        Mockito.verifyNoMoreInteractions(this.builder);
+        Mockito.verify(this.generalConfigPart).withAdditionalPropertiesResolver(Mockito.any());
+        Mockito.verifyNoMoreInteractions(this.generalConfigPart);
+    }
+
+    @Test
+    public void testApplyToConfigBuilderForForbiddenForAllObjectsModule() {
+        AdditionalPropertiesModule.forbiddenForAllObjectsButContainers()
+                .applyToConfigBuilder(this.builder);
+
+        Mockito.verify(this.builder).forTypesInGeneral();
+        Mockito.verifyNoMoreInteractions(this.builder);
+        Mockito.verify(this.generalConfigPart).withAdditionalPropertiesResolver(Mockito.any());
+        Mockito.verifyNoMoreInteractions(this.generalConfigPart);
+    }
+
+    public Object[] parametersForTestResolveAdditionalProperties() {
+        AdditionalPropertiesModule mapValuesInstance = AdditionalPropertiesModule.forMapValues();
+        AdditionalPropertiesModule noneButContainersInstance = AdditionalPropertiesModule.forbiddenForAllObjectsButContainers();
+        return new Object[][]{
+            {mapValuesInstance, null, String.class, new Type[0]},
+            {noneButContainersInstance, Void.class, String.class, new Type[0]},
+            {mapValuesInstance, Integer.class, Map.class, new Type[]{String.class, Integer.class}},
+            {noneButContainersInstance, Void.class, Map.class, new Type[]{String.class, Integer.class}},
+            {mapValuesInstance, String.class, TestMapSubType.class, new Type[0]},
+            {noneButContainersInstance, Void.class, TestMapSubType.class, new Type[0]},
+            {mapValuesInstance, null, List.class, new Type[]{String.class}},
+            {noneButContainersInstance, null, List.class, new Type[]{String.class}}
+        };
+    }
+
+    @Test
+    @Parameters
+    @SuppressWarnings("unchecked")
+    public void testResolveAdditionalProperties(Module moduleInstance, Type expectedAdditionalProperties, Type type, Type[] typeParameters) {
+        moduleInstance.applyToConfigBuilder(this.builder);
+        ArgumentCaptor<ConfigFunction<TypeScope, Type>> resolverCaptor = ArgumentCaptor.forClass(ConfigFunction.class);
+        Mockito.verify(this.generalConfigPart).withAdditionalPropertiesResolver(resolverCaptor.capture());
+        ConfigFunction<TypeScope, Type> additionalPropertiesResolver = resolverCaptor.getValue();
+
+        TypeContext typeContext = this.getContext().getTypeContext();
+        TypeScope scope = typeContext.createTypeScope(typeContext.resolve(type, typeParameters));
+        Type result = additionalPropertiesResolver.apply(scope);
+
+        if (expectedAdditionalProperties == null || expectedAdditionalProperties == Void.class) {
+            Assert.assertEquals(expectedAdditionalProperties, result);
+        } else {
+            Assert.assertEquals(typeContext.resolve(expectedAdditionalProperties), result);
+        }
+    }
+
+    private static class TestMapSubType extends HashMap<Object, String> {
+        // no further fields
+    }
+}