Repository: smithy-lang/smithy
Pull request #2069 (2023-12-11 22:11:25)

Add docs and validation for endpoint patterns

*Description of changes:*
Add docs and validation for endpoint patterns used by standardRegionalEndpoints and standardPartitionalEndpoints.

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/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e4f6029fc..96d1e5a61 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -36,7 +36,7 @@ jobs:
         if: matrix.java >= 17
         run: ./gradlew :smithy-cli:integ -Plog-tests --stacktrace
 
-      - uses: actions/upload-artifact@v3
+      - uses: actions/upload-artifact@v4
         if: failure()
         with:
           name: java-${{ matrix.java }}-${{ matrix.os }}-test-report
@@ -49,7 +49,7 @@ jobs:
     - uses: actions/checkout@v4
 
     - name: Set up Python 3.x
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
         python-version: '3.x'
 
@@ -59,7 +59,7 @@ jobs:
     - name: Build docs
       run: cd docs && make html
 
-    - uses: actions/upload-artifact@v3
+    - uses: actions/upload-artifact@v4
       with:
         name: built-docs
         path: docs/build/html
diff --git a/.github/workflows/codeguru-reviewer.yml b/.github/workflows/codeguru-reviewer.yml
index 5a2c81847..c1b8f456d 100644
--- a/.github/workflows/codeguru-reviewer.yml
+++ b/.github/workflows/codeguru-reviewer.yml
@@ -55,6 +55,6 @@ jobs:
 
     - name: Upload review result
       if: steps.iam-role.outcome == 'success' &&steps.guruscan.outcome == 'success'
-      uses: github/codeql-action/upload-sarif@v2
+      uses: github/codeql-action/upload-sarif@v3
       with:
         sarif_file: codeguru-results.sarif.json
diff --git a/.github/workflows/sdk-codegen-ci.yml b/.github/workflows/sdk-codegen-ci.yml
index 50cada842..d94ec9446 100644
--- a/.github/workflows/sdk-codegen-ci.yml
+++ b/.github/workflows/sdk-codegen-ci.yml
@@ -52,7 +52,7 @@ jobs:
           distribution: 'zulu'
 
       - name: Set up Go 1.19
-        uses: actions/setup-go@v4
+        uses: actions/setup-go@v5
         with:
           go-version: 1.19
 
diff --git a/.github/workflows/update-brew.yml b/.github/workflows/update-brew.yml
index 7f9f78cf9..9a86b65de 100644
--- a/.github/workflows/update-brew.yml
+++ b/.github/workflows/update-brew.yml
@@ -40,7 +40,7 @@ jobs:
           echo $hash_url
           curl -fJLO $hash_url.sha256
       - name: Save artifacts
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: brew-artifacts
           path: '*.sha256'
@@ -54,7 +54,7 @@ jobs:
     steps:
       - name: Get artifacts
         id: download
-        uses: actions/download-artifact@v3
+        uses: actions/download-artifact@v4
         with:
           name: brew-artifacts
       - name: Checkout bottle repo
