Repository: smithy-lang/smithy
Pull request #1898 (2023-08-01 23:08:08)

Add intEnum support for json-schema
This PR adds support for intEnums when converting to json-schema and OpenAPI and closes: https://github.com/smithy-lang/smithy/issues/1667. This feature is enabled by default, but can be disabled by setting the `disableIntEnums` configuration property to `true`.

Currently, an intEnum is only represented as a "number".

The following Smithy model:
```
structure Foo {
    bar: TestIntEnum
}

intEnum TestIntEnum {
    FOO = 1
    BAR = 2
}
```

Is converted as:
```
{
    "definitions": {
        "Foo": {
            "type": "object",
            "properties": {
                "bar": {
                    "type": "number"
                }
            }
        }
    }
}
```

This PR adds the `enum` property to the output, and updates the ref strategy so that intEnums use a `$ref` the same way as enum shapes.

```
{
    "definitions": {
        "Foo": {
            "type": "object",
            "properties": {
                "bar": {
                    "$ref": "#/definitions/TestIntEnum"
                }
            }
        },
        "TestIntEnum": {
            "type": "number",
            "enum": [
                1,
                2
            ]
        }
    }
}
```

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.



Patch:
diff --git a/docs/source-2.0/guides/converting-to-openapi.rst b/docs/source-2.0/guides/converting-to-openapi.rst
index 39bad112e..eb3009ad0 100644
--- a/docs/source-2.0/guides/converting-to-openapi.rst
+++ b/docs/source-2.0/guides/converting-to-openapi.rst
@@ -873,6 +873,63 @@ disableDefaultValues (``boolean``)
         }
 
 
+.. _generate-openapi-setting-disableIntEnums:
+
+disableIntEnums (``boolean``)
+    Set to true to disable setting the ``enum`` property for intEnum shapes.
+
+    .. code-block:: json
+
+        {
+            "version": "2.0",
+            "plugins": {
+                "openapi": {
+                    "service": "example.weather#Weather",
+                    "disableIntEnums": true
+                }
+            }
+        }
+
+    With this disabled, intEnum shapes will be inlined and the ``enum`` property
+    will not be set:
+
+    .. code-block:: json
+
+        {
+            "Foo": {
+                "type": "object",
+                "properties": {
+                    "bar": {
+                        "type": "number"
+                    }
+                }
+            }
+        }
+
+    With this enabled (the default), intEnum shapes will have the ``enum``
+    property set and the schema will use a ``$ref``.
+
+    .. code-block:: json
+
+        {
+            "Foo": {
+                "type": "object",
+                "properties": {
+                    "bar": {
+                        "$ref": "#/definitions/MyIntEnum"
+                    }
+                }
+            },
+            "MyIntEnum": {
+                "type": "number",
+                "enum": [
+                    1,
+                    2
+                ]
+            }
+        }
+
+
 ----------------
 Security schemes
 ----------------
diff --git a/docs/source-2.0/spec/constraint-traits.rst b/docs/source-2.0/spec/constraint-traits.rst
index 78a05d2d1..767a411c6 100644
--- a/docs/source-2.0/spec/constraint-traits.rst
+++ b/docs/source-2.0/spec/constraint-traits.rst
@@ -12,6 +12,20 @@ impact the types signatures of generated code.
        that also functions like a constraint.
 
 
+Constraint trait enforcement
+============================
+
+Constraint traits SHOULD be enforced after deserializing input. For example,
+when a server deserializes a request from a client, the server SHOULD enforce
+any defined constraint traits and reject the request if appropriate.
+
+Constraint traits SHOULD NOT be enforced when serializing shapes or when
+deserializing output. For example, when returning a response from a server
+to a client, failing to serialize a response due to a constraint trait
+violation would prevent a client from observing a state change of the server
+and would provide no real recourse for the client or server to recover.
+
+
 .. smithy-trait:: smithy.api#idRef
 .. _idref-trait:
 
