Repository: smithy-lang/smithy
Pull request #1609 (2023-02-09 05:24:55)

Support serializing traits into specification extensions in OpenAPI
## Description of changes

Adds a "meta-trait" `smithy.openapi#specificationExtension` that can be used to annotate other traits to indicate they should be serialized into specification extension (`x-*`) properties when converting to OpenAPI. This is supported on both shapes/"schemas" as well as operations. By default the extension will be named by the shape ID replacing `#` & `.` with `-` prefixed with `x-`, otherwise there is an `as:` member on the trait that allows renaming.

I believe this could be useful in bridging the gap where custom traits are currently unsupported in OpenAPI conversion, by allowing some opt in.

Adds a `JsonSchemaMapperV2` interface that extends the original, and adds an overload of `updateSchema` that accepts a context object that includes the model being converted.

## Example

Some operation:
```smithy
$version: "2.0"

namespace smithy.example

list StringList {
    member: String
}

@trait
@smithy.openapi#specificationExtension
structure structuredExtension {
    stringMember: String
    integerMember: Integer
    listMember: StringList
}

@trait
@smithy.openapi#specificationExtension(as: "x-metadata")
string stringExtension

@stringExtension("hello world")
structure SpecificationExtensionsOperationInput {
    name: String
    language: String
}

@structuredExtension(
    stringMember: "first field"
    integerMember: 17
    listMember: ["item1", "item2", "item3"]
)
@http(method: "PUT", uri: "/")
operation SpecificationExtensionsOperation {
    input: SpecificationExtensionsOperationInput
}

@aws.protocols#restJson1
service SpecificationExtensions {
    operations: [SpecificationExtensionsOperation]
}
```

Becomes:
```json
{
    "openapi": "3.0.2",
    "info": {
        "title": "SpecificationExtensions",
        "version": ""
    },
    "paths": {
        "/": {
            "put": {
                "operationId": "SpecificationExtensionsOperation",
                "requestBody": {
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/SpecificationExtensionsOperationRequestContent"
                            }
                        }
                    }
                },
                "responses": {
                    "200": {
                        "description": "SpecificationExtensionsOperation 200 response"
                    }
                },
                "x-smithy-example-structuredExtension": {
                    "stringMember": "first field",
                    "integerMember": 17,
                    "listMember": [
                        "item1",
                        "item2",
                        "item3"
                    ]
                }
            }
        }
    },
    "components": {
        "schemas": {
            "SpecificationExtensionsOperationRequestContent": {
                "type": "object",
                "properties": {
                    "name": {
                        "type": "string"
                    },
                    "language": {
                        "type": "string"
                    }
                },
               "x-metadata": "hello world"
            }
        }
    }
}
```

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 2095436f3..49448b23a 100644
--- a/docs/source-2.0/guides/converting-to-openapi.rst
+++ b/docs/source-2.0/guides/converting-to-openapi.rst
@@ -1151,6 +1151,157 @@ The following is an example OpenAPI model for the above Smithy example value.
             }
         }
 
+-------------------------
+OpenAPI conversion traits
+-------------------------
+
+The ``software.amazon.smithy:smithy-openapi-traits`` package defines traits used to augment the conversion
+of a Smithy model into an OpenAPI specification.
+
+The following example shows how to add it to your Gradle build alongside the ``smithy-openapi`` plugin:
+
+.. code-block:: kotlin
+    :caption: build.gradle.kts
+
+    plugins {
+        java
+        id("software.amazon.smithy").version("0.6.0")
+    }
+
+    buildscript {
+        dependencies {
+            classpath("software.amazon.smithy:smithy-openapi:__smithy_version__")
+            classpath("software.amazon.smithy:smithy-openapi-traits:__smithy_version__")
+        }
+    }
+
+    dependencies {
+        implementation("software.amazon.smithy:smithy-openapi-traits:__smithy_version__")
+    }
+
+Refer to `Converting to OpenAPI with smithy-build`_ for more detailed information about using the plugin and Gradle.
+
+.. smithy-trait:: smithy.openapi#specificationExtension
+.. _specification-extension-trait:
+
+``specificationExtension`` trait
+================================
+
+Summary
+    Indicates a trait shape should be converted into an `OpenAPI specification extension`_.
+    Any custom trait that has been annotated with this trait will be serialized into the OpenAPI specification using
+    its :ref:`Smithy JSON AST representation <json-ast>`.
+Trait selector
+    ``[trait|trait]``
+Value type
+    ``structure``
+
+The ``specificationExtension`` trait is a structure that supports the following members:
+
+.. list-table::
+    :header-rows: 1
+    :widths: 10 25 65
+
+    * - Property
+      - Type
+      - Description
+    * - as
+      - ``string``
+      - Explicitly name the specification extension.
+        If set, it must begin with ``"x-"``.
+        Otherwise, it defaults to the target trait's shape ID normalized with hyphens and prepended with ``"x-"``.
+
+The following example defines a specification extension representing a custom metadata structure using the ``specificationExtension`` trait:
+
+.. code-block:: smithy
+
+    $version: "2"
+    namespace smithy.example
+
+    use smithy.openapi#specificationExtension
+
+    @trait
+    @specificationExtension(as: "x-meta")
+    structure metadata {
+        owner: String
+    }
+
+    @output
+    @metadata(owner: "greetings-team-b")
+    structure GreetResponse {
+        greeting: String
+    }
+
+    @readonly
+    @http(method: "GET", uri: "/greet")
+    @metadata(owner: "greetings-team-a")
+    operation Greet {
+        output: GreetResponse
+    }
+
+This results in an ``x-meta`` property being added to the respective objects in the OpenAPI output:
+
+.. code-block:: json
+
+    {
+        "...": "...",
+        "paths": {
+            "/greet": {
+                "get": {
+                    "operationId": "Greet",
+                    "responses": {
+                        "200": {
+                            "description": "Greet 200 response",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/GreetResponseContent"
+                                    }
+                                }
+                            }
+                        }
+                    },
+                    "x-meta": {
+                        "owner": "greetings-team-a"
+                    }
+                }
+            }
+        },
+        "components": {
+            "schemas": {
+                "GreetResponseContent": {
+                    "type": "object",
+                    "properties": {
+                        "greeting": {
+                            "type": "string"
+                        }
+                    },
+                    "x-meta": {
+                        "owner": "greetings-team-b"
+                    }
+                }
+            }
+        }
+    }
+
+.. rubric:: Supported trait locations:
+
+Only a subset of OpenAPI locations are supported in the conversion:
+
+.. list-table::
+    :header-rows: 1
+    :widths: 50 50
+
+    * - Smithy Location
+      - OpenAPI Location
+    * - Service shape
+      - `Root OpenAPI schema <https://spec.openapis.org/oas/v3.1.0#openapi-object>`_
+    * - Operation shape
+      - `Operation object <https://spec.openapis.org/oas/v3.1.0#operation-object>`_
+    * - Simple & Aggregate shapes
+      - `Schema object <https://spec.openapis.org/oas/v3.1.0#schema-object>`_
+
+Unsupported use cases can likely be covered by the :ref:`jsonAdd <generate-openapi-setting-jsonAdd>` feature of the ``smithy-openapi`` plugin.
 
 -----------------------------
 Amazon API Gateway extensions
