Repository: smithy-lang/smithy
Pull request #2504 (2025-02-03 19:58:36)

Add support for json schema oneOf enumStrategy
#### Background
* What do these changes do? 

This adds support for a new json schema setting, `enumStrategy`, that allows for variants `enum` (current behaviour, and default) and `oneOf` (gets converted to a oneOf with const string values, allowing for documentation of individual enum variants.)

I'm new to this codebase, let me know if there are other considerations I may have missed here

* Why are they important?

Allows for documentation on enum variants in an open-api schema for example.  The current behaviour (which will remain the default), doesn't allow documentation at the variant level

#### Testing
* How did you test these changes?

Added unit test cases to `JsonSchemaConverterTest`.

ran `./gradlew build`

#### Links
* Links to additional context, if necessary

For: #2481

---
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/aws/aws-cloudformation.rst b/docs/source-2.0/aws/aws-cloudformation.rst
index 2f782e6f1..962350ddb 100644
--- a/docs/source-2.0/aws/aws-cloudformation.rst
+++ b/docs/source-2.0/aws/aws-cloudformation.rst
@@ -661,16 +661,16 @@ The following example defines a CloudFormation resource that has the
 Summary
     Indicates that the member annotated has a default value
     for that property of the CloudFormation resource. Thus,
-    when this trait annotates an ``@output`` structure member,
+    when this trait annotates an ``@input`` or ``@output`` structure member,
     it indicates that the CloudFormation property generated
     from that member has a default value in the CloudFormation
     schema. This trait can be used to indicate that an output
     field with a value may return a default value assigned
     by the service.
 Trait selector
-    ``resource > operation -[output]-> structure > member``
+    ``resource > operation -[input, output]-> structure > member``
 
-    *Only applicable to members of @output operations*
+    *Only applicable to members of @input or @output operations*
 Value type
     Annotation trait
 
diff --git a/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/TaggableApiConfig.java b/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/TaggableApiConfig.java
index 39eb4bca3..2c81655f5 100644
--- a/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/TaggableApiConfig.java
+++ b/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/tagging/TaggableApiConfig.java
@@ -117,22 +117,22 @@ public final class TaggableApiConfig implements FromSourceLocation, ToNode, ToSm
         private ShapeId listTagsApi;
         private SourceLocation sourceLocation = SourceLocation.none();
 
-        Builder tagApi(ShapeId tagApi) {
+        public Builder tagApi(ShapeId tagApi) {
             this.tagApi = tagApi;
             return this;
         }
 
-        Builder untagApi(ShapeId untagApi) {
+        public Builder untagApi(ShapeId untagApi) {
             this.untagApi = untagApi;
             return this;
         }
 
-        Builder listTagsApi(ShapeId listTagsApi) {
+        public Builder listTagsApi(ShapeId listTagsApi) {
             this.listTagsApi = listTagsApi;
             return this;
         }
 
-        Builder sourceLocation(SourceLocation sourceLocation) {
+        public Builder sourceLocation(SourceLocation sourceLocation) {
             this.sourceLocation = sourceLocation;
             return this;
         }
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 a51ceca2e..4f034610d 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
@@ -85,11 +85,42 @@ public class JsonSchemaConfig {
         }
     }
 
+    /**
+     * Configures how Smithy enum shapes are converted to JSON Schema
+     */
+    public enum EnumStrategy {
+
+        /**
+         * Converts to a schema that uses enum, which contains an array of strings
+         *
+         * <p>This is the default setting used if not configured.
+         */
+        ENUM("enum"),
+
+        /**
+         * Converts to a schema that uses oneOf, with an array of const strings and optional
+         * descriptions for documentation purposes
+         */
+        ONE_OF("oneOf");
+
+        private final String stringValue;
+
+        EnumStrategy(String stringValue) {
+            this.stringValue = stringValue;
+        }
+
+        @Override
+        public String toString() {
+            return stringValue;
+        }
+    }
+
     private boolean alphanumericOnlyRefs;
     private boolean useJsonName;
     private TimestampFormatTrait.Format defaultTimestampFormat = TimestampFormatTrait.Format.DATE_TIME;
     private UnionStrategy unionStrategy = UnionStrategy.ONE_OF;
     private MapStrategy mapStrategy = MapStrategy.PROPERTY_NAMES;
+    private EnumStrategy enumStrategy = EnumStrategy.ENUM;
     private String definitionPointer;
     private ObjectNode schemaDocumentExtensions = Node.objectNode();
     private ObjectNode extensions = Node.objectNode();
@@ -186,6 +217,18 @@ public class JsonSchemaConfig {
         this.mapStrategy = mapStrategy;
     }
 
+    public EnumStrategy getEnumStrategy() {
+        return enumStrategy;
+    }
+
+    /**
+     * Configures how Smithy enum shapes ae converted to JSON Schema
+     * @param enumStrategy The enum strategy to use
+     */
+    public void setEnumStrategy(EnumStrategy enumStrategy) {
+        this.enumStrategy = enumStrategy;
+    }
+
     public String getDefinitionPointer() {
         return definitionPointer != null ? definitionPointer : jsonSchemaVersion.getDefaultDefinitionPointer();
     }
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 d10064f06..1c4276d5e 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
@@ -7,11 +7,13 @@ package software.amazon.smithy.jsonschema;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.regex.Pattern;
 import software.amazon.smithy.model.Model;
 import software.amazon.smithy.model.node.Node.NonNumericFloat;
+import software.amazon.smithy.model.node.StringNode;
 import software.amazon.smithy.model.shapes.BigDecimalShape;
 import software.amazon.smithy.model.shapes.BigIntegerShape;
 import software.amazon.smithy.model.shapes.BlobShape;
@@ -19,6 +21,7 @@ import software.amazon.smithy.model.shapes.BooleanShape;
 import software.amazon.smithy.model.shapes.ByteShape;
 import software.amazon.smithy.model.shapes.DocumentShape;
 import software.amazon.smithy.model.shapes.DoubleShape;
+import software.amazon.smithy.model.shapes.EnumShape;
 import software.amazon.smithy.model.shapes.FloatShape;
 import software.amazon.smithy.model.shapes.IntegerShape;
 import software.amazon.smithy.model.shapes.ListShape;
@@ -248,6 +251,43 @@ final class JsonSchemaShapeVisitor extends ShapeVisitor.Default<Schema> {
         }
     }
 
+    @Override
+    public Schema enumShape(EnumShape shape) {
+        JsonSchemaConfig.EnumStrategy enumStrategy = converter.getConfig().getEnumStrategy();
+
+        switch (enumStrategy) {
+            case ENUM:
+                return super.enumShape(shape);
+            case ONE_OF:
+                Map<String, String> enumValues = shape.getEnumValues();
+                List<Schema> schemas = new ArrayList<>();
+
+                for (Map.Entry<String, MemberShape> entry : shape.getAllMembers().entrySet()) {
+                    String memberName = entry.getKey();
+                    MemberShape member = entry.getValue();
+                    Schema enumSchema = Schema.builder()
+                            .constValue(StringNode.from(enumValues.get(memberName)))
+                            .description(member.getTrait(DocumentationTrait.class)
+                                    .map(DocumentationTrait::getValue)
+                                    .orElse(null))
+                            .build();
+
+                    schemas.add(enumSchema);
+                }
+
+                return buildSchema(shape,
+                        Schema.builder()
+                                .description(shape.getTrait(DocumentationTrait.class)
+                                        .map(DocumentationTrait::getValue)
+                                        .orElse(null))
+                                .type("string")
+                                .oneOf(schemas));
+            default: {
+                throw new SmithyJsonSchemaException(String.format("Unsupported enum strategy: %s", enumStrategy));
+            }
+        }
+    }
+
     @Override
     public Schema timestampShape(TimestampShape shape) {
         return buildSchema(shape, createBuilder(shape, "string"));
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/shapes/Shape.java b/smithy-model/src/main/java/software/amazon/smithy/model/shapes/Shape.java
index c5da52aaf..866bdb6da 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/shapes/Shape.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/shapes/Shape.java
@@ -210,7 +210,7 @@ public abstract class Shape implements FromSourceLocation, Tagged, ToShapeId, Co
      * @param shape Shape to create a builder from.
      * @param <B> Shape builder to create.
      * @param <S> Shape that is being converted to a builder.
-     * @return Returns a shape fro the given shape.
+     * @return Returns a shape builder from the given shape.
      */
     @SuppressWarnings("unchecked")
     public static <B extends AbstractShapeBuilder<B, S>, S extends Shape> B shapeToBuilder(S shape) {
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/shapes/SmithyIdlModelSerializer.java b/smithy-model/src/main/java/software/amazon/smithy/model/shapes/SmithyIdlModelSerializer.java
index 7f0ee2080..6f1db2603 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/shapes/SmithyIdlModelSerializer.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/shapes/SmithyIdlModelSerializer.java
@@ -837,6 +837,11 @@ public final class SmithyIdlModelSerializer {
                                 entry.getValue()));
                 codeWriter.closeBlock("}");
             }
+            if (shape.hasProperties()) {
+                codeWriter.openBlock("properties: {");
+                shape.getProperties().forEach((name, shapeId) -> codeWriter.write("$L: $I", name, shapeId));
+                codeWriter.closeBlock("}");
+            }
 
             shape.getPut().ifPresent(shapeId -> codeWriter.write("put: $I", shapeId));
             shape.getCreate().ifPresent(shapeId -> codeWriter.write("create: $I", shapeId));
@@ -847,11 +852,6 @@ public final class SmithyIdlModelSerializer {
             codeWriter.writeOptionalIdList("operations", shape.getIntroducedOperations());
             codeWriter.writeOptionalIdList("collectionOperations", shape.getCollectionOperations());
             codeWriter.writeOptionalIdList("resources", shape.getIntroducedResources());
-            if (shape.hasProperties()) {
-                codeWriter.openBlock("properties: {");
-                shape.getProperties().forEach((name, shapeId) -> codeWriter.write("$L: $I", name, shapeId));
-                codeWriter.closeBlock("}");
-            }
             codeWriter.closeBlock("}");
             codeWriter.write("");
             return null;


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 e4b4b00bb..c04321289 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
@@ -817,6 +817,43 @@ public class JsonSchemaConverterTest {
         Node.assertEquals(document.toNode(), expected);
     }
 
+    @Test
+    public void supportsDefaultEnumStrategy() {
+        Model model = Model.assembler()
+                .addImport(getClass().getResource("string-enums.smithy"))
+                .assemble()
+                .unwrap();
+
+        SchemaDocument document = JsonSchemaConverter.builder()
+                .model(model)
+                .build()
+                .convert();
+
+        Node expected = Node.parse(
+                IoUtils.toUtf8String(getClass().getResourceAsStream("string-enums.jsonschema.v07.json")));
+        Node.assertEquals(document.toNode(), expected);
+    }
+
+    @Test
+    public void supportsOneOfEnumStrategy() {
+        Model model = Model.assembler()
+                .addImport(getClass().getResource("string-enums.smithy"))
+                .assemble()
+                .unwrap();
+
+        JsonSchemaConfig config = new JsonSchemaConfig();
+        config.setEnumStrategy(JsonSchemaConfig.EnumStrategy.ONE_OF);
+        SchemaDocument document = JsonSchemaConverter.builder()
+                .model(model)
+                .config(config)
+                .build()
+                .convert();
+
+        Node expected = Node.parse(
+                IoUtils.toUtf8String(getClass().getResourceAsStream("string-enums-one-of.jsonschema.v07.json")));
+        Node.assertEquals(document.toNode(), expected);
+    }
+
     @Test
     public void intEnumsCanBeDisabled() {
         Model model = Model.assembler()
diff --git a/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/string-enums-one-of.jsonschema.v07.json b/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/string-enums-one-of.jsonschema.v07.json
new file mode 100644
index 000000000..2bda54d8e
--- /dev/null
+++ b/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/string-enums-one-of.jsonschema.v07.json
@@ -0,0 +1,25 @@
+{
+  "definitions": {
+    "Foo": {
+      "type": "object",
+      "properties": {
+        "bar": {
+          "$ref": "#/definitions/TestEnum"
+        }
+      }
+    },
+    "TestEnum": {
+      "type": "string",
+      "description": "This is a test enum",
+      "oneOf": [
+        {
+          "const": "Foo",
+          "description": "it really does foo"
+        },
+        {
+          "const": "Bar"
+        }
+      ]
+    }
+  }
+}
diff --git a/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/string-enums.jsonschema.v07.json b/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/string-enums.jsonschema.v07.json
new file mode 100644
index 000000000..905f26a4a
--- /dev/null
+++ b/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/string-enums.jsonschema.v07.json
@@ -0,0 +1,20 @@
+{
+    "definitions": {
+        "Foo": {
+            "type": "object",
+            "properties": {
+                "bar": {
+                    "$ref": "#/definitions/TestEnum"
+                }
+            }
+        },
+        "TestEnum": {
+            "type": "string",
+            "enum": [
+                "Foo",
+                "Bar"
+            ],
+            "description": "This is a test enum"
+        }
+    }
+}
diff --git a/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/string-enums.smithy b/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/string-enums.smithy
new file mode 100644
index 000000000..798281d65
--- /dev/null
+++ b/smithy-jsonschema/src/test/resources/software/amazon/smithy/jsonschema/string-enums.smithy
@@ -0,0 +1,14 @@
+$version: "2.0"
+
+namespace smithy.example
+
+structure Foo {
+    bar: TestEnum
+}
+
+@documentation("This is a test enum")
+enum TestEnum {
+    @documentation("it really does foo")
+    FOO = "Foo"
+    BAR = "Bar"
+}
diff --git a/smithy-model/src/test/resources/software/amazon/smithy/model/shapes/idl-serialization/cases/service-shapes.smithy b/smithy-model/src/test/resources/software/amazon/smithy/model/shapes/idl-serialization/cases/service-shapes.smithy
index 6dcf0e75a..e18db148a 100644
--- a/smithy-model/src/test/resources/software/amazon/smithy/model/shapes/idl-serialization/cases/service-shapes.smithy
+++ b/smithy-model/src/test/resources/software/amazon/smithy/model/shapes/idl-serialization/cases/service-shapes.smithy
@@ -23,6 +23,10 @@ resource MyResource {
     identifiers: {
         id: String
     }
+    properties: {
+        value: String
+        other: String
+    }
     put: ResourceOperation
     create: EmptyOperation
     read: ReadonlyResourceOperation
@@ -38,10 +42,6 @@ resource MyResource {
     resources: [
         SubResource
     ]
-    properties: {
-        value: String
-        other: String
-    }
 }
 
 resource SubResource {