diff --git a/build.gradle b/build.gradle
index 2e7c14a3f..f246dd12b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -24,7 +24,8 @@ plugins {
     id "com.github.spotbugs" version "4.7.1"
     id "io.codearte.nexus-staging" version "0.30.0"
     id "me.champeau.jmh" version "0.7.1"
-    id 'com.github.johnrengelman.shadow' version "7.1.2"
+    id "com.github.johnrengelman.shadow" version "7.1.2"
+    id "org.jreleaser" version "1.9.0"
 }
 
 ext {
@@ -39,6 +40,14 @@ allprojects {
     version = libraryVersion
 }
 
+// Only using JReleaser based on a flag allows going back to the old
+// release process if necessary. We can remove this later.
+def useJreleaser = project.hasProperty("useJreleaser")
+
+// JReleaser publishes artifacts from a local staging repository, rather than maven local.
+// https://jreleaser.org/guide/latest/examples/maven/staging-artifacts.html#_gradle
+def stagingDirectory = rootProject.layout.buildDirectory.dir("staging")
+
 subprojects {
     apply plugin: "java-library"
 
@@ -127,14 +136,23 @@ subprojects {
 
     publishing {
         repositories {
-            mavenCentral {
-                url = uri("https://aws.oss.sonatype.org/service/local/staging/deploy/maven2/")
-                if (project.hasProperty("sonatypeUser")) {
-                    credentials {
-                        username = project.property("sonatypeUser")
-                        password = project.property("sonatypePassword")
-                    }
+            // JReleaser's `publish` task publishes to all repositories, so only configure one.
+            if (useJreleaser) {
+                maven {
+                    name = "localStaging"
+                    url = stagingDirectory
                 }
+            } else {
+               mavenCentral {
+                   name = "sonatypeNexus"
+                   url = uri("https://aws.oss.sonatype.org/service/local/staging/deploy/maven2/")
+                   if (project.hasProperty("sonatypeUser")) {
+                       credentials {
+                           username = project.property("sonatypeUser")
+                           password = project.property("sonatypePassword")
+                       }
+                   }
+               }
             }
         }
 
@@ -271,6 +289,53 @@ allprojects {
     }
 }
 
+if (useJreleaser) {
+    jreleaser {
+        dryrun = false
+
+        // Used for creating a tagged release, uploading files and generating changelog.
+        // In the future we can set this up to push release tags to GitHub, but for now it's
+        // set up to do nothing.
+        // https://jreleaser.org/guide/latest/reference/release/index.html
+        release {
+            generic {
+                enabled = true
+                skipRelease = true
+            }
+        }
+
+        // Used to announce a release to configured announcers.
+        // https://jreleaser.org/guide/latest/reference/announce/index.html
+        announce {
+            active = "NEVER"
+        }
+
+        // Signing configuration.
+        // https://jreleaser.org/guide/latest/reference/signing.html
+        signing {
+            active = "ALWAYS"
+            armored = true
+        }
+
+        // Configuration for deploying to Maven Central.
+        // https://jreleaser.org/guide/latest/examples/maven/maven-central.html#_gradle
+        deploy {
+            maven {
+                nexus2 {
+                    'maven-central' {
+                        active = "ALWAYS"
+                        url = "https://aws.oss.sonatype.org/service/local"
+                        snapshotUrl = "https://aws.oss.sonatype.org/content/repositories/snapshots"
+                        closeRepository = false
+                        releaseRepository = false
+                        stagingRepository(stagingDirectory.get().toString())
+                    }
+                }
+            }
+        }
+    }
+}
+
 /*
  * Sonatype Staging Finalization
  * ====================================================
@@ -280,7 +345,7 @@ allprojects {
  * validated. This configuration is for the root project because
  * it operates at the "group" level.
  */
-if (project.hasProperty("sonatypeUser") && project.hasProperty("sonatypePassword")) {
+if (!useJreleaser && project.hasProperty("sonatypeUser") && project.hasProperty("sonatypePassword")) {
     apply plugin: "io.codearte.nexus-staging"
     nexusStaging {
         packageGroup = "software.amazon"
diff --git a/docs/source-2.0/aws/aws-endpoints-region.rst b/docs/source-2.0/aws/aws-endpoints-region.rst
index 10c0db91f..a4eaa8443 100644
--- a/docs/source-2.0/aws/aws-endpoints-region.rst
+++ b/docs/source-2.0/aws/aws-endpoints-region.rst
@@ -124,10 +124,10 @@ Summary
     that indicates that a service's endpoints should be resolved using the standard AWS regional
     patterns:
 
-    - Default: ``{service}.{region}.{dnsSuffix}``
-    - Fips: ``{service}-fips.{region}.{dnsSuffix}``
-    - Dualstack: ``{service}.{region}.{dualStackDnsSuffix}``
-    - Fips/Dualstack: ``{service}-fips.{region}.{dualStackDnsSuffix}``
+    - Default: ``https://{service}.{region}.{dnsSuffix}``
+    - Fips: ``https://{service}-fips.{region}.{dnsSuffix}``
+    - Dualstack: ``https://{service}.{region}.{dualStackDnsSuffix}``
+    - Fips/Dualstack: ``https://{service}-fips.{region}.{dualStackDnsSuffix}``
 
 Trait selector
     ``service``
@@ -184,7 +184,7 @@ FIPS endpoints in US GovCloud:
         partitionSpecialCases: {
             aws-us-gov: [
                 {
-                    endpoint: "myservice.{region}.{dnsSuffix}",
+                    endpoint: "https://myservice.{region}.{dnsSuffix}",
                     fips: true
                 }
             ]
@@ -209,7 +209,7 @@ in the given partition. A PartitionSpecialCase object contains the following pro
       - Description
     * - endpoint
       - ``string``
-      - **Required**. The special-cased endpoint template.
+      - **Required**. The special-cased :ref:`endpoint pattern <aws.endpoints#endpoint-pattern>`
     * - dualStack
       - ``boolean``
       - When ``true`` the special case will apply to dualstack endpoint variants.
@@ -232,7 +232,7 @@ A ``RegionSpecialCase`` object contains the following properties:
       - Description
     * - endpoint
       - ``string``
-      - **Required**. The special-cased endpoint template.
+      - **Required**. The special-cased :ref:`endpoint pattern <aws.endpoints#endpoint-pattern>`.
     * - dualStack
       - ``boolean``
       - When ``true`` the special case will apply to dualstack endpoint variants.
@@ -270,8 +270,8 @@ Trait value
     * - endpointPatternType
       - ``string``
       - **Required** The pattern type to use for the partition endpoint.  This value can be set to ``service_dnsSuffix`` to
-        use the ``{service}.{dnsSuffix}`` pattern or ``service_region_dnsSuffix`` to use
-        ``{service}.{region}.{dnsSuffix}``.
+        use the ``https://{service}.{dnsSuffix}`` pattern or ``service_region_dnsSuffix`` to use
+        ``https://{service}.{region}.{dnsSuffix}``.
     * - partitionEndpointSpecialCases
       - ``map`` of partition to `PartitionEndpointSpecialCase object`_
       - A map of partition to partition endpoint special cases - partitions that do not follow the
@@ -285,8 +285,8 @@ Partitional services (also known as "global" services) resolve a single endpoint
 That single endpoint is located in the partition's ``defaultGlobalRegion``. Partitional
 services should follow one of two standard patterns:
 
-- ``service_dnsSuffix``: ``{service}.{dnsSuffix}``
-- ``service_region_dnsSuffix``: ``{service}.{region}.{dnsSuffix}``
+- ``service_dnsSuffix``: ``https://{service}.{dnsSuffix}``
+- ``service_region_dnsSuffix``: ``https://{service}.{region}.{dnsSuffix}``
 
 The following example defines a partitional service that uses ``{service}.{dnsSuffix}``:
 
@@ -312,7 +312,7 @@ the ``aws`` partition and uses a non-standard global region in the ``aws-cn`` pa
     @standardPartitionalEndpoints(
         endpointPatternType: "service_dnsSuffix",
         partitionEndpointSpecialCases: {
-            aws: [{endpoint: "myservice.global.amazonaws.com"}],
+            aws: [{endpoint: "https://myservice.global.amazonaws.com"}],
             aws-cn: [{region: "cn-north-1"}]
         }
     )
@@ -334,7 +334,7 @@ A ``PartitionEndpointSpecialCase`` object contains the following properties:
       - Description
     * - endpoint
       - ``string``
-      - The special-cased endpoint template.
+      - The special-cased :ref:`endpoint pattern <aws.endpoints#endpoint-pattern>`.
     * - region
       - ``string``
       - Override the ``defaultGlobalRegion`` used in this partition.
@@ -412,3 +412,30 @@ The following example specifies a service that has standard regional endpoints e
      service MyService {
          version: "2020-04-02"
      }
+
+.. _aws.endpoints#endpoint-pattern:
+
+----------------
+Endpoint Pattern
+----------------
+
+Endpoint Patterns SHOULD begin with a scheme of either `http` or `https`.  When specifying special case endpoints in
+:ref:`StandardRegionalEndpoints <aws.endpoints#standardRegionalEndpoints-trait>` or
+:ref:`StandardPartitionalEndpoints <aws.endpoints#standardPartitionalEndpoints-trait>` you may use
+an endpoint pattern such as ``https://{service}.{region}.{dnsSuffix}`` with the following pattern substitutions:
+
+.. list-table::
+    :header-rows: 1
+    :widths: 10 60
+
+    * - Pattern
+      - Description
+    * - ``{region}``
+      - The region from the :ref:`AWS::Region built-in <rules-engine-aws-built-ins-region>`.
+    * - ``{service}``
+      - The endpoint prefix from the :ref:`service's endpointPrefix <service-endpoint-prefix>`.
+    * - ``{dnsSuffix}``
+      - The dns suffix from the :ref:`resolved partition's properties <rules-engine-aws-library-awsPartition-Partition>`.
+    * - ``{dualStackDnsSuffix}``
+      - The dualStack dns suffix from the :ref:`resolved partition's properties <rules-engine-aws-library-awsPartition-Partition>`.
+
diff --git a/docs/source-2.0/aws/rules-engine/built-ins.rst b/docs/source-2.0/aws/rules-engine/built-ins.rst
index c91a63203..cc8deefe3 100644
--- a/docs/source-2.0/aws/rules-engine/built-ins.rst
+++ b/docs/source-2.0/aws/rules-engine/built-ins.rst
@@ -78,6 +78,16 @@ Description
 Type
     ``string``
 
+.. _rules-engine-aws-built-ins-account-id-endpoint-mode:
+
+``AWS::Auth::AccountIdEndpointMode`` built-in
+=============================================
+
+Description
+    The AccountId Endpoint Mode.
+Type
+    ``string``
+
 .. _rules-engine-aws-built-ins-credential-scope:
 
 ``AWS::Auth::CredentialScope`` built-in
diff --git a/docs/source-2.0/guides/building-models/build-config.rst b/docs/source-2.0/guides/building-models/build-config.rst
index dd13aa32e..fa8145fa4 100644
--- a/docs/source-2.0/guides/building-models/build-config.rst
+++ b/docs/source-2.0/guides/building-models/build-config.rst
@@ -271,6 +271,19 @@ the following configuration:
       - HTTP basic or digest credentials to use with the repository.
         Credentials are provided in the form of "username:password".
 
+        .. warning::
+
+            Credentials SHOULD NOT be defined statically in a smithy-build.json
+            file. Instead, use :ref:`environment variables <build_envars>` to
+            keep credentials out of source control.
+    * - proxyHost
+      - ``string``
+      - The URL of the proxy to configure for this repository (for example, ``http://proxy.maven.apache.org:8080``).
+    * - proxyCredentials
+      - ``string``
+      - HTTP credentials to use with the proxy for the repository.
+        Credentials are provided in the form of "username:password".
+
         .. warning::
 
             Credentials SHOULD NOT be defined statically in a smithy-build.json
@@ -285,7 +298,9 @@ the following configuration:
             "repositories": [
                 {
                     "url": "https://my_domain-111122223333.d.codeartifact.region.amazonaws.com/maven/my_repo/",
-                    "httpCredentials": "aws:${CODEARTIFACT_AUTH_TOKEN}"
+                    "httpCredentials": "aws:${CODEARTIFACT_AUTH_TOKEN}",
+                    "proxyHost": "http://localhost:8080",
+                    "proxyCredentials": "user:${PROXY_AUTH_TOKEN}"
                 }
             ],
             "dependencies": [
@@ -324,6 +339,33 @@ variable, no default repositories are assumed when resolving the
     Repositories defined in ``SMITHY_MAVEN_REPOS`` take precedence over
     repositories defined through smithy-build.json configuration.
 
+Proxy environment variables
+---------------------------
+
+When using the Smithy CLI, the ``SMITHY_PROXY_HOST`` and ``SMITHY_PROXY_CREDENTIALS`` environment variables can be used
+to configure a proxy to use for dependency resolution. Setting these environment variables will configure a common
+proxy configuration for all repositories.
+
+.. important::
+
+    If a :ref:`Maven Repository <maven-repositories>` definition provides a proxy configuration, that configuration will
+    override the proxy configuration defined by the ``SMITHY_PROXY_HOST`` and ``SMITHY_PROXY_CREDENTIALS``
+    environment variables.
+
+The ``SMITHY_PROXY_HOST`` environment variable is a URL:
+
+.. code-block::
+
+    SMITHY_PROXY_HOST='http://localhost:8080'
+
+The ``SMITHY_PROXY_CREDENTIALS`` environment variable is a colon-delimited value (``:``) containing both the
+username and password to use for authenticating to the proxy configured by the ``SMITHY_PROXY_HOST`` environment
+variable:
+
+.. code-block::
+
+    SMITHY_PROXY_CREDENTIALS='user:$PROXY_PASSWORD'
+
 
 .. _projections:
 
diff --git a/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/language/functions/AwsBuiltIns.java b/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/language/functions/AwsBuiltIns.java
index f5fb0c459..5a47c5bb1 100644
--- a/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/language/functions/AwsBuiltIns.java
+++ b/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/language/functions/AwsBuiltIns.java
@@ -64,6 +64,17 @@ public final class AwsBuiltIns {
                     .documentation("The AWS AccountId used for the request.")
                     .build();
 
+    /**
+     * Built-in parameter representing the AccountId Endpoint Mode.
+     */
+    public static final Parameter ACCOUNT_ID_ENDPOINT_MODE =
+            Parameter.builder()
+                    .name("AccountIdEndpointMode")
+                    .type(ParameterType.STRING)
+                    .builtIn("AWS::Auth::AccountIdEndpointMode")
+                    .documentation("The AccountId Endpoint Mode.")
+                    .build();
+
     /**
      * Built-in parameter representing the Credential Scope.
      */
diff --git a/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/language/functions/AwsRuleSetExtension.java b/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/language/functions/AwsRuleSetExtension.java
index fd151a5a7..7fceddb7e 100644
--- a/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/language/functions/AwsRuleSetExtension.java
+++ b/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/language/functions/AwsRuleSetExtension.java
@@ -23,6 +23,7 @@ public final class AwsRuleSetExtension implements EndpointRuleSetExtension {
                 AwsBuiltIns.FIPS,
                 AwsBuiltIns.REGION,
                 AwsBuiltIns.ACCOUNT_ID,
+                AwsBuiltIns.ACCOUNT_ID_ENDPOINT_MODE,
                 AwsBuiltIns.CREDENTIAL_SCOPE,
                 AwsBuiltIns.S3_ACCELERATE,
                 AwsBuiltIns.S3_DISABLE_MRAP,
diff --git a/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/traits/EndpointPatternType.java b/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/traits/EndpointPatternType.java
index f836f7e15..a2cc25ad0 100644
--- a/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/traits/EndpointPatternType.java
+++ b/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/traits/EndpointPatternType.java
@@ -16,7 +16,7 @@ public enum EndpointPatternType {
     SERVICE_DNSSUFFIX("service_dnsSuffix"),
 
     /** An endpoint with pattern `{service}.{region}.{dnsSuffix}`. */
-    SERVICE_REGION_DNSSUFFI("service_region_dnsSuffix");
+    SERVICE_REGION_DNSSUFFIX("service_region_dnsSuffix");
 
     private final String name;
 
diff --git a/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/validators/AwsSpecialCaseEndpointValidator.java b/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/validators/AwsSpecialCaseEndpointValidator.java
new file mode 100644
index 000000000..a5591c87a
--- /dev/null
+++ b/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/validators/AwsSpecialCaseEndpointValidator.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.rulesengine.aws.validators;
+
+import java.net.MalformedURLException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import software.amazon.smithy.model.FromSourceLocation;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.shapes.ServiceShape;
+import software.amazon.smithy.model.validation.AbstractValidator;
+import software.amazon.smithy.model.validation.ValidationEvent;
+import software.amazon.smithy.rulesengine.aws.traits.PartitionEndpointSpecialCase;
+import software.amazon.smithy.rulesengine.aws.traits.PartitionSpecialCase;
+import software.amazon.smithy.rulesengine.aws.traits.RegionSpecialCase;
+import software.amazon.smithy.rulesengine.aws.traits.StandardPartitionalEndpointsTrait;
+import software.amazon.smithy.rulesengine.aws.traits.StandardRegionalEndpointsTrait;
+import software.amazon.smithy.utils.SetUtils;
+
+/**
+ * Validate special case endpoints from endpoint traits that are applied on a service.
+ */
+public final class AwsSpecialCaseEndpointValidator extends AbstractValidator {
+
+    private static final Set<String> SUPPORTED_PATTERNS = SetUtils.of(
+            "{region}", "{service}", "{dnsSuffix}", "{dualStackDnsSuffix}"
+    );
+
+    private static final Pattern PATTERN = Pattern.compile("\\{[^\\}]*\\}");
+
+    @Override
+    public List<ValidationEvent> validate(Model model) {
+        List<ValidationEvent> events = new ArrayList<>();
+        for (ServiceShape serviceShape : model.getServiceShapesWithTrait(StandardRegionalEndpointsTrait.class)) {
+            events.addAll(validateRegionalEndpointPatterns(serviceShape,
+                    serviceShape.expectTrait(StandardRegionalEndpointsTrait.class)));
+        }
+        for (ServiceShape serviceShape : model.getServiceShapesWithTrait(StandardPartitionalEndpointsTrait.class)) {
+            events.addAll(validatePartitionalEndpointPatterns(serviceShape,
+                    serviceShape.expectTrait(StandardPartitionalEndpointsTrait.class)));
+        }
+        return events;
+    }
+
+    private List<ValidationEvent> validateRegionalEndpointPatterns(
+            ServiceShape serviceShape, StandardRegionalEndpointsTrait regionalEndpoints) {
+        List<ValidationEvent> events = new ArrayList<>();
+
+        for (List<RegionSpecialCase> specialCases : regionalEndpoints.getRegionSpecialCases().values()) {
+            for (RegionSpecialCase specialCase : specialCases) {
+                events.addAll(validateEndpointPatterns(
+                        serviceShape, regionalEndpoints, specialCase.getEndpoint()));
+            }
+        }
+
+        for (List<PartitionSpecialCase> specialCases : regionalEndpoints.getPartitionSpecialCases().values()) {
+            for (PartitionSpecialCase specialCase : specialCases) {
+                events.addAll(validateEndpointPatterns(
+                        serviceShape, regionalEndpoints, specialCase.getEndpoint()));
+            }
+        }
+
+        return events;
+    }
+
+    private List<ValidationEvent> validatePartitionalEndpointPatterns(
+            ServiceShape serviceShape, StandardPartitionalEndpointsTrait partitionalEndpoints) {
+
+        List<ValidationEvent> events = new ArrayList<>();
+
+        for (List<PartitionEndpointSpecialCase> specialCases
+                : partitionalEndpoints.getPartitionEndpointSpecialCases().values()) {
+            for (PartitionEndpointSpecialCase specialCase : specialCases) {
+                events.addAll(validateEndpointPatterns(
+                        serviceShape, partitionalEndpoints, specialCase.getEndpoint()));
+            }
+        }
+
+        return events;
+    }
+
+    private List<ValidationEvent> validateEndpointPatterns(
+            ServiceShape serviceShape, FromSourceLocation location, String endpoint) {
+        List<ValidationEvent> events = new ArrayList<>();
+
+        Matcher m = PATTERN.matcher(endpoint);
+        List<String> unsupportedPatterns = new ArrayList<>();
+        while (m.find()) {
+            if (!SUPPORTED_PATTERNS.contains(m.group())) {
+                unsupportedPatterns.add(m.group());
+            }
+        }
+
+        if (!unsupportedPatterns.isEmpty()) {
+            events.add(danger(
+                    serviceShape, location,
+                    String.format("Endpoint `%s` contains unsupported patterns: %s",
+                            endpoint, String.join(", ", unsupportedPatterns)),
+                    "UnsupportedEndpointPattern"));
+        }
+
+        if (!(endpoint.startsWith("http://") || endpoint.startsWith("https://"))) {
+            events.add(danger(
+                    serviceShape, location,
+                    String.format("Endpoint `%s` should start with scheme `http://` or `https://`",
+                            endpoint),
+                    "InvalidEndpointPatternScheme"));
+        }
+
+        if (!isValidURL(endpoint)) {
+            events.add(error(
+                    serviceShape, location,
+                    String.format("Endpoint `%s` should be a valid URL.",
+                            endpoint),
+                    "InvalidEndpointPatternUrl"));
+        }
+
+        return events;
+    }
+
+    private boolean isValidURL(String endpointPattern) {
+        String url = endpointPattern
+                .replace("{", "")
+                .replace("}", "");
+        try {
+            new URL(url).toURI();
+            return true;
+        } catch (MalformedURLException e) {
+            return false;
+        } catch (URISyntaxException e) {
+            return false;
+        }
+    }
+}
diff --git a/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/validators/RuleSetAwsBuiltInValidator.java b/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/validators/RuleSetAwsBuiltInValidator.java
index df0375a01..b4131e749 100644
--- a/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/validators/RuleSetAwsBuiltInValidator.java
+++ b/smithy-aws-endpoints/src/main/java/software/amazon/smithy/rulesengine/aws/validators/RuleSetAwsBuiltInValidator.java
@@ -27,6 +27,7 @@ import software.amazon.smithy.utils.SetUtils;
 public class RuleSetAwsBuiltInValidator extends AbstractValidator {
     private static final Set<String> ADDITIONAL_CONSIDERATION_BUILT_INS = SetUtils.of(
             AwsBuiltIns.ACCOUNT_ID.getBuiltIn().get(),
+            AwsBuiltIns.ACCOUNT_ID_ENDPOINT_MODE.getBuiltIn().get(),
             AwsBuiltIns.CREDENTIAL_SCOPE.getBuiltIn().get());
     private static final String ADDITIONAL_CONSIDERATION_MESSAGE = "The `%s` built-in used requires additional "
            + "consideration of the rules that use it.";
diff --git a/smithy-aws-endpoints/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator b/smithy-aws-endpoints/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator
index 78f29a910..b87e2e464 100644
--- a/smithy-aws-endpoints/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator
+++ b/smithy-aws-endpoints/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator
@@ -1,1 +1,2 @@
 software.amazon.smithy.rulesengine.aws.validators.RuleSetAwsBuiltInValidator
+software.amazon.smithy.rulesengine.aws.validators.AwsSpecialCaseEndpointValidator
diff --git a/smithy-build/src/main/java/software/amazon/smithy/build/model/MavenRepository.java b/smithy-build/src/main/java/software/amazon/smithy/build/model/MavenRepository.java
index e34263cdb..13b556b51 100644
--- a/smithy-build/src/main/java/software/amazon/smithy/build/model/MavenRepository.java
+++ b/smithy-build/src/main/java/software/amazon/smithy/build/model/MavenRepository.java
@@ -27,11 +27,15 @@ public final class MavenRepository implements ToSmithyBuilder<MavenRepository> {
     private final String url;
     private final String httpCredentials;
     private final String id;
+    private final String proxyHost;
+    private final String proxyCredentials;
 
     public MavenRepository(Builder builder) {
         this.url = SmithyBuilder.requiredState("url", builder.url);
         this.httpCredentials = builder.httpCredentials;
         this.id = builder.id;
+        this.proxyHost = builder.proxyHost;
+        this.proxyCredentials = builder.proxyCredentials;
     }
 
     public static Builder builder() {
@@ -41,10 +45,13 @@ public final class MavenRepository implements ToSmithyBuilder<MavenRepository> {
     public static MavenRepository fromNode(Node node) {
         Builder builder = builder();
         node.expectObjectNode()
-                .warnIfAdditionalProperties(Arrays.asList("url", "httpCredentials"))
+                .warnIfAdditionalProperties(
+                        Arrays.asList("url", "httpCredentials", "id", "proxyHost", "proxyCredentials"))
                 .expectStringMember("url", builder::url)
                 .getStringMember("httpCredentials", builder::httpCredentials)
-                .getStringMember("id", builder::id);
+                .getStringMember("id", builder::id)
+                .getStringMember("proxyHost", builder::proxyHost)
+                .getStringMember("proxyCredentials", builder::proxyCredentials);
         return builder.build();
     }
 
@@ -60,6 +67,14 @@ public final class MavenRepository implements ToSmithyBuilder<MavenRepository> {
         return Optional.ofNullable(httpCredentials);
     }
 
+    public Optional<String> getProxyCredentials() {
+        return Optional.ofNullable(proxyCredentials);
+    }
+
+    public Optional<String> getProxyHost() {
+        return Optional.ofNullable(proxyHost);
+    }
+
     @Override
     public Builder toBuilder() {
         return builder().id(id).url(url).httpCredentials(httpCredentials);
@@ -88,6 +103,8 @@ public final class MavenRepository implements ToSmithyBuilder<MavenRepository> {
         private String url;
         private String httpCredentials;
         private String id;
+        private String proxyHost;
+        private String proxyCredentials;
 
         private Builder() {}
 
@@ -106,15 +123,32 @@ public final class MavenRepository implements ToSmithyBuilder<MavenRepository> {
             return this;
         }
 
+        public Builder proxyHost(String proxyHost) {
+            this.proxyHost = proxyHost;
+            return this;
+        }
+
+        public Builder proxyCredentials(String proxyCredentials) {
+            this.proxyCredentials = proxyCredentials;
+            validateColonSeparatedValue(proxyCredentials,
+                    "Invalid proxyCredentials: expected in the format of user:pass");
+            return this;
+        }
+
         public Builder httpCredentials(String httpCredentials) {
             this.httpCredentials = httpCredentials;
-            if (httpCredentials != null) {
-                int position = httpCredentials.indexOf(':');
-                if (position < 1 || position == httpCredentials.length() - 1) {
-                    throw new IllegalArgumentException("Invalid httpCredentials: expected in the format of user:pass");
+            validateColonSeparatedValue(httpCredentials,
+                    "Invalid httpCredentials: expected in the format of user:pass");
+            return this;
+        }
+
+        private void validateColonSeparatedValue(String value, String errorMessage) {
+            if (value != null) {
+                int position = value.indexOf(':');
+                if (position < 1 || position == value.length() - 1) {
+                    throw new IllegalArgumentException(errorMessage);
                 }
             }
-            return this;
         }
     }
 }
diff --git a/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/maven-proxy-env/smithy-build.json b/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/maven-proxy-env/smithy-build.json
new file mode 100644
index 000000000..cf02723b1
--- /dev/null
+++ b/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/maven-proxy-env/smithy-build.json
@@ -0,0 +1,15 @@
+{
+    "version": "1.0",
+    "maven": {
+        "repositories": [
+            {
+                // Use HTTP instead of HTTPS because we're running a mock server during tests
+                "url": "http://localhost:1234/maven/not/there"
+            }
+        ],
+        "dependencies": [
+            // Normally, this could refer to SMITHY_VERSION, but that doesn't work for pre-release builds.
+            "software.amazon.smithy:smithy-aws-iam-traits:1.26.0"
+        ]
+    }
+}
diff --git a/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/maven-proxy/smithy-build.json b/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/maven-proxy/smithy-build.json
new file mode 100644
index 000000000..8d788c7b3
--- /dev/null
+++ b/smithy-cli/src/it/resources/software/amazon/smithy/cli/projects/maven-proxy/smithy-build.json
@@ -0,0 +1,17 @@
+{
+    "version": "1.0",
+    "maven": {
+        "repositories": [
+            {
+                // Use HTTP instead of HTTPS because we're running a mock server during tests
+                "url": "http://localhost:1234/maven/not/there",
+                "proxyHost": "http://localhost:2323",
+                "proxyCredentials": "user:pass"
+            }
+        ],
+        "dependencies": [
+            // Normally, this could refer to SMITHY_VERSION, but that doesn't work for pre-release builds.
+            "software.amazon.smithy:smithy-aws-iam-traits:1.26.0"
+        ]
+    }
+}
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/EnvironmentVariable.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/EnvironmentVariable.java
index cfeca9c06..633ee2e3e 100644
--- a/smithy-cli/src/main/java/software/amazon/smithy/cli/EnvironmentVariable.java
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/EnvironmentVariable.java
@@ -89,7 +89,38 @@ public enum EnvironmentVariable {
      *     <li>If not set and the operating system is detected as Windows, colors are disabled.</li>
      * </ul>
      */
-    TERM;
+    TERM,
+
+    /**
+     * Used to determine a proxy host to use for Maven dependency resolution.
+     *
+     * <p>Set the host, protocol, and port information with a valid url.
+     * <pre>
+     * {@code export SMITHY_PROXY_HOST=protocol://host:port}
+     * </pre>
+     * <strong>NOTE:</strong> this setting will be used for all repositories
+     * defined in the smithy-build maven configuration unless a repo-specific
+     * configuration is provided.
+     *
+     */
+    SMITHY_PROXY_HOST,
+
+    /**
+     * Used to determine a proxy credentials to use for Maven dependency resolution through
+     * a proxy.
+     *
+     * <p>Use this setting in conjunction with the {@link #SMITHY_PROXY_HOST} variable to configure
+     * proxy settings for dependency resolution.
+     *
+     * <p>Set both the username and password information as a ":" separated string.
+     * <pre>
+     * {@code export SMITHY_PROXY_CREDENTIALS=user:pass}
+     * </pre>
+     * <strong>NOTE:</strong> this setting will be used for all repositories
+     * defined in the smithy-build maven configuration unless a repo-specific
+     * configuration is provided.
+     */
+    SMITHY_PROXY_CREDENTIALS;
 
     private static final Logger LOGGER = Logger.getLogger(EnvironmentVariable.class.getName());
 
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/dependencies/MavenDependencyResolver.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/dependencies/MavenDependencyResolver.java
index f9715251a..6d2eeb077 100644
--- a/smithy-cli/src/main/java/software/amazon/smithy/cli/dependencies/MavenDependencyResolver.java
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/dependencies/MavenDependencyResolver.java
@@ -18,8 +18,10 @@ package software.amazon.smithy.cli.dependencies;
 import static org.eclipse.aether.util.artifact.JavaScopes.COMPILE;
 import static org.eclipse.aether.util.artifact.JavaScopes.RUNTIME;
 
+import java.net.MalformedURLException;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.net.URL;
 import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.List;
@@ -35,6 +37,7 @@ import org.eclipse.aether.graph.Dependency;
 import org.eclipse.aether.graph.DependencyFilter;
 import org.eclipse.aether.impl.DefaultServiceLocator;
 import org.eclipse.aether.repository.LocalRepository;
+import org.eclipse.aether.repository.Proxy;
 import org.eclipse.aether.repository.RemoteRepository;
 import org.eclipse.aether.resolution.ArtifactResult;
 import org.eclipse.aether.resolution.DependencyRequest;
@@ -46,6 +49,7 @@ import org.eclipse.aether.transport.http.HttpTransporterFactory;
 import org.eclipse.aether.util.filter.DependencyFilterUtils;
 import org.eclipse.aether.util.repository.AuthenticationBuilder;
 import software.amazon.smithy.build.model.MavenRepository;
+import software.amazon.smithy.cli.EnvironmentVariable;
 
 /**
  * Resolves Maven dependencies for the Smithy CLI using Maven resolvers.
@@ -59,6 +63,7 @@ public final class MavenDependencyResolver implements DependencyResolver {
     private final DefaultRepositorySystemSession session = MavenRepositorySystemUtils.newSession();
     private final DependencyFilter filter = DependencyFilterUtils.classpathFilter(RUNTIME, COMPILE);
     private final RepositorySystem repositorySystem;
+    private final Proxy commonProxy;
 
     public MavenDependencyResolver() {
         this(null);
@@ -78,7 +83,8 @@ public final class MavenDependencyResolver implements DependencyResolver {
                 throw new DependencyResolverException(exception);
             }
         });
-
+        commonProxy = getProxy(EnvironmentVariable.SMITHY_PROXY_HOST.get(),
+                EnvironmentVariable.SMITHY_PROXY_CREDENTIALS.get());
         repositorySystem = locator.getService(RepositorySystem.class);
 
         // Sets a default maven local to the default local repo of the user.
@@ -105,6 +111,7 @@ public final class MavenDependencyResolver implements DependencyResolver {
                 addUserInfoAuth(uri, userInfo, builder);
             }
             repository.getHttpCredentials().ifPresent(credentials -> addUserInfoAuth(uri, credentials, builder));
+            addProxyInfo(repository, builder);
             remoteRepositories.add(builder.build());
         } catch (URISyntaxException e) {
             throw new DependencyResolverException("Invalid Maven repository URL: " + repository.getUrl()
@@ -112,6 +119,53 @@ public final class MavenDependencyResolver implements DependencyResolver {
         }
     }
 
+    private void addProxyInfo(MavenRepository repository, RemoteRepository.Builder builder) {
+        if (repository.getProxyHost().isPresent()) {
+            builder.setProxy(
+                    getProxy(repository.getProxyHost().get(), repository.getProxyCredentials().orElse(null))
+            );
+        } else if (commonProxy != null) {
+            builder.setProxy(commonProxy);
+        }
+    }
+
+    private static Proxy getProxy(final String host, final String userPass) {
+        if (host == null) {
+            return null;
+        }
+
+        URL hostUrl = parseHostString(host);
+        if (userPass != null) {
+            String[] userSettings = parseProxyCredentials(userPass);
+            AuthenticationBuilder authBuilder = new AuthenticationBuilder()
+                    .addUsername(userSettings[0])
+                    .addPassword(userSettings[1]);
+            return new Proxy(hostUrl.getProtocol(), hostUrl.getHost(), hostUrl.getPort(), authBuilder.build());
+        } else {
+            return new Proxy(hostUrl.getProtocol(), hostUrl.getHost(), hostUrl.getPort());
+        }
+
+    }
+
+    private static String[] parseProxyCredentials(String value) {
+        String[] settings = value.split(":");
+        if (settings.length != 2) {
+            throw new DependencyResolverException("Expected two values separated by ':' for "
+                    + EnvironmentVariable.SMITHY_PROXY_CREDENTIALS + ", but found " + settings.length);
+        }
+        return settings;
+    }
+
+    private static URL parseHostString(String value) {
+        try {
+            return new URL(value);
+        } catch (MalformedURLException exc) {
+            throw new DependencyResolverException("Expected a valid URL for "
+                + EnvironmentVariable.SMITHY_PROXY_HOST + ". Found " + value
+            );
+        }
+    }
+
     private void addUserInfoAuth(URI uri, String userInfo, RemoteRepository.Builder builder) {
         String[] parts = userInfo.split(":", 2);
         if (parts.length != 2) {
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/transform/ReplaceShapes.java b/smithy-model/src/main/java/software/amazon/smithy/model/transform/ReplaceShapes.java
index 2c8eb2e47..807f1eac9 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/transform/ReplaceShapes.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/transform/ReplaceShapes.java
@@ -15,13 +15,13 @@
 
 package software.amazon.smithy.model.transform;
 
-import static java.util.stream.Collectors.mapping;
 import static java.util.stream.Collectors.toList;
 
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -32,8 +32,6 @@ import software.amazon.smithy.model.Model;
 import software.amazon.smithy.model.loader.TopologicalShapeSort;
 import software.amazon.smithy.model.shapes.AbstractShapeBuilder;
 import software.amazon.smithy.model.shapes.CollectionShape;
-import software.amazon.smithy.model.shapes.ListShape;
-import software.amazon.smithy.model.shapes.MapShape;
 import software.amazon.smithy.model.shapes.MemberShape;
 import software.amazon.smithy.model.shapes.Shape;
 import software.amazon.smithy.model.shapes.ShapeId;
@@ -42,7 +40,6 @@ import software.amazon.smithy.model.shapes.SimpleShape;
 import software.amazon.smithy.model.shapes.StructureShape;
 import software.amazon.smithy.model.shapes.UnionShape;
 import software.amazon.smithy.model.traits.MixinTrait;
-import software.amazon.smithy.utils.OptionalUtils;
 import software.amazon.smithy.utils.Pair;
 import software.amazon.smithy.utils.SetUtils;
 
@@ -234,34 +231,51 @@ final class ReplaceShapes {
     }
 
     private Set<Shape> getUpdatedContainers(Model model, List<Shape> shouldReplace) {
-        // Account for multiple members being updated on the same container.
-        Map<Shape, List<MemberShape>> containerToMemberMapping = shouldReplace.stream()
-                .flatMap(shape -> OptionalUtils.stream(shape.asMemberShape()))
-                .flatMap(member -> Pair.flatMapStream(
-                        member, m -> findContainerShape(m.getContainer(), model, shouldReplace)))
-                .collect(Collectors.groupingBy(Pair::getRight, mapping(Pair::getLeft, toList())));
-
-        // TODO: This could be made more efficient by building containers only once.
-        return containerToMemberMapping.entrySet().stream()
-                .map(entry -> {
-                    Shape container = entry.getKey();
-                    List<MemberShape> members = entry.getValue();
-                    for (MemberShape member : members) {
-                        container = container.accept(new UpdateContainerVisitor(member)).orElse(container);
+        // Determine which shapes being updated are members, and group them by their container shapes.
+        Map<Shape, List<MemberShape>> containerToMemberMapping = new HashMap<>();
+        for (Shape shape : shouldReplace) {
+            shape.asMemberShape().ifPresent(member -> {
+                findMostUpToDateShape(member.getContainer(), model, shouldReplace).ifPresent(container -> {
+                    // Ignore members that are already a current member of a shape.
+                    if (!isMemberPresent(member, container)) {
+                        containerToMemberMapping.computeIfAbsent(container, c -> new ArrayList<>()).add(member);
                     }
-                    // Only update if changed.
-                    return container.equals(entry.getKey()) ? null : container;
-                })
-                .filter(Objects::nonNull)
-                .collect(Collectors.toSet());
+                });
+            });
+        }
+
+        Set<Shape> containersToUpdate = new HashSet<>(containerToMemberMapping.size());
+
+        // Update the containers with members.
+        for (Map.Entry<Shape, List<MemberShape>> entry : containerToMemberMapping.entrySet()) {
+            Shape container = entry.getKey();
+            List<MemberShape> members = entry.getValue();
+            AbstractShapeBuilder<?, ?> builder = Shape.shapeToBuilder(container);
+            for (MemberShape member : members) {
+                builder.addMember(member);
+            }
+            containersToUpdate.add(builder.build());
+        }
+
+        return containersToUpdate;
     }
 
-    private Optional<Shape> findContainerShape(ShapeId shapeId, Model model, List<Shape> shouldReplace) {
+    private static boolean isMemberPresent(MemberShape member, Shape shape) {
+        // Instance equality is preferred here to account for doing things like updating source locations which aren't
+        // part of the equals method of shapes.
+        return shape.getMember(member.getMemberName()).filter(m -> m == member).isPresent();
+    }
+
+    private Optional<Shape> findMostUpToDateShape(ShapeId shapeId, Model model, List<Shape> shouldReplace) {
         // Shapes in the replacement set take precedence over shapes in the previous model.
         // This accounts for newly added shapes and not overwriting changes also made to the
         // container shape.
-        Optional<Shape> result = shouldReplace.stream().filter(shape -> shape.getId().equals(shapeId)).findFirst();
-        return result.isPresent() ? result : model.getShape(shapeId);
+        for (Shape shape : shouldReplace) {
+            if (shape.getId().equals(shapeId)) {
+                return Optional.of(shape);
+            }
+        }
+        return model.getShape(shapeId);
     }
 
     /**
@@ -307,58 +321,4 @@ final class ReplaceShapes {
             return result;
         }
     }
-
-    /**
-     * Updates the container shape of a member when a member changes,
-     * IFF the member differs from what's in the shape.
-     */
-    private static final class UpdateContainerVisitor extends ShapeVisitor.Default<Optional<Shape>> {
-
-        private final MemberShape member;
-
-        UpdateContainerVisitor(MemberShape member) {
-            this.member = member;
-        }
-
-        @Override
-        public Optional<Shape> getDefault(Shape shape) {
-            return Optional.empty();
-        }
-
-        @Override
-        public Optional<Shape> listShape(ListShape shape) {
-            return shape.getMember() == member
-                   ? Optional.empty()
-                   : Optional.of(shape.toBuilder().member(member).build());
-        }
-
-        @Override
-        public Optional<Shape> mapShape(MapShape shape) {
-            if (member.getMemberName().equals("key")) {
-                return shape.getKey() == member
-                       ? Optional.empty()
-                       : Optional.of(shape.toBuilder().key(member).build());
-            } else {
-                return shape.getValue() == member
-                       ? Optional.empty()
-                       : Optional.of(shape.toBuilder().value(member).build());
-            }
-        }
-
-        @Override
-        public Optional<Shape> structureShape(StructureShape shape) {
-            // Replace the existing structure member with the new member.
-            return shape.getMember(member.getMemberName())
-                    .filter(oldMember -> !oldMember.equals(member))
-                    .map(oldMember -> shape.toBuilder().addMember(member).build());
-        }
-
-        @Override
-        public Optional<Shape> unionShape(UnionShape shape) {
-            // Replace the existing union member with the new member.
-            return shape.getMember(member.getMemberName())
-                    .filter(oldMember -> oldMember != member)
-                    .map(oldMember -> shape.toBuilder().addMember(member).build());
-        }
-    }
 }
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/NodeValidationVisitor.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/NodeValidationVisitor.java
index a3e74ebd2..8c7a00bb0 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/validation/NodeValidationVisitor.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/NodeValidationVisitor.java
@@ -16,7 +16,10 @@
 package software.amazon.smithy.model.validation;
 
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -53,10 +56,8 @@ import software.amazon.smithy.model.shapes.TimestampShape;
 import software.amazon.smithy.model.shapes.UnionShape;
 import software.amazon.smithy.model.validation.node.NodeValidatorPlugin;
 import software.amazon.smithy.model.validation.node.TimestampValidationStrategy;
-import software.amazon.smithy.utils.BuilderRef;
 import software.amazon.smithy.utils.ListUtils;
 import software.amazon.smithy.utils.SmithyBuilder;
-import software.amazon.smithy.utils.SmithyInternalApi;
 
 /**
  * Validates {@link Node} values provided for {@link Shape} definitions.
@@ -74,7 +75,6 @@ public final class NodeValidationVisitor implements ShapeVisitor<List<Validation
 
     private final Model model;
     private final TimestampValidationStrategy timestampValidationStrategy;
-    private final boolean allowOptionalNull;
     private String eventId;
     private Node value;
     private ShapeId eventShapeId;
@@ -85,9 +85,8 @@ public final class NodeValidationVisitor implements ShapeVisitor<List<Validation
     private NodeValidationVisitor(Builder builder) {
         this.model = SmithyBuilder.requiredState("model", builder.model);
         this.nullableIndex = NullableIndex.of(model);
-        this.validationContext = new NodeValidatorPlugin.Context(model, builder.features.copy());
+        this.validationContext = new NodeValidatorPlugin.Context(model, Feature.enumSet(builder.features));
         this.timestampValidationStrategy = builder.timestampValidationStrategy;
-        this.allowOptionalNull = builder.allowOptionalNull;
         setValue(SmithyBuilder.requiredState("value", builder.value));
         setStartingContext(builder.contextText);
         setValue(builder.value);
@@ -98,7 +97,6 @@ public final class NodeValidationVisitor implements ShapeVisitor<List<Validation
     /**
      * Features to use when validating.
      */
-    // TODO Move other features here like allowOptionalNull.
     public enum Feature {
         /**
          * Emit a warning when a range trait is incompatible with a default value of 0.
@@ -109,8 +107,17 @@ public final class NodeValidationVisitor implements ShapeVisitor<List<Validation
          */
         RANGE_TRAIT_ZERO_VALUE_WARNING,
 
-        // Lowers severity of constraint trait validations to WARNING.
-        ALLOW_CONSTRAINT_ERRORS;
+        /**
+         * Lowers severity of constraint trait validations to WARNING.
+         */
+        ALLOW_CONSTRAINT_ERRORS,
+
+        /**
+         * Allows null values to be provided for an optional structure member.
+         *
+         * <p>By default, null values are not allowed for optional types.
+         */
+        ALLOW_OPTIONAL_NULLS;
 
         public static Feature fromNode(Node node) {
             return Feature.valueOf(node.expectStringNode().getValue());
@@ -119,6 +126,10 @@ public final class NodeValidationVisitor implements ShapeVisitor<List<Validation
         public static Node toNode(Feature feature) {
             return StringNode.from(feature.toString());
         }
+
+        private static EnumSet<Feature> enumSet(Collection<Feature> features) {
+            return features.isEmpty() ? EnumSet.noneOf(Feature.class) : EnumSet.copyOf(features);
+        }
     }
 
     public static Builder builder() {
@@ -169,7 +180,6 @@ public final class NodeValidationVisitor implements ShapeVisitor<List<Validation
         builder.model(model);
         builder.startingContext(startingContext.isEmpty() ? segment : (startingContext + "." + segment));
         builder.timestampValidationStrategy(timestampValidationStrategy);
-        builder.allowOptionalNull(allowOptionalNull);
         NodeValidationVisitor visitor = new NodeValidationVisitor(builder);
         // Use the same validation context.
         visitor.validationContext = this.validationContext;
@@ -395,7 +405,7 @@ public final class NodeValidationVisitor implements ShapeVisitor<List<Validation
 
     private List<ValidationEvent> invalidShape(Shape shape, NodeType expectedType) {
         // Nullable shapes allow null values.
-        if (allowOptionalNull && value.isNullNode()) {
+        if (value.isNullNode() && validationContext.hasFeature(Feature.ALLOW_OPTIONAL_NULLS)) {
             // Non-members are nullable. Members are nullable based on context.
             if (!shape.isMemberShape() || shape.asMemberShape().filter(nullableIndex::isMemberNullable).isPresent()) {
                 return Collections.emptyList();
@@ -468,8 +478,7 @@ public final class NodeValidationVisitor implements ShapeVisitor<List<Validation
         private Node value;
         private Model model;
         private TimestampValidationStrategy timestampValidationStrategy = TimestampValidationStrategy.FORMAT;
-        private boolean allowOptionalNull;
-        private final BuilderRef<Set<Feature>> features = BuilderRef.forUnorderedSet();
+        private final Set<Feature> features = new HashSet<>();
 
         Builder() {}
 
@@ -550,18 +559,14 @@ public final class NodeValidationVisitor implements ShapeVisitor<List<Validation
             return allowOptionalNull(allowBoxedNull);
         }
 
-        /**
-         * Configure how null values are handled when they are provided for
-         * optional types.
-         *
-         * <p>By default, null values are not allowed for optional types.
-         *
-         * @param allowOptionalNull Set to true to allow null values for optional shapes.
-         * @return Returns the builder.
-         */
+        @Deprecated
         public Builder allowOptionalNull(boolean allowOptionalNull) {
-            this.allowOptionalNull = allowOptionalNull;
-            return this;
+            if (allowOptionalNull) {
+                return addFeature(Feature.ALLOW_OPTIONAL_NULLS);
+            } else {
+                features.remove(Feature.ALLOW_OPTIONAL_NULLS);
+                return this;
+            }
         }
 
         /**
@@ -570,9 +575,8 @@ public final class NodeValidationVisitor implements ShapeVisitor<List<Validation
          * @param feature Feature to set.
          * @return Returns the builder.
          */
-        @SmithyInternalApi
         public Builder addFeature(Feature feature) {
-            this.features.get().add(feature);
+            this.features.add(feature);
             return this;
         }
 


Test patch:
diff --git a/smithy-aws-endpoints/src/test/java/software/amazon/smithy/rulesengine/aws/traits/StandardPartitionalEndpointsTraitTest.java b/smithy-aws-endpoints/src/test/java/software/amazon/smithy/rulesengine/aws/traits/StandardPartitionalEndpointsTraitTest.java
index 48108e249..63b553faa 100644
--- a/smithy-aws-endpoints/src/test/java/software/amazon/smithy/rulesengine/aws/traits/StandardPartitionalEndpointsTraitTest.java
+++ b/smithy-aws-endpoints/src/test/java/software/amazon/smithy/rulesengine/aws/traits/StandardPartitionalEndpointsTraitTest.java
@@ -27,19 +27,19 @@ class StandardPartitionalEndpointsTraitTest {
 
         trait = getTraitFromService(model, "ns.foo#Service2");
 
-        assertEquals(trait.getEndpointPatternType(), EndpointPatternType.SERVICE_REGION_DNSSUFFI);
+        assertEquals(trait.getEndpointPatternType(), EndpointPatternType.SERVICE_REGION_DNSSUFFIX);
         assertEquals(trait.getPartitionEndpointSpecialCases().size(), 1);
 
         List<PartitionEndpointSpecialCase> cases = trait.getPartitionEndpointSpecialCases().get("aws-us-gov");
 
         PartitionEndpointSpecialCase case1 = cases.get(0);
-        assertEquals(case1.getEndpoint(), "myservice.{region}.{dnsSuffix}");
+        assertEquals(case1.getEndpoint(), "https://myservice.{region}.{dnsSuffix}");
         assertEquals(case1.getFips(), true);
         assertEquals(case1.getRegion(), "us-east-1");
         assertNull(case1.getDualStack());
 
         PartitionEndpointSpecialCase case2 = cases.get(1);
-        assertEquals(case2.getEndpoint(), "myservice.global.amazonaws.com");
+        assertEquals(case2.getEndpoint(), "https://myservice.global.amazonaws.com");
         assertEquals(case2.getDualStack(), true);
         assertEquals(case2.getRegion(), "us-west-2");
         assertNull(case2.getFips());
diff --git a/smithy-aws-endpoints/src/test/java/software/amazon/smithy/rulesengine/aws/traits/StandardRegionalEndpointsTraitTest.java b/smithy-aws-endpoints/src/test/java/software/amazon/smithy/rulesengine/aws/traits/StandardRegionalEndpointsTraitTest.java
index 3fa40fc7d..9630ecd4c 100644
--- a/smithy-aws-endpoints/src/test/java/software/amazon/smithy/rulesengine/aws/traits/StandardRegionalEndpointsTraitTest.java
+++ b/smithy-aws-endpoints/src/test/java/software/amazon/smithy/rulesengine/aws/traits/StandardRegionalEndpointsTraitTest.java
@@ -35,12 +35,12 @@ class StandardRegionalEndpointsTraitTest {
         List<PartitionSpecialCase> partitionSpecialCases = trait.getPartitionSpecialCases().get("aws-us-gov");
 
         PartitionSpecialCase partitionSpecialCase1 = partitionSpecialCases.get(0);
-        assertEquals(partitionSpecialCase1.getEndpoint(), "myservice.{region}.{dnsSuffix}");
+        assertEquals(partitionSpecialCase1.getEndpoint(), "https://myservice.{region}.{dnsSuffix}");
         assertEquals(partitionSpecialCase1.getFips(), true);
         assertNull(partitionSpecialCase1.getDualStack());
 
         PartitionSpecialCase partitionSpecialCase2 = partitionSpecialCases.get(1);
-        assertEquals(partitionSpecialCase2.getEndpoint(), "myservice.global.amazonaws.com");
+        assertEquals(partitionSpecialCase2.getEndpoint(), "https://myservice.global.amazonaws.com");
         assertEquals(partitionSpecialCase2.getDualStack(), true);
         assertNull(partitionSpecialCase2.getFips());
 
diff --git a/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/language/functions/errorfiles/invalid/invalid-endpoint-patterns.errors b/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/language/functions/errorfiles/invalid/invalid-endpoint-patterns.errors
new file mode 100644
index 000000000..a674db8a8
--- /dev/null
+++ b/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/language/functions/errorfiles/invalid/invalid-endpoint-patterns.errors
@@ -0,0 +1,10 @@
+[WARNING] example#Service1: This shape applies a trait that is unstable: aws.endpoints#standardRegionalEndpoints | UnstableTrait.aws.endpoints#standardRegionalEndpoints
+[DANGER] example#Service1: Endpoint `https://myservice.{invalid}.{dnsSuffix}` contains unsupported patterns: {invalid} | AwsSpecialCaseEndpoint.UnsupportedEndpointPattern
+[DANGER] example#Service1: Endpoint `{invalid}-fips.{region}.{badSuffix}` contains unsupported patterns: {invalid}, {badSuffix} | AwsSpecialCaseEndpoint.UnsupportedEndpointPattern
+[DANGER] example#Service1: Endpoint `{invalid}-fips.{region}.{badSuffix}` should start with scheme `http://` or `https://` | AwsSpecialCaseEndpoint.InvalidEndpointPatternScheme
+[ERROR] example#Service1: Endpoint `{invalid}-fips.{region}.{badSuffix}` should be a valid URL. | AwsSpecialCaseEndpoint.InvalidEndpointPatternUrl
+[ERROR] example#Service1: Endpoint `https://{region}.   invalidurl   {dnsSuffix}` should be a valid URL. | AwsSpecialCaseEndpoint.InvalidEndpointPatternUrl
+[WARNING] example#Service2: This shape applies a trait that is unstable: aws.endpoints#standardPartitionalEndpoints | UnstableTrait.aws.endpoints#standardPartitionalEndpoints
+[DANGER] example#Service2: Endpoint `myservice.{invalid}.{dnsSuffix}` should start with scheme `http://` or `https://` | AwsSpecialCaseEndpoint.InvalidEndpointPatternScheme
+[DANGER] example#Service2: Endpoint `myservice.{invalid}.{dnsSuffix}` contains unsupported patterns: {invalid} | AwsSpecialCaseEndpoint.UnsupportedEndpointPattern
+[ERROR] example#Service2: Endpoint `myservice.{invalid}.{dnsSuffix}` should be a valid URL. | AwsSpecialCaseEndpoint.InvalidEndpointPatternUrl
diff --git a/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/language/functions/errorfiles/invalid/invalid-endpoint-patterns.smithy b/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/language/functions/errorfiles/invalid/invalid-endpoint-patterns.smithy
new file mode 100644
index 000000000..b735abe8c
--- /dev/null
+++ b/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/language/functions/errorfiles/invalid/invalid-endpoint-patterns.smithy
@@ -0,0 +1,42 @@
+$version: "1.0"
+
+namespace example
+
+use aws.endpoints#standardRegionalEndpoints
+use aws.endpoints#standardPartitionalEndpoints
+
+@standardRegionalEndpoints(
+    regionSpecialCases: {
+        "us-east-1": [
+            {
+                endpoint: "https://myservice.{invalid}.{dnsSuffix}",
+            }
+        ]
+    },
+    partitionSpecialCases: {
+        "aws": [
+            {
+                endpoint: "{invalid}-fips.{region}.{badSuffix}",
+                fips: true
+            },
+            {
+                endpoint: "https://{region}.   invalidurl   {dnsSuffix}",
+                dualStack: true
+            }
+        ]
+    }
+)
+service Service1 {}
+
+@standardPartitionalEndpoints(
+    endpointPatternType: "service_dnsSuffix",
+    partitionEndpointSpecialCases: {
+        "aws": [
+            {
+                endpoint: "myservice.{invalid}.{dnsSuffix}",
+                region: "us-east-1"
+            }
+        ]
+    }
+)
+service Service2 {}
diff --git a/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/endpointModifierIndex.smithy b/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/endpointModifierIndex.smithy
index 8d8cc1be7..4e7ba4dbc 100644
--- a/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/endpointModifierIndex.smithy
+++ b/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/endpointModifierIndex.smithy
@@ -21,11 +21,11 @@ service Service2 {
     partitionSpecialCases: {
         "aws-us-gov": [
             {
-                endpoint: "myservice.{region}.{dnsSuffix}",
+                endpoint: "https://myservice.{region}.{dnsSuffix}",
                 dualStack: true
             },
             {
-                endpoint: "myservice.global.amazonaws.com",
+                endpoint: "https://myservice.global.amazonaws.com",
                 dualStack: true
             }
         ]
@@ -45,12 +45,12 @@ service Service3 {
     partitionEndpointSpecialCases: {
         "aws-us-gov": [
             {
-                endpoint: "myservice.{region}.{dnsSuffix}",
+                endpoint: "https://myservice.{region}.{dnsSuffix}",
                 region: "us-east-1"
                 fips: true
             },
             {
-                endpoint: "myservice.global.amazonaws.com",
+                endpoint: "https://myservice.global.amazonaws.com",
                 region: "us-west-2"
                 dualStack: true
             }
diff --git a/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/errorfiles/standard-partitional-endpoints-trait.smithy b/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/errorfiles/standard-partitional-endpoints-trait.smithy
index 8e6bcdaed..af7abb03e 100644
--- a/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/errorfiles/standard-partitional-endpoints-trait.smithy
+++ b/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/errorfiles/standard-partitional-endpoints-trait.smithy
@@ -7,8 +7,8 @@ use aws.endpoints#standardPartitionalEndpoints
 @standardPartitionalEndpoints(
     endpointPatternType: "service_dnsSuffix",
     partitionEndpointSpecialCases: {
-        "aws-us": [{endpoint: "myservice.us-west-2.amazonaws.com", region: "us-west-2"}],
-        "aws-cn": [{endpoint: "myservice.cn-north-1.amazonaws.com", region: "cn-north-1"}]
+        "aws-us": [{endpoint: "https://myservice.us-west-2.amazonaws.com", region: "us-west-2"}],
+        "aws-cn": [{endpoint: "https://myservice.cn-north-1.amazonaws.com", region: "cn-north-1"}]
     }
 )
 service MyService {
diff --git a/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/errorfiles/standard-regional-endpoints-trait.smithy b/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/errorfiles/standard-regional-endpoints-trait.smithy
index 7d5bba101..94db0e947 100644
--- a/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/errorfiles/standard-regional-endpoints-trait.smithy
+++ b/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/errorfiles/standard-regional-endpoints-trait.smithy
@@ -8,7 +8,7 @@ use aws.endpoints#standardRegionalEndpoints
     partitionSpecialCases: {
         "aws-us-gov": [
             {
-                endpoint: "myservice.{region}.{dnsSuffix}",
+                endpoint: "https://myservice.{region}.{dnsSuffix}",
                 fips: true
             }
         ]
diff --git a/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/standardPartitionalEndpoints.smithy b/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/standardPartitionalEndpoints.smithy
index 2597dcec5..e4f5643c2 100644
--- a/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/standardPartitionalEndpoints.smithy
+++ b/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/standardPartitionalEndpoints.smithy
@@ -14,12 +14,12 @@ service Service1 {
     partitionEndpointSpecialCases: {
         "aws-us-gov": [
             {
-                endpoint: "myservice.{region}.{dnsSuffix}",
+                endpoint: "https://myservice.{region}.{dnsSuffix}",
                 region: "us-east-1"
                 fips: true
             },
             {
-                endpoint: "myservice.global.amazonaws.com",
+                endpoint: "https://myservice.global.amazonaws.com",
                 region: "us-west-2"
                 dualStack: true
             }
diff --git a/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/standardRegionalEndpoints.smithy b/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/standardRegionalEndpoints.smithy
index 2bd3bf3f5..37bf9547a 100644
--- a/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/standardRegionalEndpoints.smithy
+++ b/smithy-aws-endpoints/src/test/resources/software/amazon/smithy/rulesengine/aws/traits/standardRegionalEndpoints.smithy
@@ -18,11 +18,11 @@ service Service2 {
     partitionSpecialCases: {
         "aws-us-gov": [
             {
-                endpoint: "myservice.{region}.{dnsSuffix}",
+                endpoint: "https://myservice.{region}.{dnsSuffix}",
                 fips: true
             },
             {
-                endpoint: "myservice.global.amazonaws.com",
+                endpoint: "https://myservice.global.amazonaws.com",
                 dualStack: true
             }
         ]
diff --git a/smithy-aws-protocol-tests/model/restXml/document-maps.smithy b/smithy-aws-protocol-tests/model/restXml/document-maps.smithy
index 253c97aed..fc2541a03 100644
--- a/smithy-aws-protocol-tests/model/restXml/document-maps.smithy
+++ b/smithy-aws-protocol-tests/model/restXml/document-maps.smithy
@@ -72,7 +72,7 @@ apply XmlMaps @httpResponseTests([
         protocol: restXml,
         code: 200,
         body: """
-              <XmlMapsRequest>
+              <XmlMapsResponse>
                   <myMap>
                       <entry>
                           <key>foo</key>
@@ -87,7 +87,7 @@ apply XmlMaps @httpResponseTests([
                           </value>
                       </entry>
                   </myMap>
-              </XmlMapsRequest>
+              </XmlMapsResponse>
               """,
         bodyMediaType: "application/xml",
         headers: {
diff --git a/smithy-aws-protocol-tests/model/restXml/document-structs.smithy b/smithy-aws-protocol-tests/model/restXml/document-structs.smithy
index 8fa9e94ee..b06dcf8d0 100644
--- a/smithy-aws-protocol-tests/model/restXml/document-structs.smithy
+++ b/smithy-aws-protocol-tests/model/restXml/document-structs.smithy
@@ -577,9 +577,9 @@ apply XmlEmptyBlobs @httpResponseTests([
         protocol: restXml,
         code: 200,
         body: """
-              <XmlBlobsRequest>
+              <XmlEmptyBlobsResponse>
                   <data></data>
-              </XmlBlobsRequest>
+              </XmlEmptyBlobsResponse>
               """,
         bodyMediaType: "application/xml",
         headers: {
@@ -596,9 +596,9 @@ apply XmlEmptyBlobs @httpResponseTests([
         protocol: restXml,
         code: 200,
         body: """
-              <XmlBlobsResponse>
+              <XmlEmptyBlobsResponse>
                   <data/>
-              </XmlBlobsResponse>
+              </XmlEmptyBlobsResponse>
               """,
         bodyMediaType: "application/xml",
         headers: {
diff --git a/smithy-cli/src/it/java/software/amazon/smithy/cli/MavenResolverTest.java b/smithy-cli/src/it/java/software/amazon/smithy/cli/MavenResolverTest.java
index d856d7e62..f3e4d122b 100644
--- a/smithy-cli/src/it/java/software/amazon/smithy/cli/MavenResolverTest.java
+++ b/smithy-cli/src/it/java/software/amazon/smithy/cli/MavenResolverTest.java
@@ -6,13 +6,16 @@ import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.is;
 import static org.hamcrest.Matchers.not;
 import static org.mockserver.integration.ClientAndServer.startClientAndServer;
+import static org.mockserver.integration.ClientAndProxy.startClientAndProxy;
 
 import java.io.File;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.util.Collections;
+import java.util.Map;
 import org.junit.jupiter.api.Test;
+import org.mockserver.integration.ClientAndProxy;
 import org.mockserver.integration.ClientAndServer;
 import org.mockserver.model.HttpRequest;
 import org.mockserver.model.HttpResponse;
@@ -107,6 +110,87 @@ public class MavenResolverTest {
         }
     }
 
+    @Test
+    public void usesProxyConfiguration() {
+        ClientAndServer mockServer = null;
+        ClientAndProxy proxyServer = null;
+        try {
+            mockServer = startClientAndServer(1234);
+            proxyServer = startClientAndProxy(2323);
+
+            HttpRequest request = HttpRequest.request()
+                    .withMethod("GET")
+                    .withPath("/maven/not/there/software/amazon/smithy/smithy-aws-iam-traits/.*\\.jar");
+
+            mockServer.when(request).respond(
+                    HttpResponse
+                            .response()
+                            .withStatusCode(200)
+                            .withBody("FAKE JAR CONTENT")
+            );
+
+            IntegUtils.runWithEmptyCache("maven-proxy", ListUtils.of("validate", "--debug"),
+                    Collections.emptyMap(), result -> {
+                        assertThat(result.getExitCode(), equalTo(0));
+                        assertThat(result.getOutput(), containsString("Resolving Maven dependencies for Smithy CLI"));
+                        assertThat(result.getOutput(),
+                                containsString("Opening connection {}->http://localhost:2323->http://localhost:1234"));
+                        assertThat(result.getOutput(), containsString("Connecting to localhost/127.0.0.1:2323"));
+                    });
+            // Check that expected request was forwarded by the proxy
+            proxyServer.verify(request);
+
+        } finally {
+            if (mockServer != null) {
+                mockServer.stop();
+            }
+            if (proxyServer != null) {
+                proxyServer.stop();
+            }
+        }
+    }
+
+    @Test
+    public void usesProxyConfigurationFromEnv() {
+        ClientAndServer mockServer = null;
+        ClientAndProxy proxyServer = null;
+        try {
+            mockServer = startClientAndServer(1234);
+            proxyServer = startClientAndProxy(2323);
+
+            HttpRequest request = HttpRequest.request()
+                    .withMethod("GET")
+                    .withPath("/maven/not/there/software/amazon/smithy/smithy-aws-iam-traits/.*\\.jar");
+
+            mockServer.when(request).respond(
+                    HttpResponse
+                            .response()
+                            .withStatusCode(200)
+                            .withBody("FAKE JAR CONTENT")
+            );
+
+            Map<String, String> envMap = MapUtils.of(EnvironmentVariable.SMITHY_PROXY_HOST.toString(), "http://localhost:2323");
+            IntegUtils.runWithEmptyCache("maven-proxy-env", ListUtils.of("validate", "--debug"),
+                    envMap, result -> {
+                        assertThat(result.getExitCode(), equalTo(0));
+                        assertThat(result.getOutput(), containsString("Resolving Maven dependencies for Smithy CLI"));
+                        assertThat(result.getOutput(),
+                                containsString("Opening connection {}->http://localhost:2323->http://localhost:1234"));
+                        assertThat(result.getOutput(), containsString("Connecting to localhost/127.0.0.1:2323"));
+                    });
+            // Check that expected request was forwarded by the proxy
+            proxyServer.verify(request);
+
+        } finally {
+            if (mockServer != null) {
+                mockServer.stop();
+            }
+            if (proxyServer != null) {
+                proxyServer.stop();
+            }
+        }
+    }
+
     @Test
     public void ignoresEmptyCacheFiles() {
         IntegUtils.withProject("aws-model", path -> {
diff --git a/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/ProtocolTestCaseValidator.java b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/ProtocolTestCaseValidator.java
index c2c066240..5c630d30f 100644
--- a/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/ProtocolTestCaseValidator.java
+++ b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/ProtocolTestCaseValidator.java
@@ -160,7 +160,7 @@ abstract class ProtocolTestCaseValidator<T extends Trait> extends AbstractValida
                 .startingContext(traitId + "." + position + contextSuffix)
                 .eventId(getName())
                 .timestampValidationStrategy(TimestampValidationStrategy.EPOCH_SECONDS)
-                .allowOptionalNull(true)
+                .addFeature(NodeValidationVisitor.Feature.ALLOW_OPTIONAL_NULLS)
                 .build();
     }
 
diff --git a/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/SmokeTestCaseValidator.java b/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/SmokeTestCaseValidator.java
index e8e6f87f5..0722c2633 100644
--- a/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/SmokeTestCaseValidator.java
+++ b/smithy-smoke-test-traits/src/main/java/software/amazon/smithy/smoketests/traits/SmokeTestCaseValidator.java
@@ -106,7 +106,7 @@ public class SmokeTestCaseValidator extends AbstractValidator {
                 .startingContext(SmokeTestsTrait.ID + "." + caseId + contextSuffix)
                 .eventId(getName())
                 .timestampValidationStrategy(TimestampValidationStrategy.EPOCH_SECONDS)
-                .allowOptionalNull(true)
+                .addFeature(NodeValidationVisitor.Feature.ALLOW_OPTIONAL_NULLS)
                 .build();
     }
 }