@@ -1754,3 +1905,4 @@ The conversion process is highly extensible through
 .. _x-amazon-apigateway-authorizer: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-authorizer.html
 .. _Lambda authorizers: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-authorizer.html
 .. _API Gateway's API key usage plans: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-usage-plans.html
+.. _OpenAPI specification extension: https://spec.openapis.org/oas/v3.1.0#specification-extensions
diff --git a/settings.gradle b/settings.gradle
index f127d43b2..3ce6a7f33 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -21,6 +21,7 @@ include ":smithy-linters"
 include ":smithy-mqtt-traits"
 include ":smithy-jsonschema"
 include ":smithy-openapi"
+include ":smithy-openapi-traits"
 include ":smithy-utils"
 include ":smithy-protocol-test-traits"
 include ':smithy-jmespath'
diff --git a/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/JsonSchemaMapper.java b/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/JsonSchemaMapper.java
index fbcd186b6..a1ff07bcd 100644
--- a/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/JsonSchemaMapper.java
+++ b/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/JsonSchemaMapper.java
@@ -19,6 +19,11 @@ import software.amazon.smithy.model.shapes.Shape;
 
 /**
  * Updates a schema builder before converting a shape to a schema.
+ *
+ * {@link JsonSchemaMapper#updateSchema(JsonSchemaMapperContext, Schema.Builder)} is the entry point during JSON Schema
+ * conversion, and is the recommended method to implement. If this method is implemented,
+ * {@link JsonSchemaMapper#updateSchema(Shape, Schema.Builder, JsonSchemaConfig)} will NOT be called unless written in
+ * the implementation.
  */
 public interface JsonSchemaMapper {
     /**
@@ -36,12 +41,33 @@ public interface JsonSchemaMapper {
     }
 
     /**
-     * Updates a schema builder.
+     * Updates a schema builder using information in {@link JsonSchemaMapperContext}.
+     *
+     * If not implemented, will default to
+     * {@link JsonSchemaMapper#updateSchema(Shape, Schema.Builder, JsonSchemaConfig)} for backwards-compatibility.
+     *
+     * @param context Context with information needed to update the schema.
+     * @param schemaBuilder Schema builder to update.
+     * @return Returns an updated schema builder.
+     */
+    default Schema.Builder updateSchema(JsonSchemaMapperContext context, Schema.Builder schemaBuilder) {
+        return updateSchema(context.getShape(), schemaBuilder, context.getConfig());
+    }
+
+    /**
+     * Updates a schema builder, and is not recommended. Use
+     * {@link JsonSchemaMapper#updateSchema(JsonSchemaMapperContext, Schema.Builder)} instead.
+     *
+     * If not implemented, this method will default to a no-op.
+     *
+     * This method is not deprecated for backwards-compatibility.
      *
      * @param shape Shape used for the conversion.
      * @param schemaBuilder Schema builder to update.
      * @param config JSON Schema config.
      * @return Returns an updated schema builder.
      */
-    Schema.Builder updateSchema(Shape shape, Schema.Builder schemaBuilder, JsonSchemaConfig config);
+    default Schema.Builder updateSchema(Shape shape, Schema.Builder schemaBuilder, JsonSchemaConfig config) {
+        return schemaBuilder;
+    }
 }
diff --git a/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/JsonSchemaMapperContext.java b/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/JsonSchemaMapperContext.java
new file mode 100644
index 000000000..49f47e4ef
--- /dev/null
+++ b/smithy-jsonschema/src/main/java/software/amazon/smithy/jsonschema/JsonSchemaMapperContext.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.jsonschema;
+
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.shapes.Shape;
+
+/**
+ * Context for a JSON schema mapping.
+ */
+public class JsonSchemaMapperContext {
+    private final Model model;
+    private final Shape shape;
+    private final JsonSchemaConfig config;
+
+    JsonSchemaMapperContext(
+            Model model,
+            Shape shape,
+            JsonSchemaConfig config
+    ) {
+        this.model = model;
+        this.shape = shape;
+        this.config = config;
+    }
+
+    /**
+     * Gets the Smithy model being converted.
+     *
+     * @return Returns the Smithy model.
+     */
+    public Model getModel() {
+        return model;
+    }
+
+    /**
+     * Gets the Smithy shape being mapped.
+     *
+     * @return Returns the Smithy shape.
+     */
+    public Shape getShape() {
+        return shape;
+    }
+
+    /**
+     * Gets the JSON schema configuration object.
+     *
+     * @return Returns the JSON schema config object.
+     */
+    public JsonSchemaConfig getConfig() {
+        return config;
+    }
+}
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 477bf1526..46550dfff 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
@@ -344,8 +344,9 @@ final class JsonSchemaShapeVisitor extends ShapeVisitor.Default<Schema> {
      * @return Returns the built schema.
      */
     private Schema buildSchema(Shape shape, Schema.Builder builder) {
+        JsonSchemaMapperContext context = new JsonSchemaMapperContext(model, shape, converter.getConfig());
         for (JsonSchemaMapper mapper : mappers) {
-            mapper.updateSchema(shape, builder, converter.getConfig());
+            builder = mapper.updateSchema(context, builder);
         }
 
         return builder.build();
diff --git a/smithy-openapi-traits/build.gradle b/smithy-openapi-traits/build.gradle
new file mode 100644
index 000000000..b671893fd
--- /dev/null
+++ b/smithy-openapi-traits/build.gradle
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+description = "This module provides Smithy traits that are used in converting a Smithy model to OpenAPI."
+
+ext {
+    displayName = "Smithy :: OpenAPI Traits"
+    moduleName = "software.amazon.smithy.openapi.traits"
+}
+
+dependencies {
+    api project(":smithy-model")
+}
diff --git a/smithy-openapi-traits/src/main/java/software/amazon/smithy/openapi/traits/SpecificationExtensionTrait.java b/smithy-openapi-traits/src/main/java/software/amazon/smithy/openapi/traits/SpecificationExtensionTrait.java
new file mode 100644
index 000000000..1d472dbad
--- /dev/null
+++ b/smithy-openapi-traits/src/main/java/software/amazon/smithy/openapi/traits/SpecificationExtensionTrait.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.openapi.traits;
+
+import java.util.Optional;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.node.NodeMapper;
+import software.amazon.smithy.model.shapes.ShapeId;
+import software.amazon.smithy.model.traits.AbstractTrait;
+import software.amazon.smithy.model.traits.AbstractTraitBuilder;
+import software.amazon.smithy.model.traits.Trait;
+import software.amazon.smithy.utils.ToSmithyBuilder;
+
+/**
+ * <code>smithy.openapi#specificationExtension</code> - Indicates a trait shape should be converted into an <a href="https://spec.openapis.org/oas/v3.1.0#specification-extensions">OpenAPI specification extension</a>.
+ */
+public final class SpecificationExtensionTrait extends AbstractTrait
+    implements ToSmithyBuilder<SpecificationExtensionTrait> {
+    public static final ShapeId ID = ShapeId.from("smithy.openapi#specificationExtension");
+
+    private final String as;
+
+    private SpecificationExtensionTrait(Builder builder) {
+        super(ID, builder.getSourceLocation());
+        this.as = builder.as;
+    }
+
+    public static final class Provider extends AbstractTrait.Provider {
+        public Provider() {
+            super(ID);
+        }
+
+        @Override
+        public Trait createTrait(ShapeId target, Node value) {
+            SpecificationExtensionTrait trait = new NodeMapper().deserialize(value, SpecificationExtensionTrait.class);
+            trait.setNodeCache(value);
+            return trait;
+        }
+    }
+
+    /**
+     * Gets the specification extension header value in "as".
+     *
+     * @return Returns the optionally present "as".
+     */
+    public Optional<String> getAs() {
+        return Optional.ofNullable(as);
+    }
+
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    @Override
+    protected Node createNode() {
+        NodeMapper mapper = new NodeMapper();
+        mapper.disableToNodeForClass(SpecificationExtensionTrait.class);
+        mapper.setOmitEmptyValues(true);
+        return mapper.serialize(this).expectObjectNode();
+    }
+
+    @Override
+    public Builder toBuilder() {
+        return builder()
+                .sourceLocation(getSourceLocation())
+                .as(this.as);
+    }
+
+    /**
+     * Builds a {@link SpecificationExtensionTrait} trait.
+     */
+    public static final class Builder extends AbstractTraitBuilder<SpecificationExtensionTrait, Builder> {
+        private String as;
+
+        private Builder() {}
+
+        @Override
+        public SpecificationExtensionTrait build() {
+            return new SpecificationExtensionTrait(this);
+        }
+
+        /**
+         * Set the explicit name for the target specification extension.
+         *
+         * @param as Explicit name for the target specification extension, or null.
+         * @return This builder instance.
+         */
+        public Builder as(String as) {
+            this.as = as;
+            return this;
+        }
+    }
+}
diff --git a/smithy-openapi-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService b/smithy-openapi-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService
new file mode 100644
index 000000000..82fbaa1fe
--- /dev/null
+++ b/smithy-openapi-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService
@@ -0,0 +1,1 @@
+software.amazon.smithy.openapi.traits.SpecificationExtensionTrait$Provider
diff --git a/smithy-openapi-traits/src/main/resources/META-INF/smithy/manifest b/smithy-openapi-traits/src/main/resources/META-INF/smithy/manifest
new file mode 100644
index 000000000..7357478d9
--- /dev/null
+++ b/smithy-openapi-traits/src/main/resources/META-INF/smithy/manifest
@@ -0,0 +1,1 @@
+smithy.openapi.smithy
diff --git a/smithy-openapi-traits/src/main/resources/META-INF/smithy/smithy.openapi.smithy b/smithy-openapi-traits/src/main/resources/META-INF/smithy/smithy.openapi.smithy
new file mode 100644
index 000000000..0af3d334b
--- /dev/null
+++ b/smithy-openapi-traits/src/main/resources/META-INF/smithy/smithy.openapi.smithy
@@ -0,0 +1,21 @@
+$version: "2"
+
+namespace smithy.openapi
+
+/// Indicates a trait shape should be converted into an [OpenAPI specification extension](https://spec.openapis.org/oas/v3.1.0#specification-extensions).
+@trait(
+    selector: "[trait|trait]",
+    breakingChanges: [
+        {change: "presence"},
+        {path: "/as", change: "any"}
+    ]
+)
+structure specificationExtension {
+    /// Explicitly name the specification extension.
+    /// If set must begin with `x-`, otherwise defaults to the target trait shape's ID normalized with hyphens and prepended with `x-`.
+    as: SpecificationExtensionKey
+}
+
+@private
+@pattern("^x-.+$")
+string SpecificationExtensionKey
diff --git a/smithy-openapi/build.gradle b/smithy-openapi/build.gradle
index 4f5d6be28..5cc5ba831 100644
--- a/smithy-openapi/build.gradle
+++ b/smithy-openapi/build.gradle
@@ -25,4 +25,5 @@ dependencies {
     api project(":smithy-build")
     api project(":smithy-jsonschema")
     api project(":smithy-aws-traits")
+    api project(":smithy-openapi-traits")
 }
diff --git a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/OpenApiUtils.java b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/OpenApiUtils.java
new file mode 100644
index 000000000..fb9143454
--- /dev/null
+++ b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/OpenApiUtils.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.openapi;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.ShapeId;
+import software.amazon.smithy.openapi.traits.SpecificationExtensionTrait;
+import software.amazon.smithy.utils.SmithyInternalApi;
+
+@SmithyInternalApi
+public final class OpenApiUtils {
+    private OpenApiUtils() {}
+
+    /**
+     * Gets the specification extension name for a given meta trait.
+     *
+     * Either an explicitly configured extension name in specificationExtensionTrait, or a normalization of the shape
+     * ID. The normalization replaces all "." and "#" in a shapeId to "-".
+     *
+     * @param metaTraitId Trait shape to get the extension name.
+     * @return Extension name for the given trait shape.
+     */
+    public static String getSpecificationExtensionName(
+        ShapeId metaTraitId,
+        SpecificationExtensionTrait specificationExtensionTrait
+    ) {
+        return specificationExtensionTrait.getAs()
+            .orElse("x-" + metaTraitId.toString().replaceAll("[.#]", "-"));
+    }
+
+    /**
+     * Return specification extensions attached to a given shape.
+     *
+     * @param shape    Shape to get extensions for.
+     * @param model    Model the shape belongs to.
+     * @return map of specification extension names to node values
+     */
+    public static Map<String, Node> getSpecificationExtensionsMap(Model model, Shape shape) {
+        Map<String, Node> specificationExtensions = new LinkedHashMap<String, Node>();
+        shape.getAllTraits().forEach((traitId, trait) ->
+            // Get Applied Trait
+            model.getShape(traitId)
+                    // Get SpecificationExtensionTrait on the Applied Trait
+                    .flatMap(traitShape -> traitShape.getTrait(SpecificationExtensionTrait.class))
+                    // Get specification extension name from the Applied Trait and SpecificationExtensionTrait
+                    .map(specificationExtension -> getSpecificationExtensionName(traitId, specificationExtension))
+                    // Put the specification extension name and Applied Meta trait into the map.
+                    .ifPresent(name -> specificationExtensions.put(name, trait.toNode())));
+        return specificationExtensions;
+    }
+}
diff --git a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/CoreExtension.java b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/CoreExtension.java
index a3ddc8a28..4907b2b8b 100644
--- a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/CoreExtension.java
+++ b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/CoreExtension.java
@@ -24,6 +24,7 @@ import software.amazon.smithy.openapi.fromsmithy.mappers.OpenApiJsonAdd;
 import software.amazon.smithy.openapi.fromsmithy.mappers.OpenApiJsonSubstitutions;
 import software.amazon.smithy.openapi.fromsmithy.mappers.RemoveEmptyComponents;
 import software.amazon.smithy.openapi.fromsmithy.mappers.RemoveUnusedComponents;
+import software.amazon.smithy.openapi.fromsmithy.mappers.SpecificationExtensionsMapper;
 import software.amazon.smithy.openapi.fromsmithy.mappers.UnsupportedTraits;
 import software.amazon.smithy.openapi.fromsmithy.protocols.AwsRestJson1Protocol;
 import software.amazon.smithy.openapi.fromsmithy.security.AwsV4Converter;
@@ -62,7 +63,8 @@ public final class CoreExtension implements Smithy2OpenApiExtension {
                 new OpenApiJsonAdd(),
                 new RemoveUnusedComponents(),
                 new UnsupportedTraits(),
-                new RemoveEmptyComponents()
+                new RemoveEmptyComponents(),
+                new SpecificationExtensionsMapper()
         );
     }
 
diff --git a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/OpenApiConverter.java b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/OpenApiConverter.java
index 4424a0480..fa7855f68 100644
--- a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/OpenApiConverter.java
+++ b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/OpenApiConverter.java
@@ -330,7 +330,7 @@ public final class OpenApiConverter {
         openapi.components(environment.components.build());
 
         // Add arbitrary extensions if they're configured.
-        openapi.extensions(context.getConfig().getSchemaDocumentExtensions());
+        openapi.getExtensions().putAll(context.getConfig().getSchemaDocumentExtensions().getStringMap());
 
         return mapper.after(context, openapi.build());
     }
diff --git a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/OpenApiJsonSchemaMapper.java b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/OpenApiJsonSchemaMapper.java
index 8fecc349e..fea1bb9bd 100644
--- a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/OpenApiJsonSchemaMapper.java
+++ b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/OpenApiJsonSchemaMapper.java
@@ -24,6 +24,7 @@ import java.util.Map;
 import java.util.Optional;
 import software.amazon.smithy.jsonschema.JsonSchemaConfig;
 import software.amazon.smithy.jsonschema.JsonSchemaMapper;
+import software.amazon.smithy.jsonschema.JsonSchemaMapperContext;
 import software.amazon.smithy.jsonschema.Schema;
 import software.amazon.smithy.model.node.Node;
 import software.amazon.smithy.model.shapes.Shape;
@@ -32,6 +33,7 @@ import software.amazon.smithy.model.traits.ExternalDocumentationTrait;
 import software.amazon.smithy.model.traits.SensitiveTrait;
 import software.amazon.smithy.model.traits.TimestampFormatTrait;
 import software.amazon.smithy.openapi.OpenApiConfig;
+import software.amazon.smithy.openapi.OpenApiUtils;
 import software.amazon.smithy.openapi.model.ExternalDocumentation;
 import software.amazon.smithy.utils.MapUtils;
 
@@ -45,7 +47,12 @@ import software.amazon.smithy.utils.MapUtils;
 public final class OpenApiJsonSchemaMapper implements JsonSchemaMapper {
 
     @Override
-    public Schema.Builder updateSchema(Shape shape, Schema.Builder builder, JsonSchemaConfig config) {
+    public Schema.Builder updateSchema(JsonSchemaMapperContext context, Schema.Builder builder) {
+        Shape shape = context.getShape();
+        OpenApiUtils.getSpecificationExtensionsMap(context.getModel(), shape).entrySet()
+            .forEach(entry -> builder.putExtension(entry.getKey(), entry.getValue()));
+
+        JsonSchemaConfig config = context.getConfig();
         getResolvedExternalDocs(shape, config)
                 .map(ExternalDocumentation::toNode)
                 .ifPresent(docs -> builder.putExtension("externalDocs", docs));
diff --git a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/mappers/SpecificationExtensionsMapper.java b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/mappers/SpecificationExtensionsMapper.java
new file mode 100644
index 000000000..9e4225571
--- /dev/null
+++ b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/mappers/SpecificationExtensionsMapper.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.openapi.fromsmithy.mappers;
+
+import software.amazon.smithy.model.shapes.OperationShape;
+import software.amazon.smithy.model.traits.Trait;
+import software.amazon.smithy.openapi.OpenApiUtils;
+import software.amazon.smithy.openapi.fromsmithy.Context;
+import software.amazon.smithy.openapi.fromsmithy.OpenApiMapper;
+import software.amazon.smithy.openapi.model.OpenApi;
+import software.amazon.smithy.openapi.model.OperationObject;
+import software.amazon.smithy.openapi.traits.SpecificationExtensionTrait;
+
+/**
+ * Maps trait shapes tagged with {@link SpecificationExtensionTrait} into <a href="https://spec.openapis.org/oas/v3.1.0#specification-extensions">OpenAPI specification extensions</a>.
+ */
+public class SpecificationExtensionsMapper implements OpenApiMapper {
+    /**
+     * Attach Specification Extensions to Service.
+     */
+    @Override
+    public OpenApi after(Context<? extends Trait> context, OpenApi openapi) {
+        openapi.getExtensions().putAll(
+            OpenApiUtils.getSpecificationExtensionsMap(context.getModel(), context.getService()));
+        return openapi;
+    }
+
+    /**
+     * Attach Specification Extensions to Operation.
+     */
+    @Override
+    public OperationObject updateOperation(
+            Context<? extends Trait> context,
+            OperationShape shape,
+            OperationObject operation,
+            String httpMethodName,
+            String path
+    ) {
+        operation.getExtensions().putAll(OpenApiUtils.getSpecificationExtensionsMap(context.getModel(), shape));
+        return operation;
+    }
+}
diff --git a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/model/OpenApi.java b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/model/OpenApi.java
index 1573542ca..69d595814 100644
--- a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/model/OpenApi.java
+++ b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/model/OpenApi.java
@@ -101,7 +101,7 @@ public final class OpenApi extends Component implements ToSmithyBuilder<OpenApi>
         security.forEach(builder::addSecurity);
         servers.forEach(builder::addServer);
         tags.forEach(builder::addTag);
-        return builder.extensions(getExtensions());
+        return builder;
     }
 
     @Override


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 f7bea5ec3..c65c8b9b3 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
@@ -211,7 +211,30 @@ public class JsonSchemaConverterTest {
     public void canAddCustomSchemaMapper() {
         Shape struct = StructureShape.builder().id("smithy.example#Foo").build();
         Model model = Model.builder().addShape(struct).build();
-        JsonSchemaMapper mapper = (shape, builder, conf) -> builder.putExtension("Hi", Node.from("There"));
+        class CustomMapper implements JsonSchemaMapper {
+            @Override
+            public Schema.Builder updateSchema(Shape shape, Schema.Builder builder, JsonSchemaConfig conf) {
+                return builder.putExtension("Hi", Node.from("There"));
+            }
+        }
+        JsonSchemaMapper mapper = new CustomMapper();
+        SchemaDocument doc = JsonSchemaConverter.builder().addMapper(mapper).model(model).build().convert();
+
+        assertTrue(doc.getDefinition("#/definitions/Foo").isPresent());
+        assertTrue(doc.getDefinition("#/definitions/Foo").get().getExtension("Hi").isPresent());
+    }
+
+    @Test
+    public void canAddCustomSchemaMapperContextMethod() {
+        Shape struct = StructureShape.builder().id("smithy.example#Foo").build();
+        Model model = Model.builder().addShape(struct).build();
+        class CustomMapper implements JsonSchemaMapper {
+            @Override
+            public Schema.Builder updateSchema(JsonSchemaMapperContext context, Schema.Builder builder) {
+                return builder.putExtension("Hi", Node.from("There"));
+            }
+        }
+        JsonSchemaMapper mapper = new CustomMapper();
         SchemaDocument doc = JsonSchemaConverter.builder().addMapper(mapper).model(model).build().convert();
 
         assertTrue(doc.getDefinition("#/definitions/Foo").isPresent());
diff --git a/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/OpenApiJsonSchemaMapperTest.java b/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/OpenApiJsonSchemaMapperTest.java
index ceeee93ed..eb87b08b6 100644
--- a/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/OpenApiJsonSchemaMapperTest.java
+++ b/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/OpenApiJsonSchemaMapperTest.java
@@ -16,7 +16,6 @@
 package software.amazon.smithy.openapi.fromsmithy;
 
 import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.equalTo;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -31,6 +30,7 @@ import software.amazon.smithy.jsonschema.SchemaDocument;
 import software.amazon.smithy.model.Model;
 import software.amazon.smithy.model.node.Node;
 import software.amazon.smithy.model.node.ObjectNode;
+import software.amazon.smithy.model.node.StringNode;
 import software.amazon.smithy.model.shapes.BlobShape;
 import software.amazon.smithy.model.shapes.ByteShape;
 import software.amazon.smithy.model.shapes.DoubleShape;
@@ -43,9 +43,12 @@ import software.amazon.smithy.model.shapes.ShortShape;
 import software.amazon.smithy.model.shapes.StringShape;
 import software.amazon.smithy.model.shapes.StructureShape;
 import software.amazon.smithy.model.traits.DeprecatedTrait;
+import software.amazon.smithy.model.traits.DynamicTrait;
 import software.amazon.smithy.model.traits.ExternalDocumentationTrait;
 import software.amazon.smithy.model.traits.SensitiveTrait;
+import software.amazon.smithy.model.traits.TraitDefinition;
 import software.amazon.smithy.openapi.OpenApiConfig;
+import software.amazon.smithy.openapi.traits.SpecificationExtensionTrait;
 import software.amazon.smithy.utils.ListUtils;
 
 public class OpenApiJsonSchemaMapperTest {
@@ -371,4 +374,33 @@ public class OpenApiJsonSchemaMapperTest {
 
         assertThat(document.getRootSchema().getFormat().get(), equalTo("password"));
     }
+
+    @Test
+    public void supportsSpecificationExtensionTrait() {
+        StringShape extensionTraitShape = StringShape.builder()
+                .id("a.b#extensionTrait")
+                .addTrait(TraitDefinition.builder().build())
+                .addTrait(SpecificationExtensionTrait.builder().as("x-important-metadata").build())
+                .build();
+        DynamicTrait extensionTraitInstance = new DynamicTrait(extensionTraitShape.getId(), StringNode.from("string content"));
+        IntegerShape integerShape = IntegerShape.builder().id("a.b#Integer").build();
+        StructureShape structure = StructureShape.builder()
+                .id("a.b#Struct")
+                .addTrait(extensionTraitInstance)
+                .addMember("c", integerShape.getId())
+                .build();
+
+        Model model = Model.builder().addShapes(extensionTraitShape, integerShape, structure).build();
+
+        SchemaDocument document = JsonSchemaConverter.builder()
+                .addMapper(new OpenApiJsonSchemaMapper())
+                .model(model)
+                .build()
+                .convertShape(structure);
+
+        assertThat(
+                document.getRootSchema().getExtension("x-important-metadata").get().toNode().expectStringNode().getValue(),
+                equalTo("string content")
+        );
+    }
 }
diff --git a/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/mappers/SpecificationExtensionsMapperTest.java b/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/mappers/SpecificationExtensionsMapperTest.java
new file mode 100644
index 000000000..e51a2eaed
--- /dev/null
+++ b/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/mappers/SpecificationExtensionsMapperTest.java
@@ -0,0 +1,55 @@
+package software.amazon.smithy.openapi.fromsmithy.mappers;
+
+import java.io.InputStream;
+import java.net.URL;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.shapes.ShapeId;
+import software.amazon.smithy.openapi.OpenApiConfig;
+import software.amazon.smithy.openapi.fromsmithy.OpenApiConverter;
+import software.amazon.smithy.utils.IoUtils;
+
+public class SpecificationExtensionsMapperTest {
+    @ParameterizedTest
+    @ValueSource(strings = {
+            "inlined-type-target",
+            "structure-target",
+            "operation-target",
+            "service-target"
+    })
+    public void checkMapping(String name) {
+        OpenApiConfig config = new OpenApiConfig();
+        config.setService(ShapeId.from("smithy.example#Service"));
+
+        Node actual = OpenApiConverter
+                        .create()
+                        .config(config)
+                        .convertToNode(getSpecificationExtensionTraits(name));
+        Node expected = getExpectedOpenAPI(name);
+
+        Node.assertEquals(actual, expected);
+    }
+
+    private static Model getSpecificationExtensionTraits(String name) {
+        return Model.assembler()
+                .addImport(getResource(name + ".smithy"))
+                .addImport(getResource("specification-extension-traits.smithy"))
+                .discoverModels()
+                .assemble()
+                .unwrap();
+    }
+
+    private static Node getExpectedOpenAPI(String name) {
+        return Node.parse(IoUtils.toUtf8String(getResourceAsStream(name + ".openapi.json")));
+    }
+
+    private static URL getResource(String name) {
+        return SpecificationExtensionsMapperTest.class.getResource("specificationextensions/" + name);
+    }
+
+    private static InputStream getResourceAsStream(String name) {
+        return SpecificationExtensionsMapperTest.class.getResourceAsStream("specificationextensions/" + name);
+    }
+}
diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/inlined-type-target.openapi.json b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/inlined-type-target.openapi.json
new file mode 100644
index 000000000..22075a3e4
--- /dev/null
+++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/inlined-type-target.openapi.json
@@ -0,0 +1,73 @@
+{
+  "openapi": "3.0.2",
+  "info": {
+    "title": "Service",
+    "version": ""
+  },
+  "paths": {
+    "/": {
+      "put": {
+        "operationId": "Operation",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/OperationRequestContent"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Operation 200 response"
+          }
+        }
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "OperationRequestContent": {
+        "type": "object",
+        "properties": {
+          "name": {
+            "type": "string",
+            "x-blob": "blob content",
+            "x-boolean": true,
+            "x-string": "string content",
+            "x-byte": 64,
+            "x-short": 16384,
+            "x-integer": 1073741824,
+            "x-long": 4611686018427387904,
+            "x-float": 1.07846,
+            "x-double": 57.64123,
+            "x-big-integer": 46116860184273879045678,
+            "x-big-decimal": 0.1234567890123456789,
+            "x-timestamp": "2023-02-27T13:01:57Z",
+            "x-document": {
+              "a": "b",
+              "c": ["d"]
+            },
+            "x-enum": "first",
+            "x-int-enum": 3,
+            "x-list": ["a", "b", "c"],
+            "x-map": {
+              "a": 15,
+              "b": 18
+            },
+            "x-smithy-example-structureExt": {
+              "stringMember": "first field",
+              "integerMember": 17
+            },
+            "x-smithy-example-unionExt": {
+              "string": "string variant"
+            }
+          },
+          "language": {
+            "type": "string"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/inlined-type-target.smithy b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/inlined-type-target.smithy
new file mode 100644
index 000000000..092b9fcf6
--- /dev/null
+++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/inlined-type-target.smithy
@@ -0,0 +1,45 @@
+$version: "2.0"
+
+namespace smithy.example
+
+@blobExt("blob content")
+@booleanExt(true)
+@stringExt("string content")
+@byteExt(64)
+@shortExt(16384)
+@integerExt(1073741824)
+@longExt(4611686018427387904)
+@floatExt(1.07846)
+@doubleExt(57.64123)
+@bigIntegerExt(46116860184273879045678)
+@bigDecimalExt(0.1234567890123456789)
+@timestampExt("2023-02-27T13:01:57Z")
+@documentExt({
+    "a": "b",
+    "c": ["d"]
+})
+@enumExt("first")
+@intEnumExt(3)
+@listExt(["a", "b", "c"])
+@mapExt("a": 15, "b": 18)
+@structureExt(
+    stringMember: "first field"
+    integerMember: 17
+)
+@unionExt(string: "string variant")
+string Name
+
+structure Input {
+    name: Name
+    language: String
+}
+
+@http(method: "PUT", uri: "/")
+operation Operation {
+    input: Input
+}
+
+@aws.protocols#restJson1
+service Service {
+    operations: [Operation]
+}
diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/operation-target.openapi.json b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/operation-target.openapi.json
new file mode 100644
index 000000000..da9d355df
--- /dev/null
+++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/operation-target.openapi.json
@@ -0,0 +1,49 @@
+{
+  "openapi": "3.0.2",
+  "info": {
+    "title": "Service",
+    "version": ""
+  },
+  "paths": {
+    "/": {
+      "put": {
+        "operationId": "Operation",
+        "responses": {
+          "200": {
+            "description": "Operation 200 response"
+          }
+        },
+        "x-blob": "blob content",
+        "x-boolean": true,
+        "x-string": "string content",
+        "x-byte": 64,
+        "x-short": 16384,
+        "x-integer": 1073741824,
+        "x-long": 4611686018427387904,
+        "x-float": 1.07846,
+        "x-double": 57.64123,
+        "x-big-integer": 46116860184273879045678,
+        "x-big-decimal": 0.1234567890123456789,
+        "x-timestamp": "2023-02-27T13:01:57Z",
+        "x-document": {
+          "a": "b",
+          "c": ["d"]
+        },
+        "x-enum": "first",
+        "x-int-enum": 3,
+        "x-list": ["a", "b", "c"],
+        "x-map": {
+          "a": 15,
+          "b": 18
+        },
+        "x-smithy-example-structureExt": {
+          "stringMember": "first field",
+          "integerMember": 17
+        },
+        "x-smithy-example-unionExt": {
+          "string": "string variant"
+        }
+      }
+    }
+  }
+}
diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/operation-target.smithy b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/operation-target.smithy
new file mode 100644
index 000000000..63c616baf
--- /dev/null
+++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/operation-target.smithy
@@ -0,0 +1,37 @@
+$version: "2.0"
+
+namespace smithy.example
+
+@blobExt("blob content")
+@booleanExt(true)
+@stringExt("string content")
+@byteExt(64)
+@shortExt(16384)
+@integerExt(1073741824)
+@longExt(4611686018427387904)
+@floatExt(1.07846)
+@doubleExt(57.64123)
+@bigIntegerExt(46116860184273879045678)
+@bigDecimalExt(0.1234567890123456789)
+@timestampExt("2023-02-27T13:01:57Z")
+@documentExt({
+    "a": "b",
+    "c": ["d"]
+})
+@enumExt("first")
+@intEnumExt(3)
+@listExt(["a", "b", "c"])
+@mapExt("a": 15, "b": 18)
+@structureExt(
+    stringMember: "first field"
+    integerMember: 17
+)
+@unionExt(string: "string variant")
+@http(method: "PUT", uri: "/")
+operation Operation {
+}
+
+@aws.protocols#restJson1
+service Service {
+    operations: [Operation]
+}
diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/service-target.openapi.json b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/service-target.openapi.json
new file mode 100644
index 000000000..7a6122719
--- /dev/null
+++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/service-target.openapi.json
@@ -0,0 +1,49 @@
+{
+  "openapi": "3.0.2",
+  "info": {
+    "title": "Service",
+    "version": ""
+  },
+  "paths": {
+    "/": {
+      "put": {
+        "operationId": "Operation",
+        "responses": {
+          "200": {
+            "description": "Operation 200 response"
+          }
+        }
+      }
+    }
+  },
+  "x-blob": "blob content",
+  "x-boolean": true,
+  "x-string": "string content",
+  "x-byte": 64,
+  "x-short": 16384,
+  "x-integer": 1073741824,
+  "x-long": 4611686018427387904,
+  "x-float": 1.07846,
+  "x-double": 57.64123,
+  "x-big-integer": 46116860184273879045678,
+  "x-big-decimal": 0.1234567890123456789,
+  "x-timestamp": "2023-02-27T13:01:57Z",
+  "x-document": {
+    "a": "b",
+    "c": ["d"]
+  },
+  "x-enum": "first",
+  "x-int-enum": 3,
+  "x-list": ["a", "b", "c"],
+  "x-map": {
+    "a": 15,
+    "b": 18
+  },
+  "x-smithy-example-structureExt": {
+    "stringMember": "first field",
+    "integerMember": 17
+  },
+  "x-smithy-example-unionExt": {
+    "string": "string variant"
+  }
+}
diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/service-target.smithy b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/service-target.smithy
new file mode 100644
index 000000000..9064c6971
--- /dev/null
+++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/service-target.smithy
@@ -0,0 +1,37 @@
+$version: "2.0"
+
+namespace smithy.example
+
+@http(method: "PUT", uri: "/")
+operation Operation {
+}
+
+@blobExt("blob content")
+@booleanExt(true)
+@stringExt("string content")
+@byteExt(64)
+@shortExt(16384)
+@integerExt(1073741824)
+@longExt(4611686018427387904)
+@floatExt(1.07846)
+@doubleExt(57.64123)
+@bigIntegerExt(46116860184273879045678)
+@bigDecimalExt(0.1234567890123456789)
+@timestampExt("2023-02-27T13:01:57Z")
+@documentExt({
+    "a": "b",
+    "c": ["d"]
+})
+@enumExt("first")
+@intEnumExt(3)
+@listExt(["a", "b", "c"])
+@mapExt("a": 15, "b": 18)
+@structureExt(
+    stringMember: "first field"
+    integerMember: 17
+)
+@unionExt(string: "string variant")
+@aws.protocols#restJson1
+service Service {
+    operations: [Operation]
+}
diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/specification-extension-traits.smithy b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/specification-extension-traits.smithy
new file mode 100644
index 000000000..2eff3fde8
--- /dev/null
+++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/specification-extension-traits.smithy
@@ -0,0 +1,100 @@
+$version: "2.0"
+
+namespace smithy.example
+
+use smithy.openapi#specificationExtension
+
+@trait
+@specificationExtension(as: "x-blob")
+blob blobExt
+
+@trait
+@specificationExtension(as: "x-boolean")
+boolean booleanExt
+
+@trait
+@specificationExtension(as: "x-string")
+string stringExt
+
+@trait
+@specificationExtension(as: "x-byte")
+byte byteExt
+
+@trait
+@specificationExtension(as: "x-short")
+short shortExt
+
+@trait
+@specificationExtension(as: "x-integer")
+integer integerExt
+
+@trait
+@specificationExtension(as: "x-long")
+long longExt
+
+@trait
+@specificationExtension(as: "x-float")
+float floatExt
+
+@trait
+@specificationExtension(as: "x-double")
+double doubleExt
+
+@trait
+@specificationExtension(as: "x-big-integer")
+bigInteger bigIntegerExt
+
+@trait
+@specificationExtension(as: "x-big-decimal")
+bigDecimal bigDecimalExt
+
+@trait
+@specificationExtension(as: "x-timestamp")
+timestamp timestampExt
+
+@trait
+@specificationExtension(as: "x-document")
+document documentExt
+
+@trait
+@specificationExtension(as: "x-enum")
+enum enumExt {
+    FIRST = "first"
+    SECOND = "second"
+    THIRD = "third"
+}
+
+@trait
+@specificationExtension(as: "x-int-enum")
+intEnum intEnumExt {
+    ONE = 1
+    TWO = 2
+    THREE = 3
+}
+
+@trait
+@specificationExtension(as: "x-list")
+list listExt {
+    member: String
+}
+
+@trait
+@specificationExtension(as: "x-map")
+map mapExt {
+    key: String
+    value: Integer
+}
+
+@trait
+@specificationExtension
+structure structureExt {
+    stringMember: String
+    integerMember: Integer
+}
+
+@trait
+@specificationExtension
+union unionExt {
+    i32: Integer
+    string: String
+}
diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/structure-target.openapi.json b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/structure-target.openapi.json
new file mode 100644
index 000000000..10c9fc728
--- /dev/null
+++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/structure-target.openapi.json
@@ -0,0 +1,73 @@
+{
+  "openapi": "3.0.2",
+  "info": {
+    "title": "Service",
+    "version": ""
+  },
+  "paths": {
+    "/": {
+      "put": {
+        "operationId": "Operation",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/OperationRequestContent"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Operation 200 response"
+          }
+        }
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "OperationRequestContent": {
+        "type": "object",
+        "properties": {
+          "name": {
+            "type": "string"
+          },
+          "language": {
+            "type": "string"
+          }
+        },
+        "x-blob": "blob content",
+        "x-boolean": true,
+        "x-string": "string content",
+        "x-byte": 64,
+        "x-short": 16384,
+        "x-integer": 1073741824,
+        "x-long": 4611686018427387904,
+        "x-float": 1.07846,
+        "x-double": 57.64123,
+        "x-big-integer": 46116860184273879045678,
+        "x-big-decimal": 0.1234567890123456789,
+        "x-timestamp": "2023-02-27T13:01:57Z",
+        "x-document": {
+          "a": "b",
+          "c": ["d"]
+        },
+        "x-enum": "first",
+        "x-int-enum": 3,
+        "x-list": ["a", "b", "c"],
+        "x-map": {
+          "a": 15,
+          "b": 18
+        },
+        "x-smithy-example-structureExt": {
+          "stringMember": "first field",
+          "integerMember": 17
+        },
+        "x-smithy-example-unionExt": {
+          "string": "string variant"
+        }
+      }
+    }
+  }
+}
diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/structure-target.smithy b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/structure-target.smithy
new file mode 100644
index 000000000..9058f4824
--- /dev/null
+++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/specificationextensions/structure-target.smithy
@@ -0,0 +1,43 @@
+$version: "2.0"
+
+namespace smithy.example
+
+@blobExt("blob content")
+@booleanExt(true)
+@stringExt("string content")
+@byteExt(64)
+@shortExt(16384)
+@integerExt(1073741824)
+@longExt(4611686018427387904)
+@floatExt(1.07846)
+@doubleExt(57.64123)
+@bigIntegerExt(46116860184273879045678)
+@bigDecimalExt(0.1234567890123456789)
+@timestampExt("2023-02-27T13:01:57Z")
+@documentExt({
+    "a": "b",
+    "c": ["d"]
+})
+@enumExt("first")
+@intEnumExt(3)
+@listExt(["a", "b", "c"])
+@mapExt("a": 15, "b": 18)
+@structureExt(
+    stringMember: "first field"
+    integerMember: 17
+)
+@unionExt(string: "string variant")
+structure Input {
+    name: String
+    language: String
+}
+
+@http(method: "PUT", uri: "/")
+operation Operation {
+    input: Input
+}
+
+@aws.protocols#restJson1
+service Service {
+    operations: [Operation]
+}