diff --git a/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/DefaultRefStrategy.java b/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/DefaultRefStrategy.java
index 351cb9478..f082670b8 100644
--- a/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/DefaultRefStrategy.java
+++ b/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/DefaultRefStrategy.java
@@ -35,8 +35,8 @@ import software.amazon.smithy.model.traits.EnumTrait;
  *         JSON schema definition.
  *     </li>
  *     <li>
- *         <p>Members that target structures, unions, enums, and maps use a $ref to the
- *         targeted shape. With the exception of maps, these kinds of shapes are almost
+ *         <p>Members that target structures, unions, enums, intEnums, and maps use a $ref to
+ *         the targeted shape. With the exception of maps, these kinds of shapes are almost
  *         always generated as concrete types by code generators, so it's useful to reuse
  *         them throughout the schema. However, this means that member documentation
  *         and other member traits need to be moved in some way to the containing
@@ -157,6 +157,10 @@ final class DefaultRefStrategy implements RefStrategy {
             return false;
         }
 
+        if (shape.isIntEnumShape() && !config.getDisableIntEnums()) {
+            return false;
+        }
+
         // Simple types are always inlined unless the type has the enum trait.
         return shape instanceof SimpleShape;
     }
diff --git a/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/JsonSchemaConfig.java b/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/JsonSchemaConfig.java
index 9c332b809..6794a8c29 100644
--- a/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/JsonSchemaConfig.java
+++ b/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/JsonSchemaConfig.java
@@ -113,6 +113,7 @@ public class JsonSchemaConfig {
     private boolean enableOutOfServiceReferences = false;
     private boolean useIntegerType;
     private boolean disableDefaultValues = false;
+    private boolean disableIntEnums = false;
 
     public JsonSchemaConfig() {
         nodeMapper.setWhenMissingSetter(NodeMapper.WhenMissing.IGNORE);
@@ -421,6 +422,21 @@ public class JsonSchemaConfig {
     }
 
 
+    public boolean getDisableIntEnums() {
+        return disableIntEnums;
+    }
+
+    /**
+     * Set to true to disable setting an `enum` property for intEnums. When disabled,
+     * intEnums are inlined instead of using a $ref.
+     *
+     * @param disableIntEnums True to disable setting `enum` property for intEnums.
+     */
+    public void setDisableIntEnums(boolean disableIntEnums) {
+        this.disableIntEnums = disableIntEnums;
+    }
+
+
     /**
      * JSON schema version to use when converting Smithy shapes into Json Schema.
      *
diff --git a/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/JsonSchemaShapeVisitor.java b/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/JsonSchemaShapeVisitor.java
index 05a32b8b2..477bf1526 100644
--- a/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/JsonSchemaShapeVisitor.java
+++ b/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/JsonSchemaShapeVisitor.java
@@ -321,6 +321,10 @@ final class JsonSchemaShapeVisitor extends ShapeVisitor.Default<Schema> {
                 .map(EnumTrait::getEnumDefinitionValues)
                 .ifPresent(builder::enumValues);
 
+        if (shape.isIntEnumShape() && !converter.getConfig().getDisableIntEnums()) {
+            builder.intEnumValues(shape.asIntEnumShape().get().getEnumValues().values());
+        }
+
         if (shape.hasTrait(DefaultTrait.class) && !converter.getConfig().getDisableDefaultValues()) {
             builder.defaultValue(shape.expectTrait(DefaultTrait.class).toNode());
         }
diff --git a/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/Schema.java b/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/Schema.java
index ef2078a24..08a33ccf8 100644
--- a/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/Schema.java
+++ b/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/Schema.java
@@ -63,6 +63,7 @@ public final class Schema implements ToNode, ToSmithyBuilder<Schema> {
     private final String ref;
     private final String type;
     private final Collection<String> enumValues;
+    private final Collection<Integer> intEnumValues;
     private final Node constValue;
     private final Node defaultValue;
 
@@ -113,6 +114,7 @@ public final class Schema implements ToNode, ToSmithyBuilder<Schema> {
         ref = builder.ref;
         type = builder.type;
         enumValues = Collections.unmodifiableCollection(builder.enumValues);
+        intEnumValues = Collections.unmodifiableCollection(builder.intEnumValues);
         constValue = builder.constValue;
         defaultValue = builder.defaultValue;
 
@@ -174,6 +176,10 @@ public final class Schema implements ToNode, ToSmithyBuilder<Schema> {
         return Optional.ofNullable(enumValues);
     }
 
+    public Optional<Collection<Integer>> getIntEnumValues() {
+        return Optional.ofNullable(intEnumValues);
+    }
+
     public Optional<Node> getConstValue() {
         return Optional.ofNullable(constValue);
     }
@@ -379,9 +385,20 @@ public final class Schema implements ToNode, ToSmithyBuilder<Schema> {
             result.withMember("required", required.stream().sorted().map(Node::from).collect(ArrayNode.collect()));
         }
 
-        if (!enumValues.isEmpty()) {
-            result.withOptionalMember("enum", getEnumValues()
-                    .map(v -> v.stream().map(Node::from).collect(ArrayNode.collect())));
+        if (!enumValues.isEmpty() || !intEnumValues.isEmpty()) {
+            ArrayNode.Builder builder = ArrayNode.builder();
+            if (getIntEnumValues().isPresent()) {
+                for (Integer i : getIntEnumValues().get()) {
+                    builder.withValue(i);
+                }
+            }
+
+            if (getEnumValues().isPresent()) {
+                for (String s : getEnumValues().get()) {
+                    builder.withValue(s);
+                }
+            }
+            result.withOptionalMember("enum", builder.build().asArrayNode());
         }
 
         if (!allOf.isEmpty()) {
@@ -486,6 +503,7 @@ public final class Schema implements ToNode, ToSmithyBuilder<Schema> {
                 .ref(ref)
                 .type(type)
                 .enumValues(enumValues)
+                .intEnumValues(intEnumValues)
                 .constValue(constValue)
                 .defaultValue(defaultValue)
 
@@ -554,6 +572,7 @@ public final class Schema implements ToNode, ToSmithyBuilder<Schema> {
         private String ref;
         private String type;
         private Collection<String> enumValues = ListUtils.of();
+        private Collection<Integer> intEnumValues = ListUtils.of();
         private Node constValue;
         private Node defaultValue;
 
@@ -625,6 +644,11 @@ public final class Schema implements ToNode, ToSmithyBuilder<Schema> {
             return this;
         }
 
+        public Builder intEnumValues(Collection<Integer> intEnumValues) {
+            this.intEnumValues = intEnumValues == null ? ListUtils.of() : intEnumValues;
+            return this;
+        }
+
         public Builder constValue(Node constValue) {
             this.constValue = constValue;
             return this;
@@ -858,7 +882,7 @@ public final class Schema implements ToNode, ToSmithyBuilder<Schema> {
                 case "default":
                     return this.defaultValue(null);
                 case "enum":
-                    return this.enumValues(null);
+                    return this.enumValues(null).intEnumValues(null);
                 case "multipleOf":
                     return this.multipleOf(null);
                 case "maximum":


Test patch:
diff --git a/smithy-jsonschema/src/test/java/software/amazon/smithy/jsonschema/JsonSchemaConverterTest.java b/smithy-jsonschema/src/test/java/software/amazon/smithy/jsonschema/JsonSchemaConverterTest.java
index 23cf2627f..f7bea5ec3 100644
--- a/smithy-jsonschema/src/test/java/software/amazon/smithy/jsonschema/JsonSchemaConverterTest.java
+++ b/smithy-jsonschema/src/test/java/software/amazon/smithy/jsonschema/JsonSchemaConverterTest.java
@@ -722,4 +722,39 @@ public class JsonSchemaConverterTest {
                 IoUtils.toUtf8String(getClass().getResourceAsStream("default-values-disabled.jsonschema.v07.json")));
         Node.assertEquals(document.toNode(), expected);
     }
+
+    @Test
+    public void supportsIntEnumsByDefault() {
+        Model model = Model.assembler()
+                .addImport(getClass().getResource("int-enums.smithy"))
+                .assemble()
+                .unwrap();
+        SchemaDocument document = JsonSchemaConverter.builder()
+                .model(model)
+                .build()
+                .convert();
+
+        Node expected = Node.parse(
+                IoUtils.toUtf8String(getClass().getResourceAsStream("int-enums.jsonschema.v07.json")));
+        Node.assertEquals(document.toNode(), expected);
+    }
+
+    @Test
+    public void intEnumsCanBeDisabled() {
+        Model model = Model.assembler()
+                .addImport(getClass().getResource("int-enums.smithy"))
+                .assemble()
+                .unwrap();
+        JsonSchemaConfig config = new JsonSchemaConfig();
+        config.setDisableIntEnums(true);
+        SchemaDocument document = JsonSchemaConverter.builder()
+                .config(config)
+                .model(model)
+                .build()
+                .convert();
+
+        Node expected = Node.parse(
+                IoUtils.toUtf8String(getClass().getResourceAsStream("int-enums-disabled.jsonschema.v07.json")));
+        Node.assertEquals(document.toNode(), expected);
+    }
 }
diff --git a/smithy-jsonschema/src/test/java/software/amazon/smithy/jsonschema/SchemaTest.java b/smithy-jsonschema/src/test/java/software/amazon/smithy/jsonschema/SchemaTest.java
index 3c218f322..e663c9fb9 100644
--- a/smithy-jsonschema/src/test/java/software/amazon/smithy/jsonschema/SchemaTest.java
+++ b/smithy-jsonschema/src/test/java/software/amazon/smithy/jsonschema/SchemaTest.java
@@ -17,6 +17,7 @@ package software.amazon.smithy.jsonschema;
 
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.not;
 
@@ -25,6 +26,8 @@ import java.util.Optional;
 import java.util.Set;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import software.amazon.smithy.model.node.ArrayNode;
+import software.amazon.smithy.model.node.Node;
 import software.amazon.smithy.utils.ListUtils;
 import software.amazon.smithy.utils.SetUtils;
 
@@ -207,4 +210,18 @@ public class SchemaTest {
         assertThat(schema.getProperties().keySet(), contains("bar"));
         assertThat(schema.getRequired(), contains("bar"));
     }
+
+    @Test
+    public void mergesEnumValuesWhenConvertingToNode() {
+        Schema schema = Schema.builder()
+                .enumValues(ListUtils.of("foo", "bar"))
+                .intEnumValues(ListUtils.of(1, 2))
+                .build();
+        ArrayNode node = schema.toNode().asObjectNode().get().expectArrayMember("enum");
+        assertThat(node.getElements(), containsInAnyOrder(
+                Node.from("foo"),
+                Node.from("bar"),
+                Node.from(1),
+                Node.from(2)));
+    }
 }
diff --git a/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/int-enums-disabled.jsonschema.v07.json b/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/int-enums-disabled.jsonschema.v07.json
new file mode 100644
index 000000000..08b5ac5ba
--- /dev/null
+++ b/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/int-enums-disabled.jsonschema.v07.json
@@ -0,0 +1,12 @@
+{
+    "definitions": {
+        "Foo": {
+            "type": "object",
+            "properties": {
+                "bar": {
+                    "type": "number"
+                }
+            }
+        }
+    }
+}
diff --git a/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/int-enums.jsonschema.v07.json b/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/int-enums.jsonschema.v07.json
new file mode 100644
index 000000000..63a9209b7
--- /dev/null
+++ b/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/int-enums.jsonschema.v07.json
@@ -0,0 +1,19 @@
+{
+    "definitions": {
+        "Foo": {
+            "type": "object",
+            "properties": {
+                "bar": {
+                    "$ref": "#/definitions/TestIntEnum"
+                }
+            }
+        },
+        "TestIntEnum": {
+            "type": "number",
+            "enum": [
+                1,
+                2
+            ]
+        }
+    }
+}
diff --git a/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/int-enums.smithy b/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/int-enums.smithy
new file mode 100644
index 000000000..2cfd18be0
--- /dev/null
+++ b/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/int-enums.smithy
@@ -0,0 +1,12 @@
+$version: "2.0"
+
+namespace smithy.example
+
+structure Foo {
+    bar: TestIntEnum
+}
+
+intEnum TestIntEnum {
+    FOO = 1
+    BAR = 2